page wrapper

This commit is contained in:
2026-01-07 12:40:52 +01:00
parent e589c30bf8
commit 0db80fa98d
128 changed files with 7386 additions and 8096 deletions

View File

@@ -1,203 +0,0 @@
# Authentication Loading State Fixes
## Problem
Users were experiencing an infinite "loading" state when accessing protected routes like `/dashboard`. The page would show "Loading..." indefinitely instead of either displaying the content or redirecting to login.
## Root Cause Analysis
The issue was caused by a mismatch between multiple authentication state management systems:
1. **AuthContext**: Managed session state and loading flag
2. **AuthorizationBlocker**: Determined access reasons based on session state
3. **AuthGateway**: Combined context and blocker state
4. **RouteGuard**: Handled UI rendering and redirects
### The Problem Flow:
```
1. User visits /dashboard
2. AuthContext initializes: session = null, loading = false
3. AuthGuard checks access
4. AuthorizationBlocker sees session = null → returns 'loading'
5. AuthGateway sees blocker.reason = 'loading' → sets isLoading = true
6. RouteGuard shows loading state
7. Session fetch completes: session = null, loading = false
8. But blocker still returns 'loading' because session is null
9. Infinite loading state
```
## Fixes Applied
### 1. AuthContext.tsx
**Problem**: Initial loading state was `false`, but session fetch wasn't tracked
**Fix**:
```typescript
// Before
const [loading, setLoading] = useState(false);
const fetchSession = useCallback(async () => {
try {
const current = await sessionService.getSession();
setSession(current);
} catch {
setSession(null);
}
}, [sessionService]);
// After
const [loading, setLoading] = useState(true); // Start with loading = true
const fetchSession = useCallback(async () => {
setLoading(true); // Set loading when starting fetch
try {
const current = await sessionService.getSession();
setSession(current);
} catch {
setSession(null);
} finally {
setLoading(false); // Clear loading when done
}
}, [sessionService]);
```
### 2. AuthGateway.ts
**Problem**: Was checking both `authContext.loading` AND `blocker.reason === 'loading'`
**Fix**: Only check authContext.loading for the isLoading state
```typescript
// Before
isLoading: this.authContext.loading || reason === 'loading',
// After
isLoading: this.authContext.loading,
```
### 3. AuthorizationBlocker.ts
**Problem**: Returned 'loading' when session was null, creating confusion
**Fix**: Treat null session as unauthenticated, not loading
```typescript
// Before
getReason(): AuthorizationBlockReason {
if (!this.currentSession) {
return 'loading';
}
// ...
}
// After
getReason(): AuthorizationBlockReason {
if (!this.currentSession) {
return 'unauthenticated'; // Null = unauthenticated
}
// ...
}
canExecute(): boolean {
const reason = this.getReason();
return reason === 'enabled'; // Only enabled grants access
}
```
### 4. RouteGuard.tsx
**Problem**: Generic loading message, unclear redirect flow
**Fix**: Better user feedback during authentication flow
```typescript
// Loading state shows verification message
if (accessState.isLoading) {
return loadingComponent || (
<div className="flex items-center justify-center min-h-screen">
<LoadingState message="Verifying authentication..." className="min-h-screen" />
</div>
);
}
// Unauthorized shows redirect message before redirecting
if (!accessState.canAccess && config.redirectOnUnauthorized !== false) {
return (
<div className="flex items-center justify-center min-h-screen">
<LoadingState message="Redirecting to login..." className="min-h-screen" />
</div>
);
}
```
### 5. Dashboard Page
**Problem**: Had redundant auth checks that conflicted with layout protection
**Fix**: Simplified to only handle data loading
```typescript
// Before: Had auth checks, useEffect for redirects, etc.
export default function DashboardPage() {
const router = useRouter();
const { session, loading: authLoading } = useAuth();
// ... complex auth logic
// After: Only handles data loading
export default function DashboardPage() {
const { data: dashboardData, isLoading, error } = useDashboardOverview();
// ... simple data loading
}
```
## New Authentication Flow
### Unauthenticated User:
1. User visits `/dashboard`
2. Middleware checks for `gp_session` cookie → not found
3. Middleware redirects to `/auth/login?returnTo=/dashboard`
4. User logs in
5. Session created, cookie set
6. Redirected back to `/dashboard`
7. AuthGuard verifies session exists
8. Dashboard loads
### Authenticated User:
1. User visits `/dashboard`
2. Middleware checks for `gp_session` cookie → found
3. Request proceeds to page rendering
4. AuthGuard shows "Verifying authentication..." (briefly)
5. Session verified via AuthContext
6. AuthGuard shows "Redirecting to login..." (if unauthorized)
7. Or renders dashboard content
### Loading State Resolution:
```
Initial: session=null, loading=true → AuthGuard shows "Verifying..."
Fetch completes: session=null, loading=false → AuthGuard redirects to login
```
## Files Modified
1. `apps/website/lib/auth/AuthContext.tsx` - Fixed loading state management
2. `apps/website/lib/gateways/AuthGateway.ts` - Simplified isLoading logic
3. `apps/website/lib/blockers/AuthorizationBlocker.ts` - Removed 'loading' reason
4. `apps/website/lib/gateways/RouteGuard.tsx` - Improved user feedback
5. `apps/website/app/dashboard/page.tsx` - Removed redundant auth checks
6. `apps/website/app/dashboard/layout.tsx` - Added AuthGuard protection
7. `apps/website/app/profile/layout.tsx` - Added AuthGuard protection
8. `apps/website/app/sponsor/layout.tsx` - Added AuthGuard protection
9. `apps/website/app/onboarding/layout.tsx` - Added AuthGuard protection
10. `apps/website/app/admin/layout.tsx` - Added RouteGuard protection
## Testing the Fix
### Expected Behavior:
- **Unauthenticated access**: Redirects to login within 500ms
- **Authenticated access**: Shows dashboard after brief verification
- **No infinite loading**: Loading states resolve properly
### Test Scenarios:
1. Clear cookies, visit `/dashboard` → Should redirect to login
2. Login, visit `/dashboard` → Should show dashboard
3. Login, clear cookies, refresh → Should redirect to login
4. Login as non-admin, visit `/admin` → Should redirect to login
## Security Notes
- **Defense in depth**: Multiple protection layers (middleware + layout + page)
- **No security bypass**: All fixes maintain security requirements
- **User experience**: Clear feedback during authentication flow
- **Performance**: Minimal overhead, only necessary checks
## Future Improvements
1. Add role-based access to SessionViewModel
2. Implement proper backend role system
3. Add session refresh mechanism
4. Implement proper token validation
5. Add authentication state persistence

File diff suppressed because it is too large Load Diff

View File

@@ -1,227 +0,0 @@
# Developer Experience Enhancement - Summary
## Task Completed: ✅
Enhanced the developer experience in apps/website with maximum transparency for development environments.
## Files Created
### Infrastructure Layer
1. **`lib/infrastructure/GlobalErrorHandler.ts`** (338 lines)
- Global error handler for uncaught errors and promise rejections
- Enhanced stack traces with source map support
- Dev overlay system with detailed error information
- Error history and statistics
2. **`lib/infrastructure/ApiRequestLogger.ts`** (284 lines)
- API request/response logging interceptor
- Automatic fetch override for transparent logging
- Sensitive data masking
- Request history and performance metrics
3. **`lib/infrastructure/ErrorReplay.ts`** (228 lines)
- Complete error replay system
- Context capture (environment, API calls, React errors)
- Persistence in localStorage
- Replay execution and export capabilities
### Components Layer
4. **`components/errors/EnhancedErrorBoundary.tsx`** (350 lines)
- React error boundary with global handler integration
- React-specific error overlays
- Component stack trace capture
- Hook-based alternative for functional components
5. **`components/errors/ErrorAnalyticsDashboard.tsx`** (450 lines)
- Real-time error analytics dashboard
- Multi-tab interface (Errors, API, Environment, Raw)
- Search and filter capabilities
- Export and maintenance tools
6. **`components/dev/DebugModeToggle.tsx`** (350 lines)
- Floating debug control panel
- Real-time metrics display
- Test action triggers
- Global variable exposure
7. **`components/dev/sections/ReplaySection.tsx`** (120 lines)
- Replay management interface
- Export, copy, delete functionality
- Integration with DevToolbar
### Documentation
8. **`docs/DEVELOPER_EXPERIENCE.md`** (250 lines)
- Comprehensive documentation
- Usage examples
- Best practices
- Troubleshooting guide
9. **`DEVELOPER_EXPERIENCE_SUMMARY.md`** (This file)
- Summary of all changes
- Quick reference
## Files Modified
### Layout Integration
10. **`app/layout.tsx`**
- Added imports for all new components
- Integrated global error handler initialization
- Added API logger initialization
- Wrapped app with EnhancedErrorBoundary
- Added development tools (DebugModeToggle, ErrorAnalyticsDashboard)
11. **`lib/api/base/ApiError.ts`**
- Extended ApiErrorContext interface with source and componentStack properties
12. **`components/dev/DevToolbar.tsx`**
- Added ReplaySection import
- Added Play icon import
- Added Replay accordion section
## Key Features Implemented
### ✅ 1. Enhanced Global Error Handler
- Captures all uncaught JavaScript errors
- Handles unhandled promise rejections
- Console.error override
- Enhanced stack traces
- Dev overlays with full context
### ✅ 2. React Error Boundary
- Component-level error handling
- React-specific error information
- Integration with global handler
- Recovery mechanisms
### ✅ 3. Comprehensive Dev Overlays
- Full-screen error details
- Environment information
- Stack traces with source maps
- Quick actions (copy, reload, log)
- Keyboard shortcuts (ESC, ENTER)
### ✅ 4. API Request Logging
- Transparent fetch interception
- Request/response timing
- Sensitive data masking
- Retry tracking
- Performance metrics
### ✅ 5. Environment Detection
- Automatic dev/production detection
- Conditional feature activation
- Persistent debug state (localStorage)
### ✅ 6. Stack Trace Enhancement
- Source map information
- Enhanced formatting
- Component hierarchy
- Context enrichment
### ✅ 7. Error Analytics Dashboard
- Real-time statistics
- Multi-tab interface
- Search and filter
- Export capabilities
- Maintenance tools
### ✅ 8. Real-time Monitoring Panel
- Floating debug toggle
- Live metrics
- Test actions
- Global variable access
### ✅ 9. Error Replay System
- Complete context capture
- Persistence across sessions
- Replay execution
- Export and sharing
### ✅ 10. Environment Information Display
- Browser details
- Platform information
- Performance metrics
- Network status
## Development Experience Workflow
### Normal Development:
1. Start development server
2. Debug tools auto-initialize
3. All logging active
4. Errors show overlays
5. Dashboard provides real-time insights
### Error Occurs:
1. Dev overlay appears immediately
2. Console shows detailed logs
3. Analytics dashboard updates
4. Replay is auto-captured
5. All context preserved
### Debugging:
1. Use DebugModeToggle for quick access
2. Check Analytics Dashboard for patterns
3. Use Replay section to reproduce
4. Export data for sharing
5. Clear logs as needed
## Production Safety
All features are **development-only** by default:
- Zero overhead in production
- No console pollution
- No storage usage
- No performance impact
- Can be explicitly enabled if needed
## Quick Access Points
### Floating Controls:
- **Debug Mode Toggle** (bottom-left) - Main control panel
- **Error Analytics** (bottom-left) - Analytics dashboard
- **DevToolbar** (bottom-right) - Existing dev tools
### Console Access:
```javascript
// Global handlers
window.__GRIDPILOT_GLOBAL_HANDLER__
window.__GRIDPILOT_API_LOGGER__
window.__GRIDPILOT_REACT_ERRORS__
```
### Programmatic Access:
```typescript
import { getGlobalErrorHandler } from '@/lib/infrastructure/GlobalErrorHandler';
import { getGlobalApiLogger } from '@/lib/infrastructure/ApiRequestLogger';
import { getGlobalReplaySystem } from '@/lib/infrastructure/ErrorReplay';
```
## Testing the Enhancement
To verify everything works:
1. **Start dev server**: `npm run dev`
2. **Trigger an error**: Use DebugModeToggle "Test Error"
3. **Check overlay**: Should appear with full details
4. **Open dashboard**: Click Error Analytics icon
5. **View replay**: Check Replay section in DevToolbar
6. **Test API**: Use "Test API" button
7. **Check logs**: All should appear in dashboard
## Benefits
**Maximum Transparency**: Every error is fully documented
**Zero Configuration**: Works out of the box in dev
**Production Safe**: No impact on production builds
**Developer Friendly**: Intuitive UI and controls
**Comprehensive**: Covers all error types and contexts
**Reproducible**: Replay system for bug fixes
**Exportable**: Easy to share debug information
**Real-time**: Live updates and metrics
## Summary
This enhancement provides a complete developer experience suite that gives maximum transparency into errors, API calls, and application state in development environments. All tools are automatically available, require no configuration, and have zero impact on production builds.
The system is designed to make debugging faster, more informative, and more collaborative by providing complete context for every error and the ability to replay exact scenarios.

View File

@@ -1,224 +0,0 @@
# GridPilot Error Handling & UX Enhancement Guide
This guide documents the comprehensive error handling system implemented to improve both end-user experience and developer debugging capabilities.
## Overview
The enhanced error handling system addresses the "property rememberMe should not exist" validation error and provides a robust framework for handling form validation and API errors with clear user feedback and detailed developer information.
## Problem Statement
**Original Issue**: When users attempted to log in, they encountered a cryptic error message "property rememberMe should not exist" displayed directly next to the form. This was caused by:
1. **API Validation**: NestJS ValidationPipe with `forbidNonWhitelisted: true` rejecting unexpected properties
2. **Poor UX**: Raw validation errors shown to users without context or user-friendly messaging
3. **Poor Developer UX**: No debugging information or context for troubleshooting
## Solution Architecture
### 1. Enhanced Error Utilities (`lib/utils/errorUtils.ts`)
**Key Functions**:
- `parseApiError()`: Extracts validation errors and user-friendly messages from API responses
- `formatValidationErrorsForForm()`: Maps API field names to form field names
- `logErrorWithContext()`: Developer-friendly logging with full context
- `createUserErrorSummary()`: Creates user-friendly error summaries
**Features**:
- Automatic detection of validation errors vs general errors
- Support for NestJS validation error format
- Context-aware error classification
- Development-only detailed logging
### 2. Enhanced Form Hook (`lib/hooks/useEnhancedForm.ts`)
**Key Features**:
- Centralized form state management
- Real-time validation with debouncing
- Automatic error mapping from API to form fields
- Developer context logging on errors
- Retry logic for network errors
**Usage Example**:
```typescript
const {
formState,
setFormState,
handleChange,
handleSubmit,
setFormError,
} = useEnhancedForm<LoginFormValues>({
initialValues: { email: '', password: '', rememberMe: false },
validate: validateLoginForm,
component: 'LoginPage',
onSubmit: async (values) => {
await authService.login(values);
},
onError: (error, values) => {
// Custom error handling
},
onSuccess: () => {
// Success logic
},
});
```
### 3. Validation Utilities (`lib/utils/validation.ts`)
**Validation Functions**:
- `emailValidation()`: Email format validation
- `passwordValidation()`: Password strength requirements
- `nameValidation()`: Name format and length validation
- `confirmPasswordValidation()`: Password matching validation
- `checkPasswordStrength()`: Password strength scoring
**Form Validators**:
- `validateLoginForm()`: Login form validation
- `validateSignupForm()`: Signup form validation
### 4. Enhanced Error Display Components
#### `EnhancedFormError` Component
- **User View**: Clear, actionable error messages
- **Developer View**: Toggleable technical details
- **Features**:
- Color-coded severity levels
- Retry buttons for network errors
- Dismissible errors
- Collapsible technical details
- Full error context display
#### `FormErrorSummary` Component
- Compact error display for form submissions
- Quick dismiss action
- Consistent styling
### 5. Updated Login Page (`app/auth/login/page.tsx`)
**Improvements**:
- Uses `useEnhancedForm` hook
- Enhanced error display with `EnhancedFormError`
- Developer details toggle in development
- Better loading states
- Improved form field binding
## Key Benefits
### For End Users
1. **Clear Error Messages**: "Please check your email and password" instead of "property rememberMe should not exist"
2. **Actionable Guidance**: Specific instructions on how to fix issues
3. **Visual Feedback**: Color-coded errors and loading states
4. **Error Recovery**: Retry buttons for network issues
5. **Dismissible Errors**: Users can clear error messages
### For Developers
1. **Detailed Context**: Full error context including endpoint, status, timestamp
2. **Development Tools**: Toggleable technical details panel
3. **Console Logging**: Structured logs with form data and context
4. **Error Classification**: Network vs validation vs server errors
5. **Troubleshooting Hints**: Automatic detection of common issues (CORS, etc.)
## Error Flow
### Login Error Flow
1. **User submits form**`useEnhancedForm.handleSubmit()`
2. **Client validation**`validateLoginForm()`
3. **API call**`authService.login()`
4. **Error occurs**`parseApiError()` extracts details
5. **Display error**`EnhancedFormError` shows user-friendly message
6. **Developer logging**`logErrorWithContext()` with full context
7. **User action** → Retry or dismiss
### Validation Error Flow
1. **API returns 400** → Validation pipe rejects `rememberMe`
2. **Error parsing** → Detects validation error format
3. **Field mapping** → Maps `rememberMe` to form field
4. **Form state update** → Sets field-specific error
5. **User sees** → "Invalid input" with field details
## Implementation Details
### rememberMe Fix
The original issue was that `rememberMe` was being sent in the login request but the API validation was rejecting it. The fix involved:
1. **API DTO**: Already had `@IsOptional()` and `@ApiProperty({ required: false })`
2. **Service**: Already handled `rememberMe` correctly
3. **Root Cause**: Missing proper error handling and user feedback
**Solution**: Enhanced error handling system that gracefully handles validation errors and provides clear feedback.
### Error Context
Every error includes:
```typescript
{
timestamp: string;
component: string;
action: string;
formData?: Record<string, unknown>;
userId?: string;
sessionId?: string;
}
```
### Development vs Production
- **Development**: Full technical details, stack traces, form data
- **Production**: User-friendly messages only, no sensitive data
## Testing Recommendations
1. **Validation Errors**: Test with invalid emails, weak passwords
2. **Network Errors**: Test with CORS issues, timeouts
3. **API Errors**: Test with server errors, rate limiting
4. **Edge Cases**: Empty forms, special characters, long inputs
## Future Enhancements
1. **Error Analytics**: Track error rates and types
2. **Auto-retry**: Automatic retry for transient errors
3. **Error Recovery**: Suggest solutions based on error type
4. **Multi-language**: Error messages in different languages
5. **Accessibility**: Screen reader friendly error announcements
## Files Created/Modified
### New Files
- `lib/utils/errorUtils.ts` - Error parsing and utilities
- `lib/hooks/useEnhancedForm.ts` - Enhanced form hook
- `lib/utils/validation.ts` - Validation rules and functions
- `components/errors/EnhancedFormError.tsx` - Error display component
- `ERROR_HANDLING_GUIDE.md` - This documentation
### Modified Files
- `app/auth/login/page.tsx` - Updated with enhanced error handling
- `lib/api/base/ApiError.ts` - (No changes needed, already robust)
- `components/errors/ErrorDisplay.tsx` - (Existing, enhanced with new patterns)
## Quick Start
To use the enhanced error handling in new forms:
```typescript
// 1. Import utilities
import { useEnhancedForm } from '@/lib/hooks/useEnhancedForm';
import { validateLoginForm } from '@/lib/utils/validation';
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
// 2. Create form
const { formState, handleChange, handleSubmit } = useEnhancedForm({
initialValues: { email: '', password: '' },
validate: validateLoginForm,
onSubmit: async (values) => {
await api.login(values);
},
});
// 3. Display errors
{formState.submitError && (
<EnhancedFormError
error={new Error(formState.submitError)}
onDismiss={() => setFormState(prev => ({ ...prev, submitError: undefined }))}
/>
)}
```
This system provides a foundation for consistent, user-friendly error handling across the entire application.

View File

@@ -1,357 +0,0 @@
# Authentication Protection Strategy
## Overview
GridPilot website implements a **defense-in-depth** authentication protection strategy using both **middleware-level** and **component-level** protection to ensure authenticated routes are properly secured.
## Protection Layers
### 1. Middleware Protection (First Line of Defense)
**File**: `apps/website/middleware.ts`
The middleware provides **server-side** route protection that runs before any page rendering:
```typescript
// Key protection logic
export function middleware(request: NextRequest) {
const mode = getAppMode();
const { pathname } = request.nextUrl;
// Public routes are always accessible
if (isPublicRoute(pathname)) {
return NextResponse.next();
}
// Check for authentication cookie
const cookies = request.cookies;
const hasAuthCookie = cookies.has('gp_session');
// In alpha mode, redirect to login if no session
if (mode === 'alpha' && !hasAuthCookie) {
const loginUrl = new URL('/auth/login', request.url);
loginUrl.searchParams.set('returnTo', pathname);
return NextResponse.redirect(loginUrl);
}
// In pre-launch mode, return 404 for protected routes
return new NextResponse(null, {
status: 404,
statusText: 'Not Found',
});
}
```
**Protected Routes** (not in public list):
- `/dashboard`
- `/profile/*`
- `/onboarding`
- `/sponsor/*`
- `/admin/*`
- All other non-public routes
**Public Routes**:
- `/` (home)
- `/auth/*` (login, signup, etc.)
- `/api/auth/*` (auth API endpoints)
### 2. Component-Level Protection (Second Line of Defense)
**Files**: Layout components in protected routes
Each protected route group has a layout that wraps content with `AuthGuard`:
#### Dashboard Layout
**File**: `apps/website/app/dashboard/layout.tsx`
```typescript
export default function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
```
#### Profile Layout
**File**: `apps/website/app/profile/layout.tsx`
```typescript
export default function ProfileLayout({ children }: ProfileLayoutProps) {
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
```
#### Sponsor Layout
**File**: `apps/website/app/sponsor/layout.tsx`
```typescript
export default function SponsorLayout({ children }: SponsorLayoutProps) {
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
```
#### Onboarding Layout
**File**: `apps/website/app/onboarding/layout.tsx`
```typescript
export default function OnboardingLayout({ children }: OnboardingLayoutProps) {
return (
<AuthGuard redirectPath="/auth/login">
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</AuthGuard>
);
}
```
#### Admin Layout
**File**: `apps/website/app/admin/layout.tsx`
```typescript
export default function AdminLayout({ children }: AdminLayoutProps) {
return (
<RouteGuard config={{ requiredRoles: ['owner', 'admin'] }}>
<div className="min-h-screen bg-deep-graphite">
{children}
</div>
</RouteGuard>
);
}
```
### 3. Page-Level Protection (Third Line of Defense - Defense in Depth)
**File**: `apps/website/app/dashboard/page.tsx`
The dashboard page includes additional client-side verification:
```typescript
export default function DashboardPage() {
const router = useRouter();
const { session, loading: authLoading } = useAuth();
const { data: dashboardData, isLoading, error } = useDashboardOverview();
// Additional client-side auth check (defense in depth)
useEffect(() => {
if (!authLoading && !session) {
router.push('/auth/login?returnTo=/dashboard');
}
}, [session, authLoading, router]);
// Show loading state during auth check
if (authLoading) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-white">Verifying authentication...</div>
</main>
);
}
// Redirect if not authenticated (should be caught by layout, but this is extra safety)
if (!session) {
return null; // Layout will handle redirect
}
// ... rest of dashboard content
}
```
## Authentication Flow
### Unauthenticated User Accessing Protected Route
1. **Middleware Intercept**: User requests `/dashboard`
2. **Cookie Check**: Middleware checks for `gp_session` cookie
3. **Redirect**: If no cookie, redirect to `/auth/login?returnTo=/dashboard`
4. **Login**: User authenticates via login flow
5. **Session Creation**: API creates session, sets `gp_session` cookie
6. **Return**: User redirected back to `/dashboard`
7. **AuthGuard**: Layout verifies session exists
8. **Page Render**: Dashboard content loads
### Authenticated User Accessing Protected Route
1. **Middleware Intercept**: User requests `/dashboard`
2. **Cookie Check**: Middleware finds `gp_session` cookie
3. **Access Granted**: Request proceeds to page rendering
4. **AuthGuard**: Layout verifies session via AuthContext
5. **Page Render**: Dashboard content loads
### Role-Based Access (Admin Routes)
1. **Middleware**: Checks for authentication cookie
2. **RouteGuard**: Verifies user has required roles (`owner`, `admin`)
3. **Access**: Only granted if both conditions pass
## Security Features
### 1. Cookie Security
- Session cookie: `gp_session`
- Secure transmission via HTTPS
- HttpOnly flag (server-side)
- SameSite policy
### 2. Session Validation
- Client-side session verification via AuthContext
- Server-side session validation via API
- Automatic redirect on session loss
### 3. Error Handling
- Loading states during auth verification
- Graceful redirects for unauthorized access
- Clear error messages for users
### 4. Defense in Depth
- Multiple protection layers (middleware + layout + page)
- Each layer independently verifies authentication
- Reduces single point of failure
## Route Protection Matrix
| Route Group | Middleware | Layout Guard | Page-Level Check | Role Required |
|-------------|------------|--------------|------------------|---------------|
| `/` | ✅ Public | ❌ None | ❌ None | None |
| `/auth/*` | ✅ Public | ❌ None | ❌ None | None |
| `/dashboard` | ✅ Protected | ✅ AuthGuard | ✅ Yes | None |
| `/profile/*` | ✅ Protected | ✅ AuthGuard | ❌ None | None |
| `/onboarding` | ✅ Protected | ✅ AuthGuard | ❌ None | None |
| `/sponsor/*` | ✅ Protected | ✅ AuthGuard | ❌ None | None |
| `/admin/*` | ✅ Protected | ✅ RouteGuard | ❌ None | owner/admin |
| `/leagues` | ✅ Public | ❌ None | ❌ None | None |
| `/teams` | ✅ Public | ❌ None | ❌ None | None |
| `/drivers` | ✅ Public | ❌ None | ❌ None | None |
| `/leaderboards` | ✅ Public | ❌ None | ❌ None | None |
| `/races` | ✅ Public | ❌ None | ❌ None | None |
## Testing Authentication Protection
### Test Scenarios
1. **Unauthenticated Access to Protected Route**
- Navigate to `/dashboard` without login
- Expected: Redirect to `/auth/login?returnTo=/dashboard`
2. **Authenticated Access to Protected Route**
- Login successfully
- Navigate to `/dashboard`
- Expected: Dashboard loads with user data
3. **Session Expiry**
- Login, navigate to `/dashboard`
- Clear session cookie
- Expected: Redirect to login on next action
4. **Role-Based Access**
- Non-admin user tries `/admin`
- Expected: Redirect to login (or 404 in pre-launch mode)
### Manual Testing Commands
```bash
# Test 1: Unauthenticated access
curl -I http://localhost:3000/dashboard
# Should redirect to /auth/login
# Test 2: Check middleware response
curl -I http://localhost:3000/dashboard -H "Cookie: gp_session=valid_token"
# Should return 200 OK
# Test 3: Check public routes
curl -I http://localhost:3000/leagues
# Should return 200 OK (no auth required)
```
## Maintenance Notes
### Adding New Protected Routes
1. **Add to middleware** if it needs authentication:
```typescript
// In isPublicRoute() function
const publicRoutes = [
// ... existing routes
// '/new-public-route', // Add if public
];
```
2. **Create layout file** for route group:
```typescript
// app/new-route/layout.tsx
export default function NewRouteLayout({ children }) {
return (
<AuthGuard redirectPath="/auth/login">
{children}
</AuthGuard>
);
}
```
3. **Test thoroughly**:
- Unauthenticated access → redirect
- Authenticated access → works
- Session expiry → redirect
### Security Best Practices
1. **Always use both middleware and component guards**
2. **Never rely on only one protection layer**
3. **Test session expiry scenarios**
4. **Monitor for authentication bypass attempts**
5. **Keep public routes list minimal**
## Current Implementation Status
**Complete Protection**:
- Dashboard route with multi-layer protection
- Profile routes with AuthGuard
- Sponsor routes with AuthGuard
- Admin routes with role-based protection
- Onboarding routes with AuthGuard
**Public Routes**:
- Home page
- Auth pages
- Discovery pages (leagues, teams, drivers, races, leaderboards)
**Security Features**:
- Defense-in-depth architecture
- Role-based access control
- Session validation
- Graceful error handling
- Clear user feedback
**Loading State Fixes**:
- Fixed infinite loading issue
- Proper session state management
- Clear authentication flow feedback
- No more "stuck on loading" problems
## Known Issues Fixed
### Loading State Problem (RESOLVED)
**Issue**: Users experienced infinite "Loading..." state on dashboard
**Root Cause**: Multiple authentication state systems conflicting
**Solution**: Unified loading state management across AuthContext, AuthGateway, and RouteGuard
**Changes Made**:
1. AuthContext now properly tracks loading state during session fetch
2. AuthGateway only uses authContext.loading for isLoading state
3. AuthorizationBlocker treats null session as unauthenticated (not loading)
4. RouteGuard provides clear feedback during authentication verification
5. Dashboard page simplified to remove redundant auth checks
**Result**: Authentication flow now works smoothly with proper redirects and no infinite loading.
The authentication protection strategy is **comprehensive and secure** for production use.

View File

@@ -23,107 +23,26 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { useLogin } from '@/hooks/auth/useLogin';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
import UserRolesPreview from '@/components/auth/UserRolesPreview';
import { EnhancedFormError, FormErrorSummary } from '@/components/errors/EnhancedFormError';
import { EnhancedFormError } from '@/components/errors/EnhancedFormError';
import { useEnhancedForm } from '@/lib/hooks/useEnhancedForm';
import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation';
import { logErrorWithContext } from '@/lib/utils/errorUtils';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
const [showPassword, setShowPassword] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false);
// Use login mutation hook
const loginMutation = useLogin({
onSuccess: async () => {
// Refresh session in context so header updates immediately
await refreshSession();
router.push(returnTo);
},
onError: (error) => {
// Show error details toggle in development
if (process.env.NODE_ENV === 'development') {
setShowErrorDetails(true);
}
},
});
// Check if user is already authenticated
useEffect(() => {
console.log('[LoginPage] useEffect running', {
session: session ? 'exists' : 'null',
returnTo: searchParams.get('returnTo'),
pathname: window.location.pathname,
search: window.location.search,
});
if (session) {
// Check if this is a returnTo redirect (user lacks permissions)
const isPermissionRedirect = searchParams.get('returnTo') !== null;
console.log('[LoginPage] Authenticated user check', {
isPermissionRedirect,
returnTo: searchParams.get('returnTo'),
});
if (isPermissionRedirect) {
// User was redirected here due to insufficient permissions
// Show permission error instead of redirecting
console.log('[LoginPage] Showing permission error');
setHasInsufficientPermissions(true);
} else {
// User navigated here directly while authenticated, redirect to dashboard
console.log('[LoginPage] Redirecting to dashboard');
router.replace('/dashboard');
}
}
}, [session, router, searchParams]);
// Use enhanced form hook
// Template component for login UI
function LoginTemplate({ data }: { data: { returnTo: string; hasInsufficientPermissions: boolean; showPassword: boolean; showErrorDetails: boolean; formState: any; handleChange: any; handleSubmit: any; setFormState: any; setShowPassword: any; setShowErrorDetails: any; } }) {
const {
returnTo,
hasInsufficientPermissions,
showPassword,
showErrorDetails,
formState,
setFormState,
handleChange,
handleSubmit,
setFormError,
} = useEnhancedForm<LoginFormValues>({
initialValues: {
email: '',
password: '',
rememberMe: false,
},
validate: validateLoginForm,
component: 'LoginPage',
onSubmit: async (values) => {
// Log the attempt for debugging
logErrorWithContext(
{ message: 'Login attempt', values: { ...values, password: '[REDACTED]' } },
{
component: 'LoginPage',
action: 'login-submit',
formData: { ...values, password: '[REDACTED]' },
}
);
await loginMutation.mutateAsync({
email: values.email,
password: values.password,
rememberMe: values.rememberMe,
});
},
onError: (error, values) => {
// Error handling is done in the mutation's onError callback
},
onSuccess: () => {
// Reset error details on success
setShowErrorDetails(false);
},
});
setFormState,
setShowPassword,
setShowErrorDetails,
} = data;
return (
<main className="min-h-screen bg-deep-graphite flex">
@@ -303,7 +222,7 @@ export default function LoginPage() {
error={new Error(formState.submitError)}
onDismiss={() => {
// Clear the error by setting submitError to undefined
setFormState(prev => ({ ...prev, submitError: undefined }));
setFormState((prev: typeof formState) => ({ ...prev, submitError: undefined }));
}}
showDeveloperDetails={showErrorDetails}
/>
@@ -377,4 +296,131 @@ export default function LoginPage() {
</div>
</main>
);
}
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/dashboard';
const [showPassword, setShowPassword] = useState(false);
const [showErrorDetails, setShowErrorDetails] = useState(false);
const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false);
// Use login mutation hook
const loginMutation = useLogin({
onSuccess: async () => {
// Refresh session in context so header updates immediately
await refreshSession();
router.push(returnTo);
},
onError: (error) => {
// Show error details toggle in development
if (process.env.NODE_ENV === 'development') {
setShowErrorDetails(true);
}
},
});
// Check if user is already authenticated
useEffect(() => {
console.log('[LoginPage] useEffect running', {
session: session ? 'exists' : 'null',
returnTo: searchParams.get('returnTo'),
pathname: window.location.pathname,
search: window.location.search,
});
if (session) {
// Check if this is a returnTo redirect (user lacks permissions)
const isPermissionRedirect = searchParams.get('returnTo') !== null;
console.log('[LoginPage] Authenticated user check', {
isPermissionRedirect,
returnTo: searchParams.get('returnTo'),
});
if (isPermissionRedirect) {
// User was redirected here due to insufficient permissions
// Show permission error instead of redirecting
console.log('[LoginPage] Showing permission error');
setHasInsufficientPermissions(true);
} else {
// User navigated here directly while authenticated, redirect to dashboard
console.log('[LoginPage] Redirecting to dashboard');
router.replace('/dashboard');
}
}
}, [session, router, searchParams]);
// Use enhanced form hook
const {
formState,
setFormState,
handleChange,
handleSubmit,
} = useEnhancedForm<LoginFormValues>({
initialValues: {
email: '',
password: '',
rememberMe: false,
},
validate: validateLoginForm,
component: 'LoginPage',
onSubmit: async (values) => {
// Log the attempt for debugging
logErrorWithContext(
{ message: 'Login attempt', values: { ...values, password: '[REDACTED]' } },
{
component: 'LoginPage',
action: 'login-submit',
formData: { ...values, password: '[REDACTED]' },
}
);
await loginMutation.mutateAsync({
email: values.email,
password: values.password,
rememberMe: values.rememberMe,
});
},
onError: (error, values) => {
// Error handling is done in the mutation's onError callback
},
onSuccess: () => {
// Reset error details on success
setShowErrorDetails(false);
},
});
// Prepare template data
const templateData = {
returnTo,
hasInsufficientPermissions,
showPassword,
showErrorDetails,
formState,
handleChange,
handleSubmit,
setFormState,
setShowPassword,
setShowErrorDetails,
};
// Mutation state for wrapper
const isLoading = loginMutation.isPending;
const error = loginMutation.error;
return (
<StatefulPageWrapper
data={templateData}
isLoading={isLoading}
error={error}
retry={() => loginMutation.mutate({ email: '', password: '', rememberMe: false })}
Template={LoginTemplate}
loading={{ variant: 'full-screen', message: 'Loading login...' }}
errorConfig={{ variant: 'full-screen' }}
/>
);
}

View File

@@ -15,7 +15,6 @@ import {
User,
Check,
X,
Loader2,
Car,
Users,
Trophy,
@@ -29,6 +28,7 @@ import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext';
import { useSignup } from '@/hooks/auth/useSignup';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
interface FormErrors {
firstName?: string;
@@ -45,6 +45,10 @@ interface PasswordStrength {
color: string;
}
interface SignupData {
placeholder: string;
}
function checkPasswordStrength(password: string): PasswordStrength {
let score = 0;
if (password.length >= 8) score++;
@@ -89,13 +93,12 @@ const FEATURES = [
'Access detailed performance analytics',
];
export default function SignupPage() {
const SignupTemplate = ({ data }: { data: SignupData }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const { refreshSession } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
const [checkingAuth, setCheckingAuth] = useState(true);
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
@@ -107,32 +110,6 @@ export default function SignupPage() {
confirmPassword: '',
});
// Check if already authenticated
useEffect(() => {
if (session) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
return;
}
// If no session, still check via API for consistency
async function checkAuth() {
try {
const response = await fetch('/api/auth/session');
const data = await response.json();
if (data.authenticated) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
}
} catch {
// Not authenticated, continue showing signup page
} finally {
setCheckingAuth(false);
}
}
checkAuth();
}, [session, router, returnTo]);
const passwordStrength = checkPasswordStrength(formData.password);
const passwordRequirements = [
@@ -239,18 +216,6 @@ export default function SignupPage() {
}
};
// Loading state from mutation
const loading = signupMutation.isPending;
// Show loading while checking auth
if (checkingAuth) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
</main>
);
}
return (
<main className="min-h-screen bg-deep-graphite flex">
{/* Background Pattern */}
@@ -375,7 +340,7 @@ export default function SignupPage() {
error={!!errors.firstName}
errorMessage={errors.firstName}
placeholder="John"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="given-name"
/>
@@ -397,7 +362,7 @@ export default function SignupPage() {
error={!!errors.lastName}
errorMessage={errors.lastName}
placeholder="Smith"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="family-name"
/>
@@ -428,7 +393,7 @@ export default function SignupPage() {
error={!!errors.email}
errorMessage={errors.email}
placeholder="you@example.com"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10"
autoComplete="email"
/>
@@ -450,7 +415,7 @@ export default function SignupPage() {
error={!!errors.password}
errorMessage={errors.password}
placeholder="••••••••"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
@@ -517,7 +482,7 @@ export default function SignupPage() {
error={!!errors.confirmPassword}
errorMessage={errors.confirmPassword}
placeholder="••••••••"
disabled={loading}
disabled={signupMutation.isPending}
className="pl-10 pr-10"
autoComplete="new-password"
/>
@@ -536,29 +501,14 @@ export default function SignupPage() {
)}
</div>
{/* Error Message */}
<AnimatePresence>
{errors.submit && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="flex items-start gap-3 p-3 rounded-lg bg-red-500/10 border border-red-500/30"
>
<AlertCircle className="w-5 h-5 text-red-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-red-400">{errors.submit}</p>
</motion.div>
)}
</AnimatePresence>
{/* Submit Button */}
<Button
type="submit"
variant="primary"
disabled={loading}
disabled={signupMutation.isPending}
className="w-full flex items-center justify-center gap-2"
>
{loading ? (
{signupMutation.isPending ? (
<>
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
Creating account...
@@ -620,4 +570,82 @@ export default function SignupPage() {
</div>
</main>
);
};
export default function SignupPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { refreshSession, session } = useAuth();
const returnTo = searchParams.get('returnTo') ?? '/onboarding';
const [checkingAuth, setCheckingAuth] = useState(true);
// Check if already authenticated
useEffect(() => {
if (session) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
return;
}
// If no session, still check via API for consistency
async function checkAuth() {
try {
const response = await fetch('/api/auth/session');
const data = await response.json();
if (data.authenticated) {
// Already logged in, redirect to dashboard or return URL
router.replace(returnTo === '/onboarding' ? '/dashboard' : returnTo);
}
} catch {
// Not authenticated, continue showing signup page
} finally {
setCheckingAuth(false);
}
}
checkAuth();
}, [session, router, returnTo]);
// Use signup mutation hook for state management
const signupMutation = useSignup({
onSuccess: async () => {
// Refresh session in context so header updates immediately
try {
await refreshSession();
} catch (error) {
console.error('Failed to refresh session after signup:', error);
}
// Always redirect to dashboard after signup
router.push('/dashboard');
},
onError: (error) => {
// Error will be handled in the template
console.error('Signup error:', error);
},
});
// Loading state from mutation
const loading = signupMutation.isPending;
// Show loading while checking auth
if (checkingAuth) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</main>
);
}
// Map mutation states to StatefulPageWrapper
return (
<StatefulPageWrapper
data={{ placeholder: 'data' } as SignupData}
isLoading={loading}
error={signupMutation.error}
retry={() => signupMutation.mutate({ email: '', password: '', displayName: '' })}
Template={SignupTemplate}
loading={{ variant: 'full-screen', message: 'Processing signup...' }}
errorConfig={{ variant: 'full-screen' }}
/>
);
}

View File

@@ -1,339 +1,17 @@
'use client';
import { notFound } from 'next/navigation';
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 {
Activity,
Award,
Calendar,
ChevronRight,
Clock,
Flag,
Medal,
Play,
Star,
Target,
Trophy,
UserPlus,
Users,
} from 'lucide-react';
import Image from 'next/image';
import Link from 'next/link';
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
import { FriendItem } from '@/components/dashboard/FriendItem';
import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem';
import { StatCard } from '@/components/dashboard/StatCard';
import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil } from '@/lib/utilities/time';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDashboardOverview } from '@/hooks/dashboard/useDashboardOverview';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
export default function DashboardPage() {
const { data: dashboardData, isLoading, error, retry } = useDashboardOverview();
return (
<StateContainer
data={dashboardData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading dashboard...' },
error: { variant: 'full-screen' },
empty: {
icon: Activity,
title: 'No dashboard data',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(data: DashboardOverviewViewModel) => {
// StateContainer ensures data is non-null when this renders
const dashboardData = data!;
const currentDriver = dashboardData.currentDriver;
const nextRace = dashboardData.nextRace;
const upcomingRaces = dashboardData.upcomingRaces;
const leagueStandingsSummaries = dashboardData.leagueStandings;
const feedSummary = { items: dashboardData.feedItems };
const friends = dashboardData.friends;
const activeLeaguesCount = dashboardData.activeLeaguesCount;
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
return (
<main className="min-h-screen bg-deep-graphite">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
<div className="absolute inset-0 bg-gradient-to-br from-primary-blue/10 via-deep-graphite to-purple-600/5" />
<div className="absolute inset-0 opacity-5">
<div className="absolute inset-0" style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='none' fill-rule='evenodd'%3E%3Cg fill='%23ffffff' fill-opacity='0.4'%3E%3Cpath d='M36 34v-4h-2v4h-4v2h4v4h2v-4h4v-2h-4zm0-30V0h-2v4h-4v2h4v4h2V6h4V4h-4zM6 34v-4H4v4H0v2h4v4h2v-4h4v-2H6zM6 4V0H4v4H0v2h4v4h2V6h4V4H6z'/%3E%3C/g%3E%3C/g%3E%3C/svg%3E")`,
}} />
</div>
<div className="relative max-w-7xl mx-auto px-6 py-10">
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-8">
{/* Welcome Message */}
<div className="flex items-start gap-5">
<div className="relative">
<div className="w-20 h-20 rounded-2xl bg-gradient-to-br from-primary-blue to-purple-600 p-0.5 shadow-xl shadow-primary-blue/20">
<div className="w-full h-full rounded-xl overflow-hidden bg-iron-gray">
<Image
src={currentDriver.avatarUrl}
alt={currentDriver.name}
width={80}
height={80}
className="w-full h-full object-cover"
/>
</div>
</div>
<div className="absolute -bottom-1 -right-1 w-5 h-5 rounded-full bg-performance-green border-3 border-deep-graphite" />
</div>
<div>
<p className="text-gray-400 text-sm mb-1">{getGreeting()},</p>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">
{currentDriver.name}
<span className="ml-3 text-2xl">{getCountryFlag(currentDriver.country)}</span>
</h1>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-primary-blue/10 border border-primary-blue/30">
<Star className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-sm font-semibold text-primary-blue">{rating}</span>
</div>
<div className="flex items-center gap-1.5 px-3 py-1 rounded-full bg-yellow-400/10 border border-yellow-400/30">
<Trophy className="w-3.5 h-3.5 text-yellow-400" />
<span className="text-sm font-semibold text-yellow-400">#{globalRank}</span>
</div>
<span className="text-xs text-gray-500">{totalRaces} races completed</span>
</div>
</div>
</div>
{/* Quick Actions */}
<div className="flex flex-wrap gap-3">
<Link href="/leagues">
<Button variant="secondary" className="flex items-center gap-2">
<Flag className="w-4 h-4" />
Browse Leagues
</Button>
</Link>
<Link href="/profile">
<Button variant="primary" className="flex items-center gap-2">
<Activity className="w-4 h-4" />
View Profile
</Button>
</Link>
</div>
</div>
{/* Quick Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mt-8">
<StatCard icon={Trophy} value={wins} label="Wins" color="bg-performance-green/20 text-performance-green" />
<StatCard icon={Medal} value={podiums} label="Podiums" color="bg-warning-amber/20 text-warning-amber" />
<StatCard icon={Target} value={`${consistency}%`} label="Consistency" color="bg-primary-blue/20 text-primary-blue" />
<StatCard icon={Users} value={activeLeaguesCount} label="Active Leagues" color="bg-purple-500/20 text-purple-400" />
</div>
</div>
</section>
{/* Main Content */}
<section className="max-w-7xl mx-auto px-6 py-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Main Content */}
<div className="lg:col-span-2 space-y-6">
{/* Next Race Card */}
{nextRace && (
<Card className="relative overflow-hidden bg-gradient-to-br from-iron-gray to-iron-gray/80 border-primary-blue/30">
<div className="absolute top-0 right-0 w-40 h-40 bg-gradient-to-bl from-primary-blue/20 to-transparent rounded-bl-full" />
<div className="relative">
<div className="flex items-center gap-2 mb-4">
<div className="flex items-center gap-2 px-3 py-1 rounded-full bg-primary-blue/20 border border-primary-blue/30">
<Play className="w-3.5 h-3.5 text-primary-blue" />
<span className="text-xs font-semibold text-primary-blue uppercase tracking-wider">Next Race</span>
</div>
{nextRace.isMyLeague && (
<span className="px-2 py-0.5 rounded-full bg-performance-green/20 text-performance-green text-xs font-medium">
Your League
</span>
)}
</div>
<div className="flex flex-col md:flex-row md:items-end md:justify-between gap-4">
<div>
<h2 className="text-2xl font-bold text-white mb-2">{nextRace.track}</h2>
<p className="text-gray-400 mb-3">{nextRace.car}</p>
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="flex items-center gap-1.5 text-gray-400">
<Calendar className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleDateString('en-US', {
weekday: 'long',
month: 'short',
day: 'numeric',
})}
</span>
<span className="flex items-center gap-1.5 text-gray-400">
<Clock className="w-4 h-4" />
{nextRace.scheduledAt.toLocaleTimeString('en-US', {
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
</div>
<div className="flex flex-col items-end gap-3">
<div className="text-right">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Starts in</p>
<p className="text-3xl font-bold text-primary-blue font-mono">{timeUntil(nextRace.scheduledAt)}</p>
</div>
<Link href={`/races/${nextRace.id}`}>
<Button variant="primary" className="flex items-center gap-2">
View Details
<ChevronRight className="w-4 h-4" />
</Button>
</Link>
</div>
</div>
</div>
</Card>
)}
{/* League Standings Preview */}
{leagueStandingsSummaries.length > 0 && (
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Award className="w-5 h-5 text-yellow-400" />
Your Championship Standings
</h2>
<Link href="/profile/leagues" className="text-sm text-primary-blue hover:underline flex items-center gap-1">
View all <ChevronRight className="w-4 h-4" />
</Link>
</div>
<div className="space-y-3">
{leagueStandingsSummaries.map((summary: any) => (
<LeagueStandingItem
key={summary.leagueId}
leagueId={summary.leagueId}
leagueName={summary.leagueName}
position={summary.position}
points={summary.points}
totalDrivers={summary.totalDrivers}
/>
))}
</div>
</Card>
)}
{/* Activity Feed */}
<Card>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white flex items-center gap-2">
<Activity className="w-5 h-5 text-cyan-400" />
Recent Activity
</h2>
</div>
{feedSummary.items.length > 0 ? (
<div className="space-y-4">
{feedSummary.items.slice(0, 5).map((item: any) => (
<FeedItemRow key={item.id} item={item} />
))}
</div>
) : (
<div className="text-center py-8">
<Activity className="w-12 h-12 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400 mb-2">No activity yet</p>
<p className="text-sm text-gray-500">Join leagues and add friends to see activity here</p>
</div>
)}
</Card>
</div>
{/* Right Column - Sidebar */}
<div className="space-y-6">
{/* Upcoming Races */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Calendar className="w-5 h-5 text-red-400" />
Upcoming Races
</h3>
<Link href="/races" className="text-xs text-primary-blue hover:underline">
View all
</Link>
</div>
{upcomingRaces.length > 0 ? (
<div className="space-y-3">
{upcomingRaces.slice(0, 5).map((race: any) => (
<UpcomingRaceItem
key={race.id}
id={race.id}
track={race.track}
car={race.car}
scheduledAt={race.scheduledAt}
isMyLeague={race.isMyLeague}
/>
))}
</div>
) : (
<p className="text-gray-500 text-sm text-center py-4">No upcoming races</p>
)}
</Card>
{/* Friends */}
<Card>
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Users className="w-5 h-5 text-purple-400" />
Friends
</h3>
<span className="text-xs text-gray-500">{friends.length} friends</span>
</div>
{friends.length > 0 ? (
<div className="space-y-2">
{friends.slice(0, 6).map((friend: any) => (
<FriendItem
key={friend.id}
id={friend.id}
name={friend.name}
avatarUrl={friend.avatarUrl}
country={friend.country}
/>
))}
{friends.length > 6 && (
<Link
href="/profile"
className="block text-center py-2 text-sm text-primary-blue hover:underline"
>
+{friends.length - 6} more
</Link>
)}
</div>
) : (
<div className="text-center py-6">
<UserPlus className="w-10 h-10 text-gray-600 mx-auto mb-2" />
<p className="text-sm text-gray-400 mb-2">No friends yet</p>
<Link href="/drivers">
<Button variant="secondary" className="text-xs">
Find Drivers
</Button>
</Link>
</div>
)}
</Card>
</div>
</div>
</section>
</main>
);
}}
</StateContainer>
export default async function DashboardPage() {
const data = await PageDataFetcher.fetch<DashboardService, 'getDashboardOverview'>(
DASHBOARD_SERVICE_TOKEN,
'getDashboardOverview'
);
if (!data) notFound();
return <PageWrapper data={data} Template={DashboardTemplate} />;
}

View File

@@ -1,53 +0,0 @@
'use client';
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import { DriversTemplate } from '@/templates/DriversTemplate';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { Users } from 'lucide-react';
export function DriversInteractive() {
const { data: viewModel, isLoading: loading, error, retry } = useDriverLeaderboard();
const drivers = viewModel?.drivers || [];
const totalRaces = viewModel?.totalRaces || 0;
const totalWins = viewModel?.totalWins || 0;
const activeCount = viewModel?.activeCount || 0;
// TODO this should not be done in a page, thats part of the service??
// Transform data for template
const driverViewModels = drivers.map((driver, index) =>
new DriverLeaderboardItemViewModel(driver, index + 1)
);
return (
<StateContainer
data={viewModel}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading driver leaderboard...' },
error: { variant: 'inline' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet',
}
}}
>
{() => (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
isLoading={false}
/>
)}
</StateContainer>
);
}

View File

@@ -1,24 +0,0 @@
import { DriversTemplate } from '@/templates/DriversTemplate';
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
interface DriversStaticProps {
leaderboardData: DriverLeaderboardViewModel;
}
export async function DriversStatic({ leaderboardData }: DriversStaticProps) {
// Transform the data for the template
const drivers = leaderboardData.drivers.map((driver, index) =>
new DriverLeaderboardItemViewModel(driver, index + 1)
);
return (
<DriversTemplate
drivers={drivers}
totalRaces={leaderboardData.totalRaces}
totalWins={leaderboardData.totalWins}
activeCount={leaderboardData.activeCount}
isLoading={false}
/>
);
}

View File

@@ -1,169 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
import { Car } from 'lucide-react';
interface Team {
id: string;
name: string;
}
interface TeamMembershipInfo {
team: Team;
role: string;
joinedAt: Date;
}
export function DriverProfileInteractive() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const isSponsorMode = useSponsorMode();
// Fetch driver profile using React-Query
const { data: driverProfile, isLoading, error, refetch } = useQuery({
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
});
// Fetch team memberships using React-Query
const { data: allTeamMemberships } = useQuery({
queryKey: ['driverTeamMemberships', driverId],
queryFn: async () => {
if (!driverProfile?.currentDriver) return [];
const allTeams = await teamService.getAllTeams();
const memberships: TeamMembershipInfo[] = [];
for (const team of allTeams) {
const teamMembers = await teamService.getTeamMembers(team.id, driverId, '');
const membership = teamMembers.find(member => member.driverId === driverId);
if (membership) {
memberships.push({
team: {
id: team.id,
name: team.name,
} as Team,
role: membership.role,
joinedAt: new Date(membership.joinedAt),
});
}
}
return memberships;
},
enabled: !!driverProfile?.currentDriver,
});
const handleAddFriend = () => {
setFriendRequestSent(true);
};
const handleBackClick = () => {
router.push('/drivers');
};
// Build sponsor insights for driver
const friendsCount = driverProfile?.socialSummary?.friends?.length ?? 0;
const stats = driverProfile?.stats || null;
const driver = driverProfile?.currentDriver;
const driverMetrics = [
MetricBuilders.rating(stats?.rating ?? 0, 'Driver Rating'),
MetricBuilders.views((friendsCount * 8) + 50),
MetricBuilders.engagement(stats?.consistency ?? 75),
MetricBuilders.reach((friendsCount * 12) + 100),
];
const sponsorInsights = isSponsorMode && driver ? (
<SponsorInsightsCard
entityType="driver"
entityId={driver.id}
entityName={driver.name}
tier="standard"
metrics={driverMetrics}
slots={SlotTemplates.driver(true, 200)}
trustScore={88}
monthlyActivity={stats?.consistency ?? 75}
/>
) : null;
// Loading state
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading driver profile...</p>
</div>
</div>
);
}
// Error state
if (error) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center max-w-md">
<div className="text-red-600 text-4xl mb-4"></div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Error loading driver profile</h2>
<p className="text-gray-600 mb-4">{error.message}</p>
<button
onClick={() => refetch()}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Retry
</button>
</div>
</div>
);
}
// Empty state
if (!driverProfile) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="text-center max-w-md">
<div className="text-gray-400 text-4xl mb-4">
<Car size={48} />
</div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">Driver not found</h2>
<p className="text-gray-600 mb-4">The driver profile may not exist or you may not have access</p>
<button
onClick={handleBackClick}
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
Back to Drivers
</button>
</div>
</div>
);
}
return (
<DriverProfileTemplate
driverProfile={driverProfile}
allTeamMemberships={allTeamMemberships || []}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
);
}

View File

@@ -1,37 +0,0 @@
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
interface DriverProfileStaticProps {
profileData: DriverProfileViewModel;
teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}>;
}
export async function DriverProfileStatic({ profileData, teamMemberships }: DriverProfileStaticProps) {
return (
<DriverProfileTemplate
driverProfile={profileData}
allTeamMemberships={teamMemberships}
isLoading={false}
error={null}
onBackClick={() => {
// This will be handled by the parent page component
window.history.back();
}}
onAddFriend={() => {
// Server component - no-op for static version
console.log('Add friend - static mode');
}}
friendRequestSent={false}
activeTab="overview"
setActiveTab={() => {
// Server component - no-op for static version
console.log('Set tab - static mode');
}}
isSponsorMode={false}
/>
);
}

View File

@@ -1,3 +1,81 @@
import { DriverProfileInteractive } from './DriverProfileInteractive';
'use client';
export default DriverProfileInteractive;
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import { useDriverProfilePageData } from '@/hooks/driver/useDriverProfilePageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useParams, useRouter } from 'next/navigation';
import { useState } from 'react';
interface DriverProfileData {
driverProfile: any;
teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}>;
}
export default function DriverProfilePage() {
const router = useRouter();
const params = useParams();
const driverId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// UI State
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Fetch data using domain hook
const { data: queries, isLoading, error, refetch } = useDriverProfilePageData(driverId);
// Transform data for template
const data: DriverProfileData | undefined = queries?.driverProfile && queries?.teamMemberships
? {
driverProfile: queries.driverProfile,
teamMemberships: queries.teamMemberships,
}
: undefined;
// Actions
const handleAddFriend = () => {
setFriendRequestSent(true);
};
const handleBackClick = () => {
router.push('/drivers');
};
return (
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={({ data }) => {
if (!data) return null;
return (
<DriverProfileTemplate
driverProfile={data.driverProfile}
allTeamMemberships={data.teamMemberships}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
);
}}
loading={{ variant: 'skeleton', message: 'Loading driver profile...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: require('lucide-react').Car,
title: 'Driver not found',
description: 'The driver profile may not exist or you may not have access',
action: { label: 'Back to Drivers', onClick: handleBackClick }
}}
/>
);
}

View File

@@ -1,3 +1,14 @@
import { DriversInteractive } from './DriversInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { DriversTemplate } from '@/templates/DriversTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { DriverService } from '@/lib/services/drivers/DriverService';
export default DriversInteractive;
export default async function Page() {
const data = await PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
);
return <PageWrapper data={data} Template={DriversTemplate} />;
}

View File

@@ -1,42 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
interface LeaderboardsInteractiveProps {
drivers: DriverLeaderboardItemViewModel[];
teams: TeamSummaryViewModel[];
}
export default function LeaderboardsInteractive({ drivers, teams }: LeaderboardsInteractiveProps) {
const router = useRouter();
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}
/>
);
}

View File

@@ -1,80 +0,0 @@
'use client';
import { useRouter } from 'next/navigation';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { useAllTeams } from '@/hooks/team/useAllTeams';
import { Trophy } from 'lucide-react';
export default function LeaderboardsStatic() {
const router = useRouter();
const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDriverLeaderboard();
const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useAllTeams();
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');
};
// Combine loading states
const isLoading = driversLoading || teamsLoading;
// Combine errors (prioritize drivers error)
const error = driversError || teamsError;
// Combine retry functions
const retry = async () => {
if (driversError) await driversRetry();
if (teamsError) await teamsRetry();
};
// Prepare data for template
const drivers = driverData?.drivers || [];
const teamsData = teams || [];
const hasData = drivers.length > 0 || teamsData.length > 0;
return (
<StateContainer
data={hasData ? { drivers, teams: teamsData } : null}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leaderboards...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leaderboard data',
description: 'There is no leaderboard data available at the moment.',
}
}}
isEmpty={(data) => !data || (data.drivers.length === 0 && data.teams.length === 0)}
>
{(data) => (
<LeaderboardsTemplate
drivers={data.drivers}
teams={data.teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
)}
</StateContainer>
);
}

View File

@@ -1,46 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
interface DriverRankingsInteractiveProps {
drivers: DriverLeaderboardItemViewModel[];
}
export default function DriverRankingsInteractive({ drivers }: DriverRankingsInteractiveProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
return (
<DriverRankingsTemplate
drivers={drivers}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}

View File

@@ -1,70 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard';
import { Users } from 'lucide-react';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
export default function DriverRankingsStatic() {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all');
const [sortBy, setSortBy] = useState<SortBy>('rank');
const [showFilters, setShowFilters] = useState(false);
const { data: driverData, isLoading, error, retry } = useDriverLeaderboard();
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
const drivers = driverData?.drivers || [];
return (
<StateContainer
data={drivers}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading driver rankings...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}
}}
>
{(driversData) => (
<DriverRankingsTemplate
drivers={driversData}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
)}
</StateContainer>
);
}

View File

@@ -1,9 +1,101 @@
import DriverRankingsStatic from './DriverRankingsStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import { Users } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import { useState } from 'react';
// ============================================================================
// 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
// ============================================================================
export default function DriverLeaderboardPage() {
return <DriverRankingsStatic />;
export default async function DriverLeaderboardPage() {
// Fetch data using PageDataFetcher
const driverData = await PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
);
// Prepare data for template
const data: DriverLeaderboardViewModel | null = driverData as DriverLeaderboardViewModel | null;
const hasData = (driverData as any)?.drivers?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
redirect('/leaderboards/drivers');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={DriverRankingsPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading driver rankings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}}
/>
);
}

View File

@@ -1,9 +1,113 @@
import LeaderboardsStatic from './LeaderboardsStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { DriverService } from '@/lib/services/drivers/DriverService';
import { TeamService } from '@/lib/services/teams/TeamService';
import { Trophy } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// TYPES
// ============================================================================
interface LeaderboardsPageData {
drivers: DriverLeaderboardViewModel | 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
// ============================================================================
export default function LeaderboardsPage() {
return <LeaderboardsStatic />;
export default async function LeaderboardsPage() {
// Fetch data using PageDataFetcher with proper type annotations
const [driverData, teamsData] = await Promise.all([
PageDataFetcher.fetch<DriverService, 'getDriverLeaderboard'>(
DRIVER_SERVICE_TOKEN,
'getDriverLeaderboard'
),
PageDataFetcher.fetch<TeamService, 'getAllTeams'>(
TEAM_SERVICE_TOKEN,
'getAllTeams'
),
]);
// Prepare data for template
const data: LeaderboardsPageData = {
drivers: driverData as DriverLeaderboardViewModel | null,
teams: teamsData as TeamSummaryViewModel[] | null,
};
const hasData = (driverData as any)?.drivers?.length > 0 || (teamsData as any)?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
// This would typically trigger a page reload
redirect('/leaderboards');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={LeaderboardsPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading leaderboards...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No leaderboard data',
description: 'There is no leaderboard data available at the moment.',
}}
/>
);
}

View File

@@ -1,53 +0,0 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useAllLeagues } from '@/hooks/league/useAllLeagues';
import { Trophy } from 'lucide-react';
export default function LeaguesInteractive() {
const router = useRouter();
const { data: realLeagues = [], isLoading: loading, error, retry } = useAllLeagues();
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleCreateLeagueClick = () => {
router.push('/leagues/create');
};
return (
<StateContainer
data={realLeagues}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leagues...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leagues yet',
description: 'Create your first league to start organizing races and events.',
action: { label: 'Create League', onClick: handleCreateLeagueClick }
}
}}
>
{(leaguesData) => (
<LeaguesTemplate
leagues={leaguesData}
loading={false}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
)}
</StateContainer>
);
}

View File

@@ -1,32 +0,0 @@
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
export default async function LeaguesStatic() {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let leagues: LeagueSummaryViewModel[] = [];
let loading = false;
try {
loading = true;
leagues = await leagueService.getAllLeagues();
} catch (error) {
console.error('Failed to load leagues:', error);
} finally {
loading = false;
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeaguesTemplate
leagues={leagues}
loading={loading}
onLeagueClick={() => {}}
onCreateLeagueClick={() => {}}
/>
);
}

View File

@@ -1,111 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
import EndRaceModal from '@/components/leagues/EndRaceModal';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueDetailWithSponsors } from '@/hooks/league/useLeagueDetailWithSponsors';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Trophy } from 'lucide-react';
export default function LeagueDetailInteractive() {
const router = useRouter();
const params = useParams();
const leagueId = params.id as string;
const isSponsor = useSponsorMode();
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const raceService = useInject(RACE_SERVICE_TOKEN);
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
const currentDriverId = useEffectiveDriverId();
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
const { data: viewModel, isLoading, error, retry } = useLeagueDetailWithSponsors(leagueId);
const handleMembershipChange = () => {
retry();
};
const handleEndRaceModalOpen = (raceId: string) => {
setEndRaceModalRaceId(raceId);
};
const handleLiveRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleBackToLeagues = () => {
router.push('/leagues');
};
const handleEndRaceConfirm = async () => {
if (!endRaceModalRaceId) return;
try {
await raceService.completeRace(endRaceModalRaceId);
await retry();
setEndRaceModalRaceId(null);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race');
}
};
const handleEndRaceCancel = () => {
setEndRaceModalRaceId(null);
};
return (
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted or you may not have access',
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
}
}}
>
{(leagueData) => (
<>
<LeagueDetailTemplate
viewModel={leagueData!}
leagueId={leagueId}
isSponsor={isSponsor}
membership={membership}
currentDriverId={currentDriverId}
onMembershipChange={handleMembershipChange}
onEndRaceModalOpen={handleEndRaceModalOpen}
onLiveRaceClick={handleLiveRaceClick}
onBackToLeagues={handleBackToLeagues}
>
{/* End Race Modal */}
{endRaceModalRaceId && leagueData && (() => {
const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.name}
onConfirm={handleEndRaceConfirm}
onCancel={handleEndRaceCancel}
/>
) : null;
})()}
</LeagueDetailTemplate>
</>
)}
</StateContainer>
);
}

View File

@@ -1,60 +0,0 @@
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
interface LeagueDetailStaticProps {
leagueId: string;
}
export default async function LeagueDetailStatic({ leagueId }: LeagueDetailStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let viewModel: LeagueDetailPageViewModel | null = null;
let loading = false;
let error: string | null = null;
try {
loading = true;
viewModel = await leagueService.getLeagueDetailPageData(leagueId);
if (!viewModel) {
error = 'League not found';
}
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load league';
} finally {
loading = false;
}
if (loading) {
return (
<div className="text-center text-gray-400">Loading league...</div>
);
}
if (error || !viewModel) {
return (
<div className="text-center text-warning-amber">
{error || 'League not found'}
</div>
);
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeagueDetailTemplate
viewModel={viewModel}
leagueId={leagueId}
isSponsor={false}
membership={null}
currentDriverId={null}
onMembershipChange={() => {}}
onEndRaceModalOpen={() => {}}
onLiveRaceClick={() => {}}
onBackToLeagues={() => {}}
/>
);
}

View File

@@ -18,7 +18,7 @@ export default function LeagueLayout({
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId);
const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId ?? '');
if (loading) {
return (
@@ -102,4 +102,4 @@ export default function LeagueLayout({
</div>
</div>
);
}
}

View File

@@ -1,3 +1,93 @@
import LeagueDetailInteractive from './LeagueDetailInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
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 { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default LeagueDetailInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data
const result = await service.getLeagueDetailPageData(params.id);
if (!result) {
throw new Error('League not found');
}
return result;
});
if (!data) {
notFound();
}
// Create a wrapper component that passes data to the template
const TemplateWrapper = ({ data }: { data: LeagueDetailPageViewModel }) => {
// The LeagueDetailTemplate expects multiple props beyond just data
// We need to provide the additional props it requires
return (
<LeagueDetailTemplate
viewModel={data}
leagueId={data.id}
isSponsor={false}
membership={null}
currentDriverId={null}
onMembershipChange={() => {}}
onEndRaceModalOpen={() => {}}
onLiveRaceClick={() => {}}
onBackToLeagues={() => {}}
/>
);
};
return (
<PageWrapper
data={data}
Template={TemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading league details...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'League not found',
description: 'The league you are looking for does not exist or has been removed.',
}}
/>
);
}

View File

@@ -1,48 +0,0 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default function LeagueRulebookInteractive() {
const params = useParams();
const leagueId = params.id as string;
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
try {
const data = await leagueService.getLeagueDetailPageData(leagueId);
if (!data) {
setLoading(false);
return;
}
setViewModel(data);
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [leagueId, leagueService]);
if (!viewModel && !loading) {
return (
<div className="text-center text-gray-400 py-12">
Unable to load rulebook
</div>
);
}
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
}

View File

@@ -1,38 +0,0 @@
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
interface LeagueRulebookStaticProps {
leagueId: string;
}
export default async function LeagueRulebookStatic({ leagueId }: LeagueRulebookStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let viewModel: LeagueDetailPageViewModel | null = null;
let loading = false;
try {
loading = true;
const data = await leagueService.getLeagueDetailPageData(leagueId);
if (data) {
viewModel = data;
}
} catch (err) {
console.error('Failed to load scoring config:', err);
} finally {
loading = false;
}
if (!viewModel && !loading) {
return (
<div className="text-center text-gray-400 py-12">
Unable to load rulebook
</div>
);
}
return <LeagueRulebookTemplate viewModel={viewModel!} loading={loading} />;
}

View File

@@ -1,3 +1,63 @@
import LeagueRulebookInteractive from './LeagueRulebookInteractive';
import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
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 { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export default LeagueRulebookInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies for API clients
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
return await service.getLeagueDetailPageData(params.id);
});
if (!data) {
notFound();
}
// Create a Template wrapper that matches PageWrapper's expected interface
const Template = ({ data }: { data: LeagueDetailPageViewModel }) => {
return <LeagueRulebookTemplate viewModel={data} loading={false} />;
};
return <PageWrapper data={data} Template={Template} />;
}

View File

@@ -1,11 +0,0 @@
'use client';
import { useParams } from 'next/navigation';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
export default function LeagueScheduleInteractive() {
const params = useParams();
const leagueId = params.id as string;
return <LeagueScheduleTemplate leagueId={leagueId} />;
}

View File

@@ -1,10 +0,0 @@
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
interface LeagueScheduleStaticProps {
leagueId: string;
}
export default async function LeagueScheduleStatic({ leagueId }: LeagueScheduleStaticProps) {
// The LeagueScheduleTemplate doesn't need data fetching - it delegates to LeagueSchedule component
return <LeagueScheduleTemplate leagueId={leagueId} />;
}

View File

@@ -1,140 +1,124 @@
'use client';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from '@/hooks/league/useLeagueScheduleAdminPageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
export default function LeagueAdminSchedulePage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const [isAdmin, setIsAdmin] = useState(false);
const [membershipLoading, setMembershipLoading] = useState(true);
const [seasons, setSeasons] = useState<LeagueSeasonSummaryViewModel[]>([]);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [schedule, setSchedule] = useState<LeagueAdminScheduleViewModel | null>(null);
const [loading, setLoading] = useState(false);
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
const isEditing = editingRaceId !== null;
const publishedLabel = schedule?.published ? 'Published' : 'Unpublished';
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
const selectedSeasonLabel = useMemo(() => {
const selected = seasons.find((s) => s.seasonId === seasonId);
return selected?.name ?? seasonId;
}, [seasons, seasonId]);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
const loadSchedule = async (leagueIdToLoad: string, seasonIdToLoad: string) => {
setLoading(true);
try {
const vm = await leagueService.getAdminSchedule(leagueIdToLoad, seasonIdToLoad);
setSchedule(vm);
} finally {
setLoading(false);
}
};
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
useEffect(() => {
async function checkAdmin() {
setMembershipLoading(true);
try {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
} finally {
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
setMembershipLoading(false);
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading, refetch: refetchSchedule } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
useEffect(() => {
async function loadSeasons() {
const loaded = await leagueService.getLeagueSeasonSummaries(leagueId);
setSeasons(loaded);
if (loaded.length > 0) {
const active = loaded.find((s) => s.status === 'active') ?? loaded[0];
setSeasonId(active?.seasonId ?? '');
}
}
if (isAdmin) {
loadSeasons();
}
}, [leagueId, isAdmin, leagueService]);
useEffect(() => {
if (!isAdmin) return;
if (!seasonId) return;
loadSchedule(leagueId, seasonId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [leagueId, seasonId, isAdmin]);
const handlePublishToggle = async () => {
if (!schedule) return;
if (schedule.published) {
const vm = await leagueService.unpublishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
return;
}
const vm = await leagueService.publishAdminSchedule(leagueId, seasonId);
setSchedule(vm);
};
const handleAddOrSave = async () => {
if (!seasonId) return;
if (!scheduledAtIso) return;
if (!isEditing) {
const vm = await leagueService.createAdminScheduleRace(leagueId, seasonId, {
track,
car,
scheduledAtIso,
});
setSchedule(vm);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
return;
}
setEditingRaceId(null);
},
});
const vm = await leagueService.updateAdminScheduleRace(leagueId, seasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
setSchedule(vm);
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
@@ -144,190 +128,78 @@ export default function LeagueAdminSchedulePage() {
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = async (raceId: string) => {
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
const vm = await leagueService.deleteAdminScheduleRace(leagueId, seasonId, raceId);
setSchedule(vm);
deleteMutation.mutate(raceId);
};
if (membershipLoading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading</div>
</Card>
);
}
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
if (!isAdmin) {
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
schedule,
seasons: seasonsData,
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="space-y-6">
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-6 text-center">
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
</div>
</Card>
</div>
);
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
data={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<div className="space-y-6">
<Card>
<div className="space-y-4">
<div>
<h1 className="text-2xl font-bold text-white">Schedule Admin</h1>
<p className="text-sm text-gray-400">Create, edit, and publish season races.</p>
</div>
<div className="flex flex-col gap-2">
<label className="text-sm text-gray-300" htmlFor="seasonId">
Season
</label>
{seasons.length > 0 ? (
<select
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
>
{seasons.map((s) => (
<option key={s.seasonId} value={s.seasonId}>
{s.name}
</option>
))}
</select>
) : (
<input
id="seasonId"
value={seasonId}
onChange={(e) => setSeasonId(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="season-id"
/>
)}
<p className="text-xs text-gray-500">Selected: {selectedSeasonLabel}</p>
</div>
<div className="flex items-center justify-between gap-3">
<p className="text-sm text-gray-300">
Status: <span className="font-medium text-white">{publishedLabel}</span>
</p>
<button
type="button"
onClick={handlePublishToggle}
disabled={!schedule}
className="px-3 py-1.5 rounded bg-primary-blue text-white disabled:opacity-50"
>
{schedule?.published ? 'Unpublish' : 'Publish'}
</button>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">{isEditing ? 'Edit race' : 'Add race'}</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
<div className="flex flex-col gap-1">
<label htmlFor="track" className="text-sm text-gray-300">
Track
</label>
<input
id="track"
value={track}
onChange={(e) => setTrack(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="car" className="text-sm text-gray-300">
Car
</label>
<input
id="car"
value={car}
onChange={(e) => setCar(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
/>
</div>
<div className="flex flex-col gap-1">
<label htmlFor="scheduledAtIso" className="text-sm text-gray-300">
Scheduled At (ISO)
</label>
<input
id="scheduledAtIso"
value={scheduledAtIso}
onChange={(e) => setScheduledAtIso(e.target.value)}
className="bg-iron-gray text-white px-3 py-2 rounded"
placeholder="2025-01-01T12:00:00.000Z"
/>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={handleAddOrSave}
className="px-3 py-1.5 rounded bg-primary-blue text-white"
>
{isEditing ? 'Save' : 'Add race'}
</button>
{isEditing && (
<button
type="button"
onClick={() => setEditingRaceId(null)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Cancel
</button>
)}
</div>
</div>
<div className="border-t border-charcoal-outline pt-4 space-y-3">
<h2 className="text-lg font-semibold text-white">Races</h2>
{loading ? (
<div className="py-4 text-sm text-gray-400">Loading schedule</div>
) : schedule?.races.length ? (
<div className="space-y-2">
{schedule.races.map((race) => (
<div
key={race.id}
className="flex items-center justify-between gap-3 bg-deep-graphite border border-charcoal-outline rounded p-3"
>
<div className="min-w-0">
<p className="text-white font-medium truncate">{race.name}</p>
<p className="text-xs text-gray-400 truncate">{race.scheduledAt.toISOString()}</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => handleEdit(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Edit
</button>
<button
type="button"
onClick={() => handleDelete(race.id)}
className="px-3 py-1.5 rounded bg-iron-gray text-gray-200"
>
Delete
</button>
</div>
</div>
))}
</div>
) : (
<div className="py-4 text-sm text-gray-500">No races yet.</div>
)}
</div>
</div>
</Card>
</div>
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}

View File

@@ -1,3 +1,84 @@
import LeagueScheduleInteractive from './LeagueScheduleInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
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 { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueScheduleViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
export default LeagueScheduleInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data
const result = await service.getLeagueSchedule(params.id);
if (!result) {
throw new Error('League schedule not found');
}
return result;
});
if (!data) {
notFound();
}
// Create a wrapper component that passes data to the template
const TemplateWrapper = ({ data }: { data: LeagueScheduleViewModel }) => {
return (
<LeagueScheduleTemplate
data={data}
leagueId={params.id}
/>
);
};
return (
<PageWrapper
data={data}
Template={TemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading schedule...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Schedule not found',
description: 'The schedule for this league is not available.',
}}
/>
);
}

View File

@@ -23,14 +23,14 @@ export default function LeagueSettingsPage() {
const router = useRouter();
// Check admin status using DI + React-Query
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId ?? '');
// Load settings (only if admin) using DI + React-Query
const { data: settings, isLoading: settingsLoading, error, retry } = useLeagueSettings(leagueId, { enabled: !!isAdmin });
const handleTransferOwnership = async (newOwnerId: string) => {
try {
await leagueSettingsService.transferOwnership(leagueId, currentDriverId, newOwnerId);
await leagueSettingsService.transferOwnership(leagueId, currentDriverId ?? '', newOwnerId);
router.refresh();
} catch (err) {
throw err; // Let the component handle the error
@@ -94,7 +94,7 @@ export default function LeagueSettingsPage() {
<LeagueOwnershipTransfer
settings={settingsData!}
currentDriverId={currentDriverId}
currentDriverId={currentDriverId ?? ''}
onTransferOwnership={handleTransferOwnership}
/>
</div>

View File

@@ -1,83 +1,20 @@
'use client';
import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection';
import Card from '@/components/ui/Card';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel';
import { AlertTriangle, Building } from 'lucide-react';
import { useLeagueSponsorshipsPageData } from '@/hooks/league/useLeagueSponsorshipsPageData';
import { ApiError } from '@/lib/api/base/ApiError';
import { Building } from 'lucide-react';
import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function LeagueSponsorshipsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const [league, setLeague] = useState<LeaguePageDetailViewModel | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function loadData() {
try {
const [leagueDetail] = await Promise.all([
leagueService.getLeagueDetail(leagueId, currentDriverId),
leagueMembershipService.fetchLeagueMemberships(leagueId),
]);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setLeague(leagueDetail);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
console.error('Failed to load league:', err);
} finally {
setLoading(false);
}
}
loadData();
}, [leagueId, currentDriverId, leagueService, leagueMembershipService]);
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400 text-center">Loading sponsorships...</div>
</Card>
);
}
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can manage sponsorships.
</p>
</div>
</Card>
);
}
if (!league) {
return (
<Card>
<div className="py-6 text-sm text-gray-500 text-center">
League not found.
</div>
</Card>
);
}
interface SponsorshipsData {
league: any;
isAdmin: boolean;
}
function SponsorshipsTemplate({ data }: { data: SponsorshipsData }) {
return (
<div className="space-y-6">
{/* Header */}
@@ -92,12 +29,51 @@ export default function LeagueSponsorshipsPage() {
</div>
{/* Sponsorships Section */}
<Card>
<LeagueSponsorshipsSection
leagueId={leagueId}
readOnly={false}
/>
</Card>
<LeagueSponsorshipsSection
leagueId={data.league.id}
readOnly={false}
/>
</div>
);
}
export default function LeagueSponsorshipsPage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// Fetch data using domain hook
const { data, isLoading, error, refetch } = useLeagueSponsorshipsPageData(leagueId, currentDriverId);
// Transform data for the template
const transformedData: SponsorshipsData | undefined = data?.league && data.membership !== null
? {
league: data.league,
isAdmin: LeagueRoleUtility.isLeagueAdminOrHigherRole(data.membership?.role || 'member'),
}
: undefined;
// Check if user is not admin to show appropriate state
const isNotAdmin = transformedData && !transformedData.isAdmin;
return (
<StatefulPageWrapper
data={transformedData}
isLoading={isLoading}
error={error as ApiError | null}
retry={refetch}
Template={SponsorshipsTemplate}
loading={{ variant: 'skeleton', message: 'Loading sponsorships...' }}
errorConfig={{ variant: 'full-screen' }}
empty={isNotAdmin ? {
icon: Building,
title: 'Admin Access Required',
description: 'Only league admins can manage sponsorships.',
} : {
icon: Building,
title: 'League not found',
description: 'The league may have been deleted or is no longer accessible.',
}}
/>
);
}

View File

@@ -1,98 +0,0 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useParams } from 'next/navigation';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
export default function LeagueStandingsInteractive() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false);
const loadData = useCallback(async () => {
try {
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
setStandings(vm.standings);
setDrivers(vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null })));
setMemberships(vm.memberships);
// Check if current user is admin
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {
setLoading(false);
}
}, [leagueId, currentDriverId, leagueService]);
useEffect(() => {
loadData();
}, [loadData]);
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
await leagueService.removeMember(leagueId, currentDriverId, driverId);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to remove member');
}
};
const handleUpdateRole = async (driverId: string, newRole: string) => {
try {
await leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole);
await loadData();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update role');
}
};
if (loading) {
return (
<div className="text-center text-gray-400">
Loading standings...
</div>
);
}
if (error) {
return (
<div className="text-center text-warning-amber">
{error}
</div>
);
}
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={leagueId}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
onRemoveMember={handleRemoveMember}
onUpdateRole={handleUpdateRole}
/>
);
}

View File

@@ -1,71 +0,0 @@
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
interface LeagueStandingsStaticProps {
leagueId: string;
currentDriverId?: string | null;
}
export default async function LeagueStandingsStatic({ leagueId, currentDriverId }: LeagueStandingsStaticProps) {
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const leagueService = serviceFactory.createLeagueService();
let standings: StandingEntryViewModel[] = [];
let drivers: DriverViewModel[] = [];
let memberships: LeagueMembership[] = [];
let loading = false;
let error: string | null = null;
let isAdmin = false;
try {
loading = true;
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId || '');
standings = vm.standings;
drivers = vm.drivers.map((d) => new DriverViewModel({ ...d, avatarUrl: (d as any).avatarUrl ?? null }));
memberships = vm.memberships;
// Check if current user is admin
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
} catch (err) {
error = err instanceof Error ? err.message : 'Failed to load standings';
} finally {
loading = false;
}
if (loading) {
return (
<div className="text-center text-gray-400">
Loading standings...
</div>
);
}
if (error) {
return (
<div className="text-center text-warning-amber">
{error}
</div>
);
}
// Server components can't have event handlers, so we provide empty functions
// The Interactive wrapper will add the actual handlers
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={leagueId}
currentDriverId={currentDriverId ?? null}
isAdmin={isAdmin}
onRemoveMember={() => {}}
onUpdateRole={() => {}}
/>
);
}

View File

@@ -1,3 +1,107 @@
import LeagueStandingsInteractive from './LeagueStandingsInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
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 { SponsorsApiClient } from '@/lib/api/sponsors/SponsorsApiClient';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
export default LeagueStandingsInteractive;
interface Props {
params: { id: string };
}
export default async function Page({ params }: Props) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual for multiple dependencies
const data = await PageDataFetcher.fetchManual(async () => {
// Create dependencies
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API clients
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
const driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
const sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new LeagueService(
leaguesApiClient,
driversApiClient,
sponsorsApiClient,
racesApiClient
);
// Fetch data - using empty string for currentDriverId since this is SSR without session
const result = await service.getLeagueStandings(params.id, '');
if (!result) {
throw new Error('League standings not found');
}
return result;
});
if (!data) {
notFound();
}
// Transform data for template
const standings = data.standings ?? [];
const drivers: DriverViewModel[] = data.drivers?.map((d) =>
new DriverViewModel({
id: d.id,
name: d.name,
avatarUrl: d.avatarUrl || null,
iracingId: d.iracingId,
rating: d.rating,
country: d.country,
})
) ?? [];
const memberships: LeagueMembership[] = data.memberships ?? [];
// Create a wrapper component that passes data to the template
const TemplateWrapper = () => {
return (
<LeagueStandingsTemplate
standings={standings}
drivers={drivers}
memberships={memberships}
leagueId={params.id}
currentDriverId={null}
isAdmin={false}
onRemoveMember={() => {}}
onUpdateRole={() => {}}
loading={false}
/>
);
};
return (
<PageWrapper
data={data}
Template={TemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading standings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Standings not found',
description: 'The standings for this league are not available.',
}}
/>
);
}

View File

@@ -0,0 +1,359 @@
'use client';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
interface StewardingData {
totalPending: number;
totalResolved: number;
totalPenalties: number;
racesWithData: Array<{
race: { id: string; track: string; scheduledAt: Date };
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
}>;
allDrivers: any[];
driverMap: Record<string, any>;
}
interface StewardingTemplateProps {
data: StewardingData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
return activeTab === 'pending' ? data.racesWithData.filter(r => r.pendingProtests.length > 0) : data.racesWithData.filter(r => r.resolvedProtests.length > 0 || r.penalties.length > 0);
}, [data, activeTab]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
data.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
case 'under_review':
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}

View File

@@ -1,134 +1,24 @@
'use client';
import PenaltyFAB from '@/components/leagues/PenaltyFAB';
import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import StewardingStats from '@/components/leagues/StewardingStats';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus';
import { useLeagueStewardingData } from '@/hooks/league/useLeagueStewardingData';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useMemo, useState } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useLeagueStewardingMutations } from '@/hooks/league/useLeagueStewardingMutations';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { StewardingTemplate } from './StewardingTemplate';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import Card from '@/components/ui/Card';
import { AlertTriangle } from 'lucide-react';
import { useParams } from 'next/navigation';
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
const { data: currentDriver } = useCurrentDriver();
const currentDriverId = currentDriver?.id;
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
const currentDriverId = currentDriver?.id || '';
// Check admin status
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load stewarding data (only if admin)
const { data: stewardingData, isLoading: dataLoading, error, retry } = useLeagueStewardingData(leagueId);
// Filter races based on active tab
const filteredRaces = useMemo(() => {
return activeTab === 'pending' ? stewardingData?.pendingRaces ?? [] : stewardingData?.historyRaces ?? [];
}, [stewardingData, activeTab]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
stewardingData?.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
// TODO: Implement protest review and penalty application
// await leagueStewardingService.reviewProtest({
// protestId,
// stewardId: currentDriverId,
// decision: 'uphold',
// decisionNotes: stewardNotes,
// });
// await leagueStewardingService.applyPenalty({
// raceId: foundProtest.raceId,
// driverId: foundProtest.accusedDriverId,
// stewardId: currentDriverId,
// type: penaltyType,
// value: penaltyValue,
// reason: foundProtest.incident.description,
// protestId,
// notes: stewardNotes,
// });
}
// Retry to refresh data
await retry();
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
// TODO: Implement protest rejection
// await leagueStewardingService.reviewProtest({
// protestId,
// stewardId: currentDriverId,
// decision: 'dismiss',
// decisionNotes: stewardNotes,
// });
// Retry to refresh data
await retry();
};
const toggleRaceExpanded = (raceId: string) => {
setExpandedRaces(prev => {
const next = new Set(prev);
if (next.has(raceId)) {
next.delete(raceId);
} else {
next.add(raceId);
}
return next;
});
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
case 'under_review':
return <span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">Pending</span>;
case 'upheld':
return <span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">Upheld</span>;
case 'dismissed':
return <span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">Dismissed</span>;
case 'withdrawn':
return <span className="px-2 py-0.5 text-xs font-medium bg-blue-500/20 text-blue-400 rounded-full">Withdrawn</span>;
default:
return null;
}
};
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Show loading for admin check
if (adminLoading) {
@@ -152,265 +42,30 @@ export default function LeagueStewardingPage() {
);
}
// Load stewarding data using domain hook
const { data, isLoading, error, refetch } = useLeagueStewardingData(leagueId);
return (
<StateContainer
data={stewardingData}
isLoading={dataLoading}
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}
retry={refetch}
Template={({ data }) => (
<StewardingTemplate
data={data}
leagueId={leagueId}
currentDriverId={currentDriverId}
onRefetch={refetch}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: require('lucide-react').Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}}
>
{(data) => {
if (!data) return null;
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{displayProtests.map((protest) => {
const protester = data.driverMap[protest.protestingDriverId];
const accused = data.driverMap[protest.accusedDriverId];
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24));
const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review');
return (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{penalty.type === 'time_penalty' && `+${penalty.value}s`}
{penalty.type === 'grid_penalty' && `+${penalty.value} grid`}
{penalty.type === 'points_deduction' && `-${penalty.value} pts`}
{penalty.type === 'disqualification' && 'DSQ'}
{penalty.type === 'warning' && 'Warning'}
{penalty.type === 'license_points' && `${penalty.value} LP`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}}
</StateContainer>
/>
);
}

View File

@@ -156,7 +156,7 @@ export default function ProtestReviewPage() {
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail) return;
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
setSubmitting(true);
try {
@@ -241,7 +241,7 @@ export default function ProtestReviewPage() {
};
const handleRequestDefense = async () => {
if (!detail) return;
if (!detail || !currentDriverId) return;
try {
// Request defense
@@ -734,4 +734,4 @@ export default function ProtestReviewPage() {
}}
</StateContainer>
);
}
}

View File

@@ -0,0 +1,300 @@
'use client';
import React, { useState } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
data: LeagueWalletViewModel;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function WalletTemplate({ data, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = data.getFilteredTransactions(filterType);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!data.canWithdraw || !onWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!data.canWithdraw && data.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{data.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{data.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!data.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{data.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={data.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {data.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!data.canWithdraw || mutationLoading || !withdrawAmount}
className="flex-1"
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
);
}

View File

@@ -1,340 +1,52 @@
'use client';
import { useState, useEffect } from 'react';
import { useParams } from 'next/navigation';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import TransactionRow from '@/components/leagues/TransactionRow';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
import { useLeagueWalletPageData, useLeagueWalletWithdrawal } from '@/hooks/league/useLeagueWalletPageData';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { WalletTemplate } from './WalletTemplate';
import { Wallet } from 'lucide-react';
export default function LeagueWalletPage() {
const params = useParams();
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const [wallet, setWallet] = useState<LeagueWalletViewModel | null>(null);
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [processing, setProcessing] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const leagueId = params.id as string;
useEffect(() => {
const loadWallet = async () => {
if (params.id) {
try {
const walletData = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(walletData);
} catch (error) {
console.error('Failed to load wallet:', error);
}
}
};
loadWallet();
}, [params.id, leagueWalletService]);
// Query for wallet data using domain hook
const { data, isLoading, error, refetch } = useLeagueWalletPageData(leagueId);
if (!wallet) {
return <div>Loading...</div>;
}
// Mutation for withdrawal using domain hook
const withdrawMutation = useLeagueWalletWithdrawal(leagueId, data, refetch);
const filteredTransactions = wallet.getFilteredTransactions(filterType);
// Export handler (placeholder)
const handleExport = () => {
alert('Export functionality coming soon!');
};
const handleWithdraw = async () => {
if (!withdrawAmount || parseFloat(withdrawAmount) <= 0) return;
setProcessing(true);
try {
const result = await leagueWalletService.withdraw(
params.id as string,
parseFloat(withdrawAmount),
wallet.currency,
'season-2', // Current active season
'bank-account-***1234'
);
if (!result.success) {
alert(result.message || 'Withdrawal failed');
return;
}
alert(`Withdrawal of $${withdrawAmount} processed successfully!`);
setShowWithdrawModal(false);
setWithdrawAmount('');
// Refresh wallet data
const updatedWallet = await leagueWalletService.getWalletForLeague(params.id as string);
setWallet(updatedWallet);
} catch (err) {
console.error('Withdrawal error:', err);
alert('Failed to process withdrawal');
} finally {
setProcessing(false);
}
// Render function for the template
const renderTemplate = (walletData: any) => {
return (
<WalletTemplate
data={walletData}
onWithdraw={(amount) => withdrawMutation.mutate({ amount })}
onExport={handleExport}
mutationLoading={withdrawMutation.isPending}
/>
);
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary">
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!wallet.canWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!wallet.canWithdraw && wallet.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{wallet.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{wallet.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{wallet.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!wallet.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{wallet.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={wallet.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {wallet.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdraw}
disabled={!wallet.canWithdraw || processing || !withdrawAmount}
className="flex-1"
>
{processing ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={refetch}
Template={({ data }) => renderTemplate(data)}
loading={{ variant: 'skeleton', message: 'Loading wallet...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Wallet,
title: 'No wallet data available',
description: 'Wallet data will appear here once loaded',
}}
/>
);
}

View File

@@ -1,3 +1,19 @@
import LeaguesInteractive from './LeaguesInteractive';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
export default LeaguesInteractive;
export default async function Page() {
const data = await PageDataFetcher.fetch<LeagueService, 'getAllLeagues'>(
LEAGUE_SERVICE_TOKEN,
'getAllLeagues'
);
if (!data) {
notFound();
}
return <PageWrapper data={data} Template={LeaguesTemplate} />;
}

View File

@@ -8,7 +8,12 @@ import { useAuth } from '@/lib/auth/AuthContext';
// Shared state components
import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
// Template component that accepts data
function OnboardingTemplate({ data }: { data: any }) {
return <OnboardingWizard />;
}
export default function OnboardingPage() {
const router = useRouter();
@@ -18,7 +23,7 @@ export default function OnboardingPage() {
const shouldRedirectToLogin = !session;
// Fetch current driver data using DI + React-Query
const { data: driver, isLoading } = useCurrentDriver({
const { data: driver, isLoading, error, refetch } = useCurrentDriver({
enabled: !!session,
});
@@ -38,21 +43,24 @@ export default function OnboardingPage() {
return null;
}
if (isLoading) {
return (
<main className="min-h-screen bg-deep-graphite">
<LoadingWrapper variant="full-screen" message="Loading onboarding..." />
</main>
);
}
if (shouldRedirectToDashboard) {
return null;
}
// For the StatefulPageWrapper, we need to provide data even if it's empty
// The page is workflow-driven, not data-driven
const wrapperData = driver || {};
return (
<main className="min-h-screen bg-deep-graphite">
<OnboardingWizard />
<StatefulPageWrapper
data={wrapperData}
isLoading={isLoading}
error={error}
retry={refetch}
Template={OnboardingTemplate}
loading={{ variant: 'full-screen', message: 'Loading onboarding...' }}
/>
</main>
);
}

View File

@@ -1,366 +1,15 @@
import { redirect } from 'next/navigation';
import Image from 'next/image';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import HomeTemplate from '@/templates/HomeTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { getHomeData } from '@/lib/services/home/getHomeData';
import { notFound } from 'next/navigation';
import { FeatureFlagService } from '@/lib/feature/FeatureFlagService';
import Hero from '@/components/landing/Hero';
import AlternatingSection from '@/components/landing/AlternatingSection';
import FeatureGrid from '@/components/landing/FeatureGrid';
import DiscordCTA from '@/components/landing/DiscordCTA';
import FAQ from '@/components/landing/FAQ';
import Footer from '@/components/landing/Footer';
import CareerProgressionMockup from '@/components/mockups/CareerProgressionMockup';
import RaceHistoryMockup from '@/components/mockups/RaceHistoryMockup';
import CompanionAutomationMockup from '@/components/mockups/CompanionAutomationMockup';
import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
import MockupStack from '@/components/ui/MockupStack';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getMediaUrl } from '@/lib/utilities/media';
export default async function HomePage() {
const baseUrl = getWebsiteApiBaseUrl();
const serviceFactory = new ServiceFactory(baseUrl);
const sessionService = serviceFactory.createSessionService();
const landingService = serviceFactory.createLandingService();
const session = await sessionService.getSession();
if (session) {
redirect('/dashboard');
export default async function Page() {
const data = await PageDataFetcher.fetchManual(async () => getHomeData());
if (!data) {
notFound();
}
const featureService = FeatureFlagService.fromEnv();
const isAlpha = featureService.isEnabled('alpha_features');
const discovery = await landingService.getHomeDiscovery();
const upcomingRaces = discovery.upcomingRaces;
const topLeagues = discovery.topLeagues;
const teams = discovery.teams;
return (
<main className="min-h-screen">
<Hero />
{/* Section 1: A Persistent Identity */}
<AlternatingSection
heading="A Persistent Identity"
backgroundVideo="/gameplay.mp4"
description={
<>
<p>
Your races, your seasons, your progress finally in one place.
</p>
<div className="space-y-3 mt-4">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Lifetime stats and season history across all your leagues
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Track your performance, consistency, and team contributions
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-4 border border-slate-700/40 hover:border-primary-blue/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(59,130,246,0.15)]">
<div className="absolute top-0 left-0 w-full h-0.5 bg-gradient-to-r from-transparent via-primary-blue/60 to-transparent opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-3">
<div className="flex-shrink-0 w-9 h-9 rounded-lg bg-gradient-to-br from-primary-blue/20 to-blue-900/20 border border-primary-blue/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-5 h-5 text-primary-blue" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7" />
</svg>
</div>
<span className="text-slate-200 leading-relaxed font-light">
Your own rating that reflects real league competition
</span>
</div>
</div>
</div>
<p className="mt-4">
iRacing gives you physics. GridPilot gives you a career.
</p>
</>
}
mockup={<CareerProgressionMockup />}
layout="text-left"
/>
<FeatureGrid />
{/* Section 2: Results That Actually Stay */}
<AlternatingSection
heading="Results That Actually Stay"
backgroundImage="/images/ff1600.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Every race you run stays with you.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Your stats, your team, your story all connected
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
One race result updates your profile, team points, rating, and season history
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-r from-slate-900/60 via-slate-800/40 to-slate-900/60 p-3.5 md:p-4 border border-slate-700/40 hover:border-red-600/50 transition-all duration-300 hover:shadow-[0_0_25px_rgba(220,38,38,0.15)]">
<div className="absolute top-0 right-0 w-12 h-12 bg-gradient-to-bl from-red-600/10 to-transparent rounded-bl-3xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3">
<div className="flex-shrink-0 w-8 h-8 md:w-9 md:h-9 rounded-lg bg-gradient-to-br from-red-600/20 to-red-900/20 border border-red-600/30 flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<svg className="w-4 h-4 md:w-5 md:h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No more fragmented data across spreadsheets and forums
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Your racing career, finally in one place.
</p>
</>
}
mockup={<MockupStack index={1}><RaceHistoryMockup /></MockupStack>}
layout="text-right"
/>
{/* Section 3: Automatic Session Creation */}
<AlternatingSection
heading="Automatic Session Creation"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Setting up league races used to mean clicking through iRacing's wizard 20 times.
</p>
<div className="space-y-3 mt-4 md:mt-6">
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">1</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
Our companion app syncs with your league schedule
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">2</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
When it's race time, it creates the iRacing session automatically
</span>
</div>
</div>
<div className="group relative overflow-hidden rounded-lg bg-gradient-to-br from-slate-900/70 to-slate-800/50 p-3.5 md:p-4 border border-slate-700/50 hover:border-primary-blue/60 transition-all duration-300 hover:shadow-[0_0_30px_rgba(59,130,246,0.2)]">
<div className="absolute -top-12 -right-12 w-24 h-24 bg-gradient-to-br from-primary-blue/10 to-transparent rounded-full blur-2xl opacity-0 group-hover:opacity-100 transition-opacity" />
<div className="flex items-start gap-2.5 md:gap-3 relative">
<div className="flex-shrink-0 w-9 h-9 md:w-10 md:h-10 rounded-xl bg-gradient-to-br from-primary-blue/25 to-blue-900/25 border border-primary-blue/40 flex items-center justify-center shadow-lg group-hover:shadow-primary-blue/20 group-hover:scale-110 transition-all">
<span className="text-primary-blue font-bold text-sm">3</span>
</div>
<span className="text-slate-200 text-sm md:text-base leading-relaxed font-light">
No clicking through wizards. No manual setup
</span>
</div>
</div>
</div>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
Automation instead of repetition.
</p>
</>
}
mockup={<CompanionAutomationMockup />}
layout="text-left"
/>
{/* Section 4: Game-Agnostic Platform */}
<AlternatingSection
heading="Built for iRacing. Ready for the future."
backgroundImage="/images/lmp3.jpeg"
description={
<>
<p className="text-sm md:text-base leading-relaxed">
Right now, we're focused on making iRacing league racing better.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
But sims come and go. Your leagues, your teams, your rating — those stay.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
GridPilot is built to outlast any single platform.
</p>
<p className="mt-4 md:mt-6 text-sm md:text-base leading-relaxed">
When the next sim arrives, your competitive identity moves with you.
</p>
</>
}
mockup={<SimPlatformMockup />}
layout="text-right"
/>
{/* Alpha-only discovery section */}
{isAlpha && (
<section className="max-w-7xl mx-auto mt-20 mb-20 px-6">
<div className="flex items-baseline justify-between mb-8">
<div>
<h2 className="text-2xl font-semibold text-white">Discover the grid</h2>
<p className="text-sm text-gray-400">
Explore leagues, teams, and races that make up the GridPilot ecosystem.
</p>
</div>
</div>
<div className="grid gap-8 lg:grid-cols-3">
{/* Top leagues */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Featured leagues</h3>
<Button
as="a"
href="/leagues"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View all
</Button>
</div>
<ul className="space-y-3 text-sm">
{topLeagues.slice(0, 4).map(league => (
<li key={league.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-primary-blue/15 border border-primary-blue/30 flex items-center justify-center text-xs font-semibold text-primary-blue">
{league.name
.split(' ')
.map(word => word[0])
.join('')
.slice(0, 3)
.toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{league.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{league.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Teams */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Teams on the grid</h3>
<Button
as="a"
href="/teams"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
Browse teams
</Button>
</div>
<ul className="space-y-3 text-sm">
{teams.slice(0, 4).map(team => (
<li key={team.id} className="flex items-start gap-3">
<div className="w-10 h-10 rounded-md bg-charcoal-outline flex items-center justify-center overflow-hidden border border-charcoal-outline">
<Image
src={team.logoUrl || getMediaUrl('team-logo', team.id)}
alt={team.name}
width={40}
height={40}
className="w-full h-full object-cover"
/>
</div>
<div className="flex-1 min-w-0">
<p className="text-white truncate">{team.name}</p>
<p className="text-xs text-gray-400 line-clamp-2">
{team.description}
</p>
</div>
</li>
))}
</ul>
</Card>
{/* Upcoming races */}
<Card className="bg-iron-gray/80">
<div className="flex items-baseline justify-between mb-4">
<h3 className="text-sm font-semibold text-white">Upcoming races</h3>
<Button
as="a"
href="/races"
variant="secondary"
className="text-[11px] px-3 py-1.5"
>
View schedule
</Button>
</div>
{upcomingRaces.length === 0 ? (
<p className="text-xs text-gray-400">
No races scheduled in this demo snapshot.
</p>
) : (
<ul className="space-y-3 text-sm">
{upcomingRaces.map(race => (
<li key={race.id} className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<p className="text-white truncate">{race.track}</p>
<p className="text-xs text-gray-400 truncate">{race.car}</p>
</div>
<div className="text-right text-xs text-gray-500 whitespace-nowrap">
{race.formattedDate}
</div>
</li>
))}
</ul>
)}
</Card>
</div>
</section>
)}
<DiscordCTA />
<FAQ />
<Footer />
</main>
);
}
return <PageWrapper data={data} Template={HomeTemplate} />;
}

View File

@@ -1,103 +1,85 @@
'use client';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useInject } from '@/lib/di/hooks/useInject';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { LeagueService } from '@/lib/services/leagues/LeagueService';
import type { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
import { ContainerManager } from '@/lib/di/container';
interface LeagueWithRole {
league: LeagueSummaryViewModel;
membership: LeagueMembership;
}
export default function ManageLeaguesPage() {
const [ownedLeagues, setOwnedLeagues] = useState<LeagueWithRole[]>([]);
const [memberLeagues, setMemberLeagues] = useState<LeagueWithRole[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const effectiveDriverId = useEffectiveDriverId();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
interface ProfileLeaguesData {
ownedLeagues: LeagueWithRole[];
memberLeagues: LeagueWithRole[];
}
useEffect(() => {
let cancelled = false;
async function fetchProfileLeaguesData(): Promise<ProfileLeaguesData | null> {
try {
// Get current driver ID from session
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
if (!session?.user?.primaryDriverId) {
return null;
}
const currentDriverId = session.user.primaryDriverId;
const load = async () => {
setLoading(true);
try {
const leagues = await leagueService.getAllLeagues();
// Fetch leagues using PageDataFetcher
const leagues = await PageDataFetcher.fetch<LeagueService, 'getAllLeagues'>(
LEAGUE_SERVICE_TOKEN,
'getAllLeagues'
);
const memberships = await Promise.all(
leagues.map(async (league) => {
await leagueMembershipService.fetchLeagueMemberships(league.id);
const membership = leagueMembershipService.getMembership(league.id, effectiveDriverId);
return { league, membership };
}),
);
if (!leagues) {
return null;
}
if (cancelled) {
return;
}
// Get membership service from container
const container = ContainerManager.getInstance().getContainer();
const membershipService = container.get<LeagueMembershipService>(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
// Fetch memberships for each league
const memberships = await Promise.all(
leagues.map(async (league) => {
await membershipService.fetchLeagueMemberships(league.id);
const membership = membershipService.getMembership(league.id, currentDriverId);
return membership ? { league, membership } : null;
})
);
for (const entry of memberships) {
if (!entry.membership || entry.membership.status !== 'active') {
continue;
}
// Filter and categorize leagues
const owned: LeagueWithRole[] = [];
const member: LeagueWithRole[] = [];
if (entry.membership.role === 'owner') {
owned.push(entry as LeagueWithRole);
} else {
member.push(entry as LeagueWithRole);
}
}
setOwnedLeagues(owned);
setMemberLeagues(member);
setError(null);
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load leagues');
}
} finally {
if (!cancelled) {
setLoading(false);
}
for (const entry of memberships) {
if (!entry || !entry.membership || entry.membership.status !== 'active') {
continue;
}
};
void load();
if (entry.membership.role === 'owner') {
owned.push(entry);
} else {
member.push(entry);
}
}
return () => {
cancelled = true;
};
}, [effectiveDriverId, leagueService, leagueMembershipService]);
if (loading) {
return (
<div className="max-w-6xl mx-auto">
<div className="text-center text-gray-400">Loading your leagues...</div>
</div>
);
}
if (error) {
return (
<div className="max-w-6xl mx-auto">
<Card>
<div className="text-center py-8 text-red-400">{error}</div>
</Card>
</div>
);
return { ownedLeagues: owned, memberLeagues: member };
} catch (error) {
console.error('Failed to fetch profile leagues data:', error);
return null;
}
}
// Template component
function ProfileLeaguesTemplate({ data }: { data: ProfileLeaguesData }) {
return (
<div className="max-w-6xl mx-auto space-y-8">
<div>
@@ -107,23 +89,24 @@ export default function ManageLeaguesPage() {
</p>
</div>
<Card>
{/* Leagues You Own */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you own</h2>
{ownedLeagues.length > 0 && (
{data.ownedLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{ownedLeagues.length} {ownedLeagues.length === 1 ? 'league' : 'leagues'}
{data.ownedLeagues.length} {data.ownedLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{ownedLeagues.length === 0 ? (
{data.ownedLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You don't own any leagues yet in this session.
</p>
) : (
<div className="space-y-3">
{ownedLeagues.map(({ league }) => (
{data.ownedLeagues.map(({ league }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
@@ -135,41 +118,42 @@ export default function ManageLeaguesPage() {
</p>
</div>
<div className="flex items-center gap-2">
<Link
<a
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View
</Link>
<Link href={`/leagues/${league.id}?tab=admin`}>
<Button variant="primary" className="text-xs px-3 py-1.5">
</a>
<a href={`/leagues/${league.id}?tab=admin`}>
<button className="bg-primary hover:bg-primary/90 text-white text-xs px-3 py-1.5 rounded transition-colors">
Manage
</Button>
</Link>
</button>
</a>
</div>
</div>
))}
</div>
)}
</Card>
</div>
<Card>
{/* Leagues You're In */}
<div className="bg-charcoal rounded-lg border border-charcoal-outline p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-white">Leagues you're in</h2>
{memberLeagues.length > 0 && (
{data.memberLeagues.length > 0 && (
<span className="text-xs text-gray-400">
{memberLeagues.length} {memberLeagues.length === 1 ? 'league' : 'leagues'}
{data.memberLeagues.length} {data.memberLeagues.length === 1 ? 'league' : 'leagues'}
</span>
)}
</div>
{memberLeagues.length === 0 ? (
{data.memberLeagues.length === 0 ? (
<p className="text-sm text-gray-400">
You're not a member of any other leagues yet.
</p>
) : (
<div className="space-y-3">
{memberLeagues.map(({ league, membership }) => (
{data.memberLeagues.map(({ league, membership }) => (
<div
key={league.id}
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
@@ -184,17 +168,38 @@ export default function ManageLeaguesPage() {
{membership.role.charAt(0).toUpperCase() + membership.role.slice(1)}
</p>
</div>
<Link
<a
href={`/leagues/${league.id}`}
className="text-sm text-gray-300 hover:text-white underline-offset-2 hover:underline"
>
View league
</Link>
</a>
</div>
))}
</div>
)}
</Card>
</div>
</div>
);
}
export default async function ProfileLeaguesPage() {
const data = await fetchProfileLeaguesData();
if (!data) {
notFound();
}
return (
<PageWrapper
data={data}
Template={ProfileLeaguesTemplate}
loading={{ variant: 'skeleton', message: 'Loading your leagues...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No leagues found',
description: 'You are not a member of any leagues yet.',
}}
/>
);
}

View File

@@ -17,8 +17,10 @@ import type {
} from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
// New architecture components
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
// Icons
import {
Activity,
Award,
@@ -255,30 +257,60 @@ function FinishDistributionChart({ wins, podiums, topTen, total }: FinishDistrib
}
// ============================================================================
// MAIN PAGE COMPONENT
// TEMPLATE COMPONENT
// ============================================================================
export default function ProfilePage() {
interface ProfileTemplateProps {
data: DriverProfileViewModel;
onEdit: () => void;
onAddFriend: () => void;
activeTab: ProfileTab;
setActiveTab: (tab: ProfileTab) => void;
friendRequestSent: boolean;
isOwnProfile: boolean;
handleSaveSettings: (updates: { bio?: string; country?: string }) => Promise<void>;
editMode: boolean;
setEditMode: (edit: boolean) => void;
}
function ProfileTemplate({
data,
onEdit,
onAddFriend,
activeTab,
setActiveTab,
friendRequestSent,
isOwnProfile,
handleSaveSettings,
editMode,
setEditMode
}: ProfileTemplateProps) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const mediaService = useInject(MEDIA_SERVICE_TOKEN);
// Extract data from ViewModel
const currentDriver = data.currentDriver;
if (!currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
// Use React-Query hook for profile data
const { data: profileData, isLoading: loading, error, retry } = useDriverProfile(effectiveDriverId || '');
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const stats = data.stats;
const teamMemberships = data.teamMemberships;
const socialSummary = data.socialSummary;
const extendedProfile = data.extendedProfile;
const globalRank = currentDriver.globalRank || null;
// Update URL when tab changes
useEffect(() => {
if (tabParam !== activeTab) {
if (searchParams.get('tab') !== activeTab) {
const params = new URLSearchParams(searchParams.toString());
if (activeTab === 'overview') {
params.delete('tab');
@@ -288,62 +320,18 @@ export default function ProfilePage() {
const query = params.toString();
router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false });
}
}, [activeTab, tabParam, searchParams, router]);
}, [activeTab, searchParams, router]);
// Sync tab from URL on mount and param change
useEffect(() => {
const tabParam = searchParams.get('tab') as ProfileTab | null;
if (tabParam && tabParam !== activeTab) {
setActiveTab(tabParam);
}
}, [tabParam]);
const handleSaveSettings = async (updates: { bio?: string; country?: string }) => {
if (!profileData?.currentDriver) return;
try {
const updatedProfile = await driverService.updateProfile(updates);
// Update local state
retry();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
}
};
const handleAddFriend = () => {
setFriendRequestSent(true);
// In production, this would call a use case
};
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<User className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
<p className="text-gray-400">
Join the GridPilot community and start your racing journey
</p>
</div>
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
<p className="text-gray-400 text-sm">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p>
</div>
<CreateDriverForm />
</Card>
</div>
);
}
}, [searchParams]);
// Show edit mode
if (editMode && profileData?.currentDriver) {
if (editMode && currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4 space-y-6">
<div className="flex items-center justify-between mb-4">
@@ -352,55 +340,13 @@ export default function ProfilePage() {
Cancel
</Button>
</div>
<ProfileSettings driver={profileData.currentDriver} onSave={handleSaveSettings} />
<ProfileSettings driver={currentDriver} onSave={handleSaveSettings} />
</div>
);
}
return (
<StateContainer
data={profileData}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading profile...' },
error: { variant: 'full-screen' },
empty: {
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: retry }
}
}}
>
{(profileData) => {
// Extract data from profileData ViewModel
// At this point, we know profileData exists and currentDriver should exist
// (otherwise we would have shown the create form above)
const currentDriver = profileData.currentDriver;
// If currentDriver is null despite our checks, show empty state
if (!currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver.globalRank || null;
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}
@@ -497,7 +443,7 @@ export default function ProfilePage() {
{isOwnProfile ? (
<Button
variant="primary"
onClick={() => setEditMode(true)}
onClick={onEdit}
className="flex items-center gap-2"
>
<Edit3 className="w-4 h-4" />
@@ -507,7 +453,7 @@ export default function ProfilePage() {
<>
<Button
variant="primary"
onClick={handleAddFriend}
onClick={onAddFriend}
disabled={friendRequestSent}
className="flex items-center gap-2"
>
@@ -1055,16 +1001,112 @@ export default function ProfilePage() {
</div>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
}
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default function ProfilePage() {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const mediaService = useInject(MEDIA_SERVICE_TOKEN);
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
// Use React-Query hook for profile data
const { data: profileData, isLoading: loading, error, refetch } = useDriverProfile(effectiveDriverId || '');
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const handleSaveSettings = async (updates: { bio?: string; country?: string }) => {
if (!profileData?.currentDriver) return;
try {
const updatedProfile = await driverService.updateProfile(updates);
// Update local state
refetch();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
}
};
const handleAddFriend = () => {
setFriendRequestSent(true);
// In production, this would call a use case
};
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-primary-blue/20 to-purple-600/10 border border-primary-blue/30 mx-auto mb-4">
<User className="w-8 h-8 text-primary-blue" />
</div>
<Heading level={1} className="mb-2">Create Your Driver Profile</Heading>
<p className="text-gray-400">
Join the GridPilot community and start your racing journey
</p>
</div>
);
}}
</StateContainer>
);
<Card className="max-w-2xl mx-auto">
<div className="mb-6">
<h2 className="text-xl font-semibold text-white mb-2">Get Started</h2>
<p className="text-gray-400 text-sm">
Create your driver profile to join leagues, compete in races, and connect with other drivers.
</p>
</div>
<CreateDriverForm />
</Card>
</div>
);
}
return (
<StatefulPageWrapper
data={profileData}
isLoading={loading}
error={error}
retry={refetch}
Template={({ data }) => (
<ProfileTemplate
data={data}
onEdit={() => setEditMode(true)}
onAddFriend={handleAddFriend}
activeTab={activeTab}
setActiveTab={setActiveTab}
friendRequestSent={friendRequestSent}
isOwnProfile={isOwnProfile}
handleSaveSettings={handleSaveSettings}
editMode={editMode}
setEditMode={setEditMode}
/>
)}
loading={{ variant: 'full-screen', message: 'Loading profile...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: refetch }
}}
/>
);
}

View File

@@ -1,6 +1,13 @@
import { Bell, Shield, Eye, Volume2 } from 'lucide-react';
'use client';
export default function SettingsPage() {
import { Bell, Shield, Eye, Volume2 } from 'lucide-react';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
interface SettingsData {
// Settings page is static, no data needed
}
function SettingsTemplate({ data }: { data: SettingsData }) {
return (
<div className="min-h-screen bg-deep-graphite">
<div className="container mx-auto px-4 py-8">
@@ -199,4 +206,21 @@ export default function SettingsPage() {
</div>
</div>
);
}
export default function SettingsPage() {
return (
<StatefulPageWrapper
data={{} as SettingsData}
isLoading={false}
error={null}
Template={SettingsTemplate}
loading={{ variant: 'skeleton', message: 'Loading settings...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No settings available',
description: 'Unable to load settings page',
}}
/>
);
}

View File

@@ -1,292 +1,48 @@
'use client';
import Breadcrumbs from '@/components/layout/Breadcrumbs';
import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useCallback, useEffect, useState } from 'react';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import {
useSponsorshipRequestsPageData,
useSponsorshipRequestMutations
} from '@/hooks/sponsor/useSponsorshipRequestsPageData';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { SPONSORSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN, LEAGUE_SERVICE_TOKEN, TEAM_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SponsorshipRequestViewModel } from '@/lib/view-models/SponsorshipRequestViewModel';
import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react';
import Link from 'next/link';
interface EntitySection {
entityType: 'driver' | 'team' | 'race' | 'season';
entityId: string;
entityName: string;
requests: SponsorshipRequestViewModel[];
}
export default function SponsorshipRequestsPage() {
const currentDriverId = useEffectiveDriverId();
const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN);
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
// Fetch data using domain hook
const { data: sections, isLoading, error, refetch } = useSponsorshipRequestsPageData(currentDriverId);
const [sections, setSections] = useState<EntitySection[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Mutations using domain hook
const { acceptMutation, rejectMutation } = useSponsorshipRequestMutations(currentDriverId, refetch);
const loadAllRequests = useCallback(async () => {
setLoading(true);
setError(null);
try {
if (!currentDriverId) {
setSections([]);
return;
}
const allSections: EntitySection[] = [];
// 1. Driver's own sponsorship requests
const driverRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'driver',
entityId: currentDriverId,
});
if (driverRequests.length > 0) {
const driverProfile = await driverService.getDriverProfile(currentDriverId);
allSections.push({
entityType: 'driver',
entityId: currentDriverId,
entityName: driverProfile?.currentDriver?.name ?? 'Your Profile',
requests: driverRequests,
});
}
// 2. Leagues where the user is admin/owner
const allLeagues = await leagueService.getAllLeagues();
for (const league of allLeagues) {
const membership = await leagueMembershipService.getMembership(league.id, currentDriverId);
if (membership && LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role)) {
// Load sponsorship requests for this league's active season
try {
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
const leagueRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'season',
entityId: league.id, // Using league ID as a proxy for now
});
if (leagueRequests.length > 0) {
allSections.push({
entityType: 'season',
entityId: league.id,
entityName: league.name,
requests: leagueRequests,
});
}
} catch (err) {
// Silently skip if no requests found
}
}
}
// 3. Teams where the user is owner/manager
const allTeams = await teamService.getAllTeams();
for (const team of allTeams) {
const membership = await teamService.getMembership(team.id, currentDriverId);
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
const teamRequests = await sponsorshipService.getPendingSponsorshipRequests({
entityType: 'team',
entityId: team.id,
});
if (teamRequests.length > 0) {
allSections.push({
entityType: 'team',
entityId: team.id,
entityName: team.name,
requests: teamRequests,
});
}
}
}
setSections(allSections);
} catch (err) {
console.error('Failed to load sponsorship requests:', err);
setError(err instanceof Error ? err.message : 'Failed to load requests');
} finally {
setLoading(false);
}
}, [currentDriverId, sponsorshipService, driverService, leagueService, teamService, leagueMembershipService]);
useEffect(() => {
loadAllRequests();
}, [loadAllRequests]);
const handleAccept = async (requestId: string) => {
if (!currentDriverId) return;
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
await loadAllRequests();
};
const handleReject = async (requestId: string, reason?: string) => {
if (!currentDriverId) return;
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
await loadAllRequests();
};
const getEntityIcon = (type: 'driver' | 'team' | 'race' | 'season') => {
switch (type) {
case 'driver':
return User;
case 'team':
return Users;
case 'race':
return Trophy;
case 'season':
return Trophy;
default:
return Building;
}
};
const getEntityLink = (type: 'driver' | 'team' | 'race' | 'season', id: string) => {
switch (type) {
case 'driver':
return `/drivers/${id}`;
case 'team':
return `/teams/${id}`;
case 'race':
return `/races/${id}`;
case 'season':
return `/leagues/${id}/sponsorships`;
default:
return '#';
}
};
const totalRequests = sections.reduce((sum, s) => sum + s.requests.length, 0);
// Template needs to handle mutations
const TemplateWithMutations = ({ data }: { data: any[] }) => (
<SponsorshipRequestsTemplate
data={data}
onAccept={async (requestId) => {
await acceptMutation.mutateAsync({ requestId });
}}
onReject={async (requestId, reason) => {
await rejectMutation.mutateAsync({ requestId, reason });
}}
/>
);
return (
<div className="max-w-4xl mx-auto px-4 py-8">
<Breadcrumbs
items={[
{ label: 'Profile', href: '/profile' },
{ label: 'Sponsorship Requests' },
]}
/>
{/* Header */}
<div className="flex items-center gap-4 mt-6 mb-8">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-performance-green/10 border border-performance-green/30">
<Handshake className="w-7 h-7 text-performance-green" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsorship Requests</h1>
<p className="text-sm text-gray-400">
Manage sponsorship requests for your profile, teams, and leagues
</p>
</div>
{totalRequests > 0 && (
<div className="ml-auto px-3 py-1 rounded-full bg-performance-green/20 text-performance-green text-sm font-semibold">
{totalRequests} pending
</div>
)}
</div>
{loading ? (
<Card>
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading sponsorship requests...</div>
</div>
</Card>
) : error ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-red-500/10 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-red-400" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Error Loading Requests</h3>
<p className="text-sm text-gray-400">{error}</p>
<Button variant="secondary" onClick={loadAllRequests} className="mt-4">
Try Again
</Button>
</div>
</Card>
) : sections.length === 0 ? (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<Handshake className="w-8 h-8 text-gray-500" />
</div>
<h3 className="text-lg font-medium text-white mb-2">No Pending Requests</h3>
<p className="text-sm text-gray-400">
You don't have any pending sponsorship requests at the moment.
</p>
<p className="text-xs text-gray-500 mt-2">
Sponsors can apply to sponsor your profile, teams, or leagues you manage.
</p>
</div>
</Card>
) : (
<div className="space-y-6">
{sections.map((section) => {
const Icon = getEntityIcon(section.entityType);
const entityLink = getEntityLink(section.entityType, section.entityId);
return (
<Card key={`${section.entityType}-${section.entityId}`}>
{/* Section Header */}
<div className="flex items-center justify-between mb-6 pb-4 border-b border-charcoal-outline">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-iron-gray/50">
<Icon className="w-5 h-5 text-gray-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{section.entityName}</h2>
<p className="text-xs text-gray-500 capitalize">{section.entityType}</p>
</div>
</div>
<Link
href={entityLink}
className="flex items-center gap-1 text-sm text-primary-blue hover:text-primary-blue/80 transition-colors"
>
View {section.entityType === 'season' ? 'Sponsorships' : section.entityType}
<ChevronRight className="w-4 h-4" />
</Link>
</div>
{/* Requests */}
<PendingSponsorshipRequests
entityType={section.entityType}
entityId={section.entityId}
entityName={section.entityName}
requests={section.requests}
onAccept={handleAccept}
onReject={handleReject}
/>
</Card>
);
})}
</div>
)}
{/* Info Card */}
<Card className="mt-8 bg-gradient-to-r from-primary-blue/5 to-transparent border-primary-blue/20">
<div className="flex items-start gap-4">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/20 flex-shrink-0">
<Building className="w-5 h-5 text-primary-blue" />
</div>
<div>
<h3 className="text-sm font-semibold text-white mb-1">How Sponsorships Work</h3>
<p className="text-xs text-gray-400 leading-relaxed">
Sponsors can apply to sponsor your driver profile, teams you manage, or leagues you administer.
Review each request carefully - accepting will activate the sponsorship and the sponsor will be
charged. You'll receive the payment minus a 10% platform fee.
</p>
</div>
</div>
</Card>
</div>
<StatefulPageWrapper
data={sections}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={TemplateWithMutations}
loading={{ variant: 'skeleton', message: 'Loading sponsorship requests...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'No Pending Requests',
description: 'You don\'t have any pending sponsorship requests at the moment.',
}}
/>
);
}

View File

@@ -1,180 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RacesTemplate, TimeFilter, RaceStatusFilter } from '@/templates/RacesTemplate';
import { useRacesPageData } from '@/hooks/race/useRacesPageData';
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import { useCancelRace } from '@/hooks/race/useCancelRace';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
export function RacesInteractive() {
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
// Fetch data
const { data: pageData, isLoading } = useRacesPageData();
// Mutations
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const cancelMutation = useCancelRace();
// Filter state
const [statusFilter, setStatusFilter] = useState<RaceStatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [timeFilter, setTimeFilter] = useState<TimeFilter>('upcoming');
const [showFilterModal, setShowFilterModal] = useState(false);
// Transform data for template
const races = pageData?.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race', // Not in RaceListItemViewModel, using default
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const scheduledRaces = pageData?.scheduledRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const runningRaces = pageData?.runningRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
const completedRaces = pageData?.completedRaces.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
})) ?? [];
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleRegister = async (raceId: string, leagueId: string) => {
if (!currentDriverId) {
router.push('/auth/login');
return;
}
const confirmed = window.confirm(
`Register for this race?\n\nYou'll be added to the entry list.`,
);
if (!confirmed) return;
try {
await registerMutation.mutateAsync({ raceId, leagueId, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
}
};
const handleWithdraw = async (raceId: string) => {
if (!currentDriverId) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
);
if (!confirmed) return;
try {
await withdrawMutation.mutateAsync({ raceId, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
}
};
const handleCancel = async (raceId: string) => {
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.',
);
if (!confirmed) return;
try {
await cancelMutation.mutateAsync(raceId);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
}
};
// User memberships for admin check
// For now, we'll handle permissions in the template using LeagueMembershipUtility
// This would need actual membership data to work properly
const userMemberships: Array<{ leagueId: string; role: string }> = [];
return (
<RacesTemplate
races={races}
totalCount={pageData?.totalCount ?? 0}
scheduledRaces={scheduledRaces}
runningRaces={runningRaces}
completedRaces={completedRaces}
isLoading={isLoading}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
timeFilter={timeFilter}
setTimeFilter={setTimeFilter}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
currentDriverId={currentDriverId}
userMemberships={userMemberships}
/>
);
}

View File

@@ -1,79 +0,0 @@
import { RacesTemplate } from '@/templates/RacesTemplate';
import { ContainerManager } from '@/lib/di/container';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
import type { RaceService } from '@/lib/services/races/RaceService';
// This is a server component that fetches data and passes it to the template
export async function RacesStatic() {
const container = ContainerManager.getInstance().getContainer();
const raceService = container.get<RaceService>(RACE_SERVICE_TOKEN);
// Fetch race data server-side
const pageData = await raceService.getRacesPageData();
// Extract races from the response
const races = pageData.races.map((race: RaceListItemViewModel) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race', // Default since RaceListItemViewModel doesn't have sessionType
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
}));
// Transform the categorized races as well
const transformRaces = (raceList: RaceListItemViewModel[]) =>
raceList.map((race: RaceListItemViewModel) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
}));
// For the static wrapper, we'll use client-side data fetching
// This component will be used as a server component that renders the client template
return (
<RacesTemplate
races={races}
totalCount={pageData.totalCount}
scheduledRaces={transformRaces(pageData.scheduledRaces)}
runningRaces={transformRaces(pageData.runningRaces)}
completedRaces={transformRaces(pageData.completedRaces)}
isLoading={false}
// Filter state - will be managed by Interactive component
statusFilter="all"
setStatusFilter={() => {}}
leagueFilter="all"
setLeagueFilter={() => {}}
timeFilter="upcoming"
setTimeFilter={() => {}}
// Actions
onRaceClick={() => {}}
onLeagueClick={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
onCancel={() => {}}
// UI State
showFilterModal={false}
setShowFilterModal={() => {}}
// User state
currentDriverId={undefined}
userMemberships={undefined}
/>
);
}

View File

@@ -1,240 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
import { useRegisterForRace } from '@/hooks/race/useRegisterForRace';
import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace';
import { useCancelRace } from '@/hooks/race/useCancelRace';
import { useCompleteRace } from '@/hooks/race/useCompleteRace';
import { useReopenRace } from '@/hooks/race/useReopenRace';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceDetail } from '@/hooks/race/useRaceDetail';
import { Flag } from 'lucide-react';
export function RaceDetailInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using DI + React-Query
const { data: viewModel, isLoading, error, retry } = useRaceDetail(raceId, currentDriverId);
// Fetch membership
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
// UI State
const [showProtestModal, setShowProtestModal] = useState(false);
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
// Mutations
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
const cancelMutation = useCancelRace();
const completeMutation = useCompleteRace();
const reopenMutation = useReopenRace();
// Determine if user is owner/admin
const isOwnerOrAdmin = membership
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
: false;
// Actions
const handleBack = () => {
router.back();
};
const handleRegister = async () => {
const race = viewModel?.race;
const league = viewModel?.league;
if (!race || !league) return;
const confirmed = window.confirm(
`Register for ${race.track}?\n\nYou'll be added to the entry list for this race.`,
);
if (!confirmed) return;
try {
await registerMutation.mutateAsync({ raceId: race.id, leagueId: league.id, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race');
}
};
const handleWithdraw = async () => {
const race = viewModel?.race;
const league = viewModel?.league;
if (!race || !league) return;
const confirmed = window.confirm(
'Withdraw from this race?\n\nYou can register again later if you change your mind.',
);
if (!confirmed) return;
try {
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
}
};
const handleCancel = async () => {
const race = viewModel?.race;
if (!race || race.status !== 'scheduled') return;
const confirmed = window.confirm(
'Are you sure you want to cancel this race? This action cannot be undone.',
);
if (!confirmed) return;
try {
await cancelMutation.mutateAsync(race.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race');
}
};
const handleReopen = async () => {
const race = viewModel?.race;
if (!race || !viewModel?.canReopenRace) return;
const confirmed = window.confirm(
'Re-open this race? This will allow re-registration and re-running. Results will be archived.',
);
if (!confirmed) return;
try {
await reopenMutation.mutateAsync(race.id);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race');
}
};
const handleEndRace = async () => {
const race = viewModel?.race;
if (!race) return;
setShowEndRaceModal(true);
};
const handleFileProtest = () => {
setShowProtestModal(true);
};
const handleResultsClick = () => {
router.push(`/races/${raceId}/results`);
};
const handleStewardingClick = () => {
router.push(`/races/${raceId}/stewarding`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);
};
// Transform data for template - handle null values
const templateViewModel = viewModel && viewModel.race ? {
race: {
id: viewModel.race.id,
track: viewModel.race.track,
car: viewModel.race.car,
scheduledAt: viewModel.race.scheduledAt,
status: viewModel.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: viewModel.race.sessionType,
},
league: viewModel.league ? {
id: viewModel.league.id,
name: viewModel.league.name,
description: viewModel.league.description || undefined,
settings: viewModel.league.settings as { maxDrivers: number; qualifyingFormat: string },
} : undefined,
entryList: viewModel.entryList.map(entry => ({
id: entry.id,
name: entry.name,
avatarUrl: entry.avatarUrl,
country: entry.country,
rating: entry.rating,
isCurrentUser: entry.isCurrentUser,
})),
registration: {
isUserRegistered: viewModel.registration.isUserRegistered,
canRegister: viewModel.registration.canRegister,
},
userResult: viewModel.userResult ? {
position: viewModel.userResult.position,
startPosition: viewModel.userResult.startPosition,
positionChange: viewModel.userResult.positionChange,
incidents: viewModel.userResult.incidents,
isClean: viewModel.userResult.isClean,
isPodium: viewModel.userResult.isPodium,
ratingChange: viewModel.userResult.ratingChange,
} : undefined,
canReopenRace: viewModel.canReopenRace,
} : undefined;
return (
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race details...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'Race not found',
description: 'The race may have been cancelled or deleted',
action: { label: 'Back to Races', onClick: handleBack }
}
}}
>
{(raceData) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
}}
/>
)}
</StateContainer>
);
}

View File

@@ -1,238 +0,0 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { RaceDetailInteractive } from './RaceDetailInteractive';
import type { RaceDetailsViewModel } from '@/lib/view-models/RaceDetailsViewModel';
// Mocks for Next.js navigation
const mockPush = vi.fn();
const mockBack = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({
push: mockPush,
back: mockBack,
}),
useParams: () => ({ id: 'race-123' }),
}));
// Mock effective driver id hook
vi.mock('@/hooks/useEffectiveDriverId', () => ({
useEffectiveDriverId: () => 'driver-1',
}));
// Mock sponsor mode hook to avoid rendering heavy sponsor card
vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({
__esModule: true,
default: () => <div data-testid="sponsor-insights-mock" />,
MetricBuilders: {
views: vi.fn(() => ({ label: 'Views', value: '100' })),
engagement: vi.fn(() => ({ label: 'Engagement', value: '50%' })),
reach: vi.fn(() => ({ label: 'Reach', value: '1000' })),
},
SlotTemplates: {
race: vi.fn(() => []),
},
useSponsorMode: () => false,
}));
// Mock the new DI hooks
const mockGetRaceDetails = vi.fn();
const mockReopenRace = vi.fn();
const mockFetchLeagueMemberships = vi.fn();
const mockGetMembership = vi.fn();
// Mock race detail hook
vi.mock('@/hooks/race/useRaceDetail', () => ({
useRaceDetail: (raceId: string, driverId: string) => ({
data: mockGetRaceDetails.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockGetRaceDetails.mock.results[0]?.value,
refetch: vi.fn(),
retry: vi.fn(),
}),
}));
// Mock reopen race hook
vi.mock('@/hooks/race/useReopenRace', () => ({
useReopenRace: () => ({
mutateAsync: mockReopenRace,
mutate: mockReopenRace,
isPending: false,
isLoading: false,
}),
}));
// Mock league membership service static method
vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({
LeagueMembershipService: {
getMembership: mockGetMembership,
fetchLeagueMemberships: mockFetchLeagueMemberships,
setLeagueMemberships: vi.fn(),
clearLeagueMemberships: vi.fn(),
getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()),
getAllMembershipsForDriver: vi.fn(() => []),
getLeagueMembers: vi.fn(() => []),
},
}));
// Mock league membership hook (if used by component)
vi.mock('@/hooks/league/useLeagueMemberships', () => ({
useLeagueMemberships: (leagueId: string, currentUserId: string) => ({
data: mockFetchLeagueMemberships.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockFetchLeagueMemberships.mock.results[0]?.value,
refetch: vi.fn(),
}),
}));
// Mock the useLeagueMembership hook that the component imports
vi.mock('@/hooks/useLeagueMembershipService', () => ({
useLeagueMembership: (leagueId: string, driverId: string) => ({
data: mockGetMembership.mock.results[0]?.value || null,
isLoading: false,
isError: false,
isSuccess: !!mockGetMembership.mock.results[0]?.value,
refetch: vi.fn(),
}),
}));
// We'll use the actual hooks but they will use the mocked services
// The hooks are already mocked above via the service mocks
// Mock league membership utility to control admin vs non-admin behavior
const mockIsOwnerOrAdmin = vi.fn();
vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
LeagueMembershipUtility: {
isOwnerOrAdmin: (...args: unknown[]) => mockIsOwnerOrAdmin(...args),
},
}));
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string): RaceDetailsViewModel => {
const canReopenRace = status === 'completed' || status === 'cancelled';
return {
race: {
id: 'race-123',
track: 'Test Track',
car: 'Test Car',
scheduledAt: '2023-12-31T20:00:00Z',
status,
sessionType: 'race',
},
league: {
id: 'league-1',
name: 'Test League',
description: 'Test league description',
settings: {
maxDrivers: 32,
qualifyingFormat: 'open',
},
},
entryList: [],
registration: {
isUserRegistered: false,
canRegister: false,
},
userResult: null,
canReopenRace,
};
};
describe('RaceDetailPage - Re-open Race behavior', () => {
beforeEach(() => {
// Reset all mocks
mockGetRaceDetails.mockReset();
mockReopenRace.mockReset();
mockFetchLeagueMemberships.mockReset();
mockGetMembership.mockReset();
mockIsOwnerOrAdmin.mockReset();
// Set up default mock implementations
mockFetchLeagueMemberships.mockResolvedValue(undefined);
mockGetMembership.mockReturnValue({ role: 'owner' }); // Return owner role by default
});
it('shows Re-open Race button for admin when race is completed and calls reopen + reload on confirm', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('completed');
// Mock the hooks to return the right data
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'owner' });
mockReopenRace.mockResolvedValue(undefined);
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
renderWithQueryClient(<RaceDetailInteractive />);
// Wait for the component to load and render
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
// Check if the reopen button is present
const reopenButton = screen.getByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
fireEvent.click(reopenButton);
await waitFor(() => {
expect(mockReopenRace).toHaveBeenCalledWith('race-123');
});
confirmSpy.mockRestore();
});
it('does not render Re-open Race button for non-admin viewer', async () => {
mockIsOwnerOrAdmin.mockReturnValue(false);
const viewModel = createViewModel('completed');
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'member' });
renderWithQueryClient(<RaceDetailInteractive />);
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
it('does not render Re-open Race button when race is not completed or cancelled even for admin', async () => {
mockIsOwnerOrAdmin.mockReturnValue(true);
const viewModel = createViewModel('scheduled');
mockGetRaceDetails.mockReturnValue(viewModel);
mockGetMembership.mockReturnValue({ role: 'owner' });
renderWithQueryClient(<RaceDetailInteractive />);
await waitFor(() => {
const tracks = screen.getAllByText('Test Track');
expect(tracks.length).toBeGreaterThan(0);
});
expect(screen.queryByText('Re-open Race')).toBeNull();
});
});

View File

@@ -1,3 +1,117 @@
import { RaceDetailInteractive } from './RaceDetailInteractive';
import { notFound } from 'next/navigation';
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 type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
export default RaceDetailInteractive;
interface RaceDetailPageProps {
params: {
id: string;
};
}
export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
const raceId = params.id;
if (!raceId) {
notFound();
}
// Fetch initial race data
const data = await PageDataFetcher.fetch<RaceService, 'getRaceDetail'>(
RACE_SERVICE_TOKEN,
'getRaceDetail',
raceId,
'' // currentDriverId - will be handled client-side for auth
);
if (!data) notFound();
// Transform data for template
const templateViewModel = data && data.race ? {
race: {
id: data.race.id,
track: data.race.track,
car: data.race.car,
scheduledAt: data.race.scheduledAt,
status: data.race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: data.race.sessionType,
},
league: data.league ? {
id: data.league.id,
name: data.league.name,
description: data.league.description || undefined,
settings: data.league.settings as { maxDrivers: number; qualifyingFormat: string },
} : undefined,
entryList: data.entryList.map((entry: any) => ({
id: entry.id,
name: entry.name,
avatarUrl: entry.avatarUrl,
country: entry.country,
rating: entry.rating,
isCurrentUser: entry.isCurrentUser,
})),
registration: {
isUserRegistered: data.registration.isUserRegistered,
canRegister: data.registration.canRegister,
},
userResult: data.userResult ? {
position: data.userResult.position,
startPosition: data.userResult.startPosition,
positionChange: data.userResult.positionChange,
incidents: data.userResult.incidents,
isClean: data.userResult.isClean,
isPodium: data.userResult.isPodium,
ratingChange: data.userResult.ratingChange,
} : undefined,
canReopenRace: data.canReopenRace,
} : undefined;
return (
<PageWrapper
data={data}
Template={({ data }) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
// These will be handled client-side in the template or a wrapper
onBack={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
onCancel={() => {}}
onReopen={() => {}}
onEndRace={() => {}}
onFileProtest={() => {}}
onResultsClick={() => {}}
onStewardingClick={() => {}}
onLeagueClick={() => {}}
onDriverClick={() => {}}
currentDriverId={''}
isOwnerOrAdmin={false}
showProtestModal={false}
setShowProtestModal={() => {}}
showEndRaceModal={false}
setShowEndRaceModal={() => {}}
mutationLoading={{
register: false,
withdraw: false,
cancel: false,
reopen: false,
complete: false,
}}
/>
)}
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: () => {} }
}}
/>
);
}

View File

@@ -1,137 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceResultsDetail } from '@/hooks/race/useRaceResultsDetail';
import { useRaceWithSOF } from '@/hooks/race/useRaceWithSOF';
import { Trophy } from 'lucide-react';
export function RaceResultsInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using existing hooks
const { data: raceData, isLoading, error, retry } = useRaceResultsDetail(raceId, currentDriverId);
const { data: sofData } = useRaceWithSOF(raceId);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(raceData?.league?.id || '', currentDriverId || '');
// UI State
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [showImportForm, setShowImportForm] = useState(false);
const raceSOF = sofData?.strengthOfField || null;
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
// Transform data for template
const results = raceData?.results.map(result => ({
position: result.position,
driverId: result.driverId,
driverName: result.driverName,
driverAvatar: result.avatarUrl,
country: 'US', // Default since view model doesn't have country
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,
})) ?? [];
const penalties = raceData?.penalties.map(penalty => ({
driverId: penalty.driverId,
driverName: raceData.results.find(r => 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
})) ?? [];
// Actions
const handleBack = () => {
router.back();
};
const handleImportResults = async (importedResults: any[]) => {
setImporting(true);
setImportError(null);
try {
// TODO: Implement race results service
// await raceResultsService.importRaceResults(raceId, {
// resultsFileContent: JSON.stringify(importedResults),
// });
setImportSuccess(true);
// await loadData();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Failed to import results');
} finally {
setImporting(false);
}
};
const handlePenaltyClick = (driver: { id: string; name: string }) => {
// This would open a penalty modal in a real implementation
console.log('Penalty click for:', driver);
};
return (
<StateContainer
data={raceData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race results...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No results available',
description: 'Race results will appear here once the race is completed',
action: { label: 'Back to Race', onClick: handleBack }
}
}}
>
{(raceResultsData) => (
<RaceResultsTemplate
raceTrack={raceResultsData?.race?.track}
raceScheduledAt={raceResultsData?.race?.scheduledAt}
totalDrivers={raceResultsData?.stats.totalDrivers}
leagueName={raceResultsData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceResultsData?.pointsSystem ?? {}}
fastestLapTime={raceResultsData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
</StateContainer>
);
}

View File

@@ -1,3 +1,125 @@
import { RaceResultsInteractive } from './RaceResultsInteractive';
'use client';
export default RaceResultsInteractive;
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
import { useRaceResultsPageData } from '@/hooks/race/useRaceResultsPageData';
import { RaceResultsDataTransformer } from '@/lib/transformers/RaceResultsDataTransformer';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useState } from 'react';
import { notFound, useRouter } from 'next/navigation';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { Trophy } from 'lucide-react';
interface RaceResultsPageProps {
params: {
id: string;
};
}
export default function RaceResultsPage({ params }: RaceResultsPageProps) {
const router = useRouter();
const raceId = params.id;
if (!raceId) {
notFound();
}
const currentDriverId = useEffectiveDriverId() || '';
// Fetch data using domain hook
const { data: queries, isLoading, error, refetch } = useRaceResultsPageData(raceId, currentDriverId);
// Additional data - league memberships
const leagueName = queries?.results?.league?.name || '';
const { data: memberships } = useLeagueMemberships(leagueName, currentDriverId);
// Transform data
const data = queries?.results && queries?.sof
? RaceResultsDataTransformer.transform(
queries.results,
queries.sof,
currentDriverId,
memberships
)
: undefined;
// UI State for import functionality
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);
const [importError, setImportError] = useState<string | null>(null);
const [showImportForm, setShowImportForm] = useState(false);
// Actions
const handleBack = () => router.back();
const handleImportResults = async (importedResults: any[]) => {
setImporting(true);
setImportError(null);
try {
console.log('Import results:', importedResults);
setImportSuccess(true);
// Refetch data after import
await refetch();
} catch (err) {
setImportError(err instanceof Error ? err.message : 'Failed to import results');
} finally {
setImporting(false);
}
};
const handlePenaltyClick = (driver: { id: string; name: string }) => {
console.log('Penalty click for:', driver);
};
// Determine admin status from memberships data
const currentDriver = data?.results.find(r => r.isCurrentUser);
const currentMembership = data?.memberships?.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership
? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role)
: false;
return (
<StatefulPageWrapper
data={data}
isLoading={isLoading}
error={error as Error | null}
retry={refetch}
Template={({ data }) => (
<RaceResultsTemplate
raceTrack={data.raceTrack}
raceScheduledAt={data.raceScheduledAt}
totalDrivers={data.totalDrivers}
leagueName={data.leagueName}
raceSOF={data.raceSOF}
results={data.results}
penalties={data.penalties}
pointsSystem={data.pointsSystem}
fastestLapTime={data.fastestLapTime}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading race results...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No results available',
description: 'Race results will appear here once the race is completed',
action: { label: 'Back to Race', onClick: handleBack }
}}
/>
);
}

View File

@@ -1,87 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useRaceStewardingData } from '@/hooks/race/useRaceStewardingData';
import { Gavel } from 'lucide-react';
export function RaceStewardingInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
// Fetch data using existing hooks
const { data: stewardingData, isLoading, error, retry } = useRaceStewardingData(raceId, currentDriverId);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(stewardingData?.league?.id || '', currentDriverId || '');
// UI State
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
// Actions
const handleBack = () => {
router.push(`/races/${raceId}`);
};
const handleReviewProtest = (protestId: string) => {
// Navigate to protest review page
router.push(`/leagues/${stewardingData?.league?.id}/stewarding/protests/${protestId}`);
};
// Transform data for template
const templateData = stewardingData ? {
race: stewardingData.race,
league: stewardingData.league,
pendingProtests: stewardingData.pendingProtests,
resolvedProtests: stewardingData.resolvedProtests,
penalties: stewardingData.penalties,
driverMap: stewardingData.driverMap,
pendingCount: stewardingData.pendingCount,
resolvedCount: stewardingData.resolvedCount,
penaltiesCount: stewardingData.penaltiesCount,
} : undefined;
return (
<StateContainer
data={stewardingData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Gavel,
title: 'No stewarding data',
description: 'No protests or penalties for this race',
action: { label: 'Back to Race', onClick: handleBack }
}
}}
>
{(stewardingData) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</StateContainer>
);
}

View File

@@ -1,3 +1,140 @@
import { RaceStewardingInteractive } from './RaceStewardingInteractive';
'use client';
export default RaceStewardingInteractive;
import { useState, useEffect } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { RaceStewardingService } from '@/lib/services/races/RaceStewardingService';
import type { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel';
import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { Gavel } from 'lucide-react';
export default function RaceStewardingPage() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
// Data state
const [pageData, setPageData] = useState<RaceStewardingViewModel | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// UI State
const [activeTab, setActiveTab] = useState<StewardingTab>('pending');
// Fetch data on mount and when raceId/currentDriverId changes
useEffect(() => {
async function fetchData() {
if (!raceId) return;
try {
setIsLoading(true);
setError(null);
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
RACE_STEWARDING_SERVICE_TOKEN,
'getRaceStewardingData',
raceId,
currentDriverId
);
if (data) {
setPageData(data);
} else {
setPageData(null);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
setPageData(null);
} finally {
setIsLoading(false);
}
}
fetchData();
}, [raceId, currentDriverId]);
// Fetch membership
const { data: membershipsData } = useLeagueMemberships(pageData?.league?.id || '', currentDriverId || '');
const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId);
const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false;
// Actions
const handleBack = () => {
router.push(`/races/${raceId}`);
};
const handleReviewProtest = (protestId: string) => {
// Navigate to protest review page
router.push(`/leagues/${pageData?.league?.id}/stewarding/protests/${protestId}`);
};
// Transform data for template
const templateData = pageData ? {
race: pageData.race,
league: pageData.league,
pendingProtests: pageData.pendingProtests,
resolvedProtests: pageData.resolvedProtests,
penalties: pageData.penalties,
driverMap: pageData.driverMap,
pendingCount: pageData.pendingCount,
resolvedCount: pageData.resolvedCount,
penaltiesCount: pageData.penaltiesCount,
} : undefined;
const retry = async () => {
try {
setIsLoading(true);
setError(null);
const data = await PageDataFetcher.fetch<RaceStewardingService, 'getRaceStewardingData'>(
RACE_STEWARDING_SERVICE_TOKEN,
'getRaceStewardingData',
raceId,
currentDriverId
);
if (data) {
setPageData(data);
}
} catch (err) {
setError(err instanceof Error ? err : new Error('Failed to fetch stewarding data'));
} finally {
setIsLoading(false);
}
};
return (
<PageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={retry}
Template={({ data }) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading stewarding data...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Gavel,
title: 'No stewarding data',
description: 'No protests or penalties for this race',
action: { label: 'Back to Race', onClick: handleBack }
}}
/>
);
}

View File

@@ -1,99 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
import { useAllRacesPageData } from '@/hooks/useRaceService';
const ITEMS_PER_PAGE = 10;
export function RacesAllInteractive() {
const router = useRouter();
// Fetch data
const { data: pageData, isLoading } = useAllRacesPageData();
// Pagination
const [currentPage, setCurrentPage] = useState(1);
// Filters
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Transform data for template
const races = pageData?.races.map(race => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
})) ?? [];
// Calculate total pages
const filteredRaces = races.filter(race => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<RacesAllTemplate
races={races}
isLoading={isLoading}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
);
}

View File

@@ -1,3 +1,114 @@
import { RacesAllInteractive } from './RacesAllInteractive';
'use client';
export default RacesAllInteractive;
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RacesAllTemplate, StatusFilter } from '@/templates/RacesAllTemplate';
import { useAllRacesPageData } from '@/hooks/race/useAllRacesPageData';
import { Flag } from 'lucide-react';
const ITEMS_PER_PAGE = 10;
export default function RacesAllPage() {
const router = useRouter();
// Client-side state for filters and pagination
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Fetch data using domain hook
const { data: pageData, isLoading, error, refetch } = useAllRacesPageData();
// Transform data for template
const races = pageData?.races.map((race) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
})) ?? [];
// Calculate total pages
const filteredRaces = races.filter((race) => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<StatefulPageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={refetch}
Template={({ data }) => (
<RacesAllTemplate
races={races}
isLoading={false}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading races...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Flag,
title: 'No races found',
description: 'There are no races available at the moment',
}}
/>
);
}

View File

@@ -1,3 +1,71 @@
import { RacesInteractive } from './RacesInteractive';
import { RacesTemplate } from '@/templates/RacesTemplate';
import { RaceService } from '@/lib/services/races/RaceService';
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
export default RacesInteractive;
export default async function Page() {
// Create dependencies for API clients
const baseUrl = getWebsiteApiBaseUrl();
const logger = new ConsoleLogger();
const errorReporter = new EnhancedErrorReporter(logger, {
showUserNotifications: true,
logToConsole: true,
reportToExternal: process.env.NODE_ENV === 'production',
});
// Create API client
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new RaceService(racesApiClient);
const data = await service.getRacesPageData();
// Transform data for template
const transformRace = (race: any) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
isUpcoming: race.isUpcoming,
isLive: race.isLive,
isPast: race.isPast,
});
const races = data.races.map(transformRace);
const scheduledRaces = data.scheduledRaces.map(transformRace);
const runningRaces = data.runningRaces.map(transformRace);
const completedRaces = data.completedRaces.map(transformRace);
const totalCount = data.totalCount;
return <RacesTemplate
races={races}
totalCount={totalCount}
scheduledRaces={scheduledRaces}
runningRaces={runningRaces}
completedRaces={completedRaces}
isLoading={false}
statusFilter="all"
setStatusFilter={() => {}}
leagueFilter="all"
setLeagueFilter={() => {}}
timeFilter="upcoming"
setTimeFilter={() => {}}
onRaceClick={() => {}}
onLeagueClick={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
onCancel={() => {}}
showFilterModal={false}
setShowFilterModal={() => {}}
currentDriverId={undefined}
userMemberships={[]}
/>;
}

View File

@@ -1,546 +1,18 @@
'use client';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeagueDetailTemplate } from '@/templates/SponsorLeagueDetailTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { useState } from 'react';
import { useParams, useSearchParams } from 'next/navigation';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorLeagueDetail } from '@/hooks/sponsor/useSponsorLeagueDetail';
import {
Trophy,
Users,
Calendar,
Eye,
TrendingUp,
Download,
Image as ImageIcon,
ExternalLink,
ChevronRight,
Star,
Clock,
CheckCircle2,
Flag,
Car,
BarChart3,
ArrowUpRight,
Megaphone,
CreditCard,
FileText
} from 'lucide-react';
type TabType = 'overview' | 'drivers' | 'races' | 'sponsor';
export default function SponsorLeagueDetailPage() {
const params = useParams();
const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion();
const leagueId = params.id as string;
const showSponsorAction = searchParams.get('action') === 'sponsor';
const [activeTab, setActiveTab] = useState<TabType>(showSponsorAction ? 'sponsor' : 'overview');
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
const { data: leagueData, isLoading, error, retry } = useSponsorLeagueDetail(leagueId);
if (isLoading) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading league details...</p>
</div>
</div>
);
}
if (error || !leagueData) {
return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error?.getUserMessage() || 'No league data available'}</p>
{error && (
<Button variant="secondary" onClick={retry} className="mt-4">
Retry
</Button>
)}
</div>
</div>
);
}
const data = leagueData;
const league = data.league;
const config = league.tierConfig;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
{/* Breadcrumb */}
<div className="flex items-center gap-2 text-sm text-gray-400 mb-6">
<Link href="/sponsor/dashboard" className="hover:text-white transition-colors">Dashboard</Link>
<ChevronRight className="w-4 h-4" />
<Link href="/sponsor/leagues" className="hover:text-white transition-colors">Leagues</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{data.league.name}</span>
</div>
{/* Header */}
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 mb-8">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
{data.league.tier}
</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
Active Season
</span>
<div className="flex items-center gap-1 px-2 py-1 rounded bg-iron-gray/50">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-sm font-medium text-white">{league.rating}</span>
</div>
</div>
<h1 className="text-3xl font-bold text-white mb-2">{data.league.name}</h1>
<p className="text-gray-400 mb-4">{data.league.game} {data.league.season} {data.league.completedRaces}/{data.league.races} races completed</p>
<p className="text-gray-400 max-w-2xl">{data.league.description}</p>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<Link href={`/leagues/${league.id}`}>
<Button variant="secondary">
<ExternalLink className="w-4 h-4 mr-2" />
View League
</Button>
</Link>
{(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && (
<Button variant="primary" onClick={() => setActiveTab('sponsor')}>
<Megaphone className="w-4 h-4 mr-2" />
Become a Sponsor
</Button>
)}
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4 mb-8">
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary-blue/10">
<Eye className="w-5 h-5 text-primary-blue" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.formattedTotalImpressions}</div>
<div className="text-xs text-gray-400">Total Views</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-performance-green/10">
<TrendingUp className="w-5 h-5 text-performance-green" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.formattedAvgViewsPerRace}</div>
<div className="text-xs text-gray-400">Avg/Race</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-purple-500/10">
<Users className="w-5 h-5 text-purple-400" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.drivers}</div>
<div className="text-xs text-gray-400">Drivers</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-warning-amber/10">
<BarChart3 className="w-5 h-5 text-warning-amber" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.engagement}%</div>
<div className="text-xs text-gray-400">Engagement</div>
</div>
</div>
</Card>
</motion.div>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-racing-red/10">
<Calendar className="w-5 h-5 text-racing-red" />
</div>
<div>
<div className="text-xl font-bold text-white">{data.league.racesLeft}</div>
<div className="text-xs text-gray-400">Races Left</div>
</div>
</div>
</Card>
</motion.div>
</div>
{/* Tabs */}
<div className="flex gap-1 mb-6 border-b border-charcoal-outline overflow-x-auto">
{(['overview', 'drivers', 'races', 'sponsor'] as const).map((tab) => (
<button
key={tab}
onClick={() => setActiveTab(tab)}
className={`px-4 py-3 text-sm font-medium capitalize transition-colors border-b-2 -mb-px whitespace-nowrap ${
activeTab === tab
? 'text-primary-blue border-primary-blue'
: 'text-gray-400 border-transparent hover:text-white'
}`}
>
{tab === 'sponsor' ? '🎯 Become a Sponsor' : tab}
</button>
))}
</div>
{/* Tab Content */}
{activeTab === 'overview' && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Trophy className="w-5 h-5 text-primary-blue" />
League Information
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Platform</span>
<span className="text-white font-medium">{data.league.game}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Season</span>
<span className="text-white font-medium">{league.season}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Duration</span>
<span className="text-white font-medium">Oct 2025 - Feb 2026</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Drivers</span>
<span className="text-white font-medium">{league.drivers}</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Races</span>
<span className="text-white font-medium">{league.races}</span>
</div>
</div>
</Card>
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-performance-green" />
Sponsorship Value
</h3>
<div className="space-y-3">
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Total Season Views</span>
<span className="text-white font-medium">{data.league.formattedTotalImpressions}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Projected Total</span>
<span className="text-white font-medium">{data.league.formattedProjectedTotal}</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Main Sponsor CPM</span>
<span className="text-performance-green font-medium">
{data.league.formattedMainSponsorCpm}
</span>
</div>
<div className="flex justify-between py-2 border-b border-charcoal-outline/50">
<span className="text-gray-400">Engagement Rate</span>
<span className="text-white font-medium">{league.engagement}%</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">League Rating</span>
<div className="flex items-center gap-1">
<Star className="w-4 h-4 text-yellow-400 fill-yellow-400" />
<span className="text-white font-medium">{league.rating}/5.0</span>
</div>
</div>
</div>
</Card>
{/* Next Race */}
{league.nextRace && (
<Card className="p-5 lg:col-span-2">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Flag className="w-5 h-5 text-warning-amber" />
Next Race
</h3>
<div className="flex items-center justify-between p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-warning-amber/20 flex items-center justify-center">
<Flag className="w-6 h-6 text-warning-amber" />
</div>
<div>
<p className="font-semibold text-white text-lg">{league.nextRace.name}</p>
<p className="text-sm text-gray-400">{league.nextRace.date}</p>
</div>
</div>
<Button variant="secondary">
View Schedule
</Button>
</div>
</Card>
)}
</div>
)}
{activeTab === 'drivers' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Championship Standings</h3>
<p className="text-sm text-gray-400">Top drivers carrying sponsor branding</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.drivers.map((driver) => (
<div key={driver.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center text-lg font-bold text-white">
{driver.position}
</div>
<div>
<div className="font-medium text-white">{driver.name}</div>
<div className="text-sm text-gray-500">{driver.team} {driver.country}</div>
</div>
</div>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-medium text-white">{driver.races}</div>
<div className="text-xs text-gray-500">races</div>
</div>
<div className="text-right">
<div className="font-semibold text-white">{driver.formattedImpressions}</div>
<div className="text-xs text-gray-500">views</div>
</div>
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'races' && (
<Card>
<div className="p-4 border-b border-charcoal-outline">
<h3 className="text-lg font-semibold text-white">Race Calendar</h3>
<p className="text-sm text-gray-400">Season schedule with view statistics</p>
</div>
<div className="divide-y divide-charcoal-outline/50">
{data.races.map((race) => (
<div key={race.id} className="flex items-center justify-between p-4 hover:bg-iron-gray/30 transition-colors">
<div className="flex items-center gap-4">
<div className={`w-3 h-3 rounded-full ${
race.status === 'completed' ? 'bg-performance-green' : 'bg-warning-amber'
}`} />
<div>
<div className="font-medium text-white">{race.name}</div>
<div className="text-sm text-gray-500">{race.formattedDate}</div>
</div>
</div>
<div className="flex items-center gap-4">
{race.status === 'completed' ? (
<div className="text-right">
<div className="font-semibold text-white">{race.views.toLocaleString()}</div>
<div className="text-xs text-gray-500">views</div>
</div>
) : (
<span className="px-3 py-1 rounded-full text-xs font-medium bg-warning-amber/20 text-warning-amber">
Upcoming
</span>
)}
</div>
</div>
))}
</div>
</Card>
)}
{activeTab === 'sponsor' && (
<div className="space-y-6">
{/* Tier Selection */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Main Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'main'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${!league.sponsorSlots.main.available ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.main.available && setSelectedTier('main')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Trophy className="w-5 h-5 text-yellow-400" />
<h3 className="text-lg font-semibold text-white">Main Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Primary branding position</p>
</div>
{league.sponsorSlots.main.available ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Filled
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.main.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.main.benefits.map((benefit, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'main' && league.sponsorSlots.main.available && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
{/* Secondary Sponsor */}
<Card
className={`p-5 cursor-pointer transition-all ${
selectedTier === 'secondary'
? 'border-primary-blue ring-2 ring-primary-blue/20'
: 'hover:border-charcoal-outline/80'
} ${league.sponsorSlots.secondary.available === 0 ? 'opacity-60' : ''}`}
onClick={() => league.sponsorSlots.secondary.available > 0 && setSelectedTier('secondary')}
>
<div className="flex items-start justify-between mb-4">
<div>
<div className="flex items-center gap-2 mb-1">
<Star className="w-5 h-5 text-purple-400" />
<h3 className="text-lg font-semibold text-white">Secondary Sponsor</h3>
</div>
<p className="text-sm text-gray-400">Supporting branding position</p>
</div>
{league.sponsorSlots.secondary.available > 0 ? (
<span className="px-2 py-1 rounded text-xs font-medium bg-performance-green/20 text-performance-green">
{league.sponsorSlots.secondary.available}/{league.sponsorSlots.secondary.total} Available
</span>
) : (
<span className="px-2 py-1 rounded text-xs font-medium bg-gray-500/20 text-gray-400">
Full
</span>
)}
</div>
<div className="text-3xl font-bold text-white mb-4">
${league.sponsorSlots.secondary.price}
<span className="text-sm font-normal text-gray-500">/season</span>
</div>
<ul className="space-y-2 mb-4">
{league.sponsorSlots.secondary.benefits.map((benefit, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
))}
</ul>
{selectedTier === 'secondary' && league.sponsorSlots.secondary.available > 0 && (
<div className="w-4 h-4 rounded-full bg-primary-blue absolute top-4 right-4 flex items-center justify-center">
<CheckCircle2 className="w-3 h-3 text-white" />
</div>
)}
</Card>
</div>
{/* Checkout Summary */}
<Card className="p-5">
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<CreditCard className="w-5 h-5 text-primary-blue" />
Sponsorship Summary
</h3>
<div className="space-y-3 mb-6">
<div className="flex justify-between py-2">
<span className="text-gray-400">Selected Tier</span>
<span className="text-white font-medium capitalize">{selectedTier} Sponsor</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Season Price</span>
<span className="text-white font-medium">
${selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price}
</span>
</div>
<div className="flex justify-between py-2">
<span className="text-gray-400">Platform Fee ({siteConfig.fees.platformFeePercent}%)</span>
<span className="text-white font-medium">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * siteConfig.fees.platformFeePercent / 100).toFixed(2)}
</span>
</div>
<div className="flex justify-between py-2 border-t border-charcoal-outline pt-4">
<span className="text-white font-semibold">Total (excl. VAT)</span>
<span className="text-white font-bold text-xl">
${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)}
</span>
</div>
</div>
<p className="text-xs text-gray-500 mb-4">
{siteConfig.vat.notice}
</p>
<div className="flex gap-3">
<Button variant="primary" className="flex-1">
<Megaphone className="w-4 h-4 mr-2" />
Request Sponsorship
</Button>
<Button variant="secondary">
<FileText className="w-4 h-4 mr-2" />
Download Info Pack
</Button>
</div>
</Card>
</div>
)}
</div>
export default async function Page({ params }: { params: { id: string } }) {
const data = await PageDataFetcher.fetch<SponsorService, 'getLeagueDetail'>(
SPONSOR_SERVICE_TOKEN,
'getLeagueDetail',
params.id
);
}
if (!data) notFound();
return <PageWrapper data={data} Template={SponsorLeagueDetailTemplate} />;
}

View File

@@ -1,3 +1,38 @@
import SponsorLeaguesInteractive from './SponsorLeaguesInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { SponsorLeaguesTemplate } from '@/templates/SponsorLeaguesTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
export default SponsorLeaguesInteractive;
export default async function Page() {
const leaguesData = await PageDataFetcher.fetch<SponsorService, 'getAvailableLeagues'>(
SPONSOR_SERVICE_TOKEN,
'getAvailableLeagues'
);
// Process data with view model to calculate stats
if (!leaguesData) {
return <PageWrapper data={undefined} Template={SponsorLeaguesTemplate} />;
}
const viewModel = new AvailableLeaguesViewModel(leaguesData);
// Calculate summary stats
const stats = {
total: viewModel.leagues.length,
mainAvailable: viewModel.leagues.filter(l => l.mainSponsorSlot.available).length,
secondaryAvailable: viewModel.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0),
totalDrivers: viewModel.leagues.reduce((sum, l) => sum + l.drivers, 0),
avgCpm: Math.round(
viewModel.leagues.reduce((sum, l) => sum + l.cpm, 0) / viewModel.leagues.length
),
};
const processedData = {
leagues: viewModel.leagues,
stats,
};
return <PageWrapper data={processedData} Template={SponsorLeaguesTemplate} />;
}

View File

@@ -1,237 +0,0 @@
'use client';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Users, Search, Sparkles, Crown, Star, TrendingUp, Shield } from 'lucide-react';
import TeamsTemplate from '@/templates/TeamsTemplate';
import TeamHeroSection from '@/components/teams/TeamHeroSection';
import TeamSearchBar from '@/components/teams/TeamSearchBar';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import CreateTeamForm from '@/components/teams/CreateTeamForm';
import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
import SkillLevelSection from '@/components/teams/SkillLevelSection';
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useAllTeams } from '@/hooks/team/useAllTeams';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
export default function TeamsInteractive() {
const router = useRouter();
const { data: teams = [], isLoading: loading, error, retry } = useAllTeams();
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
// Derive groups by skill level from the loaded teams
const groupsBySkillLevel = useMemo(() => {
const byLevel: Record<string, typeof teams> = {
beginner: [],
intermediate: [],
advanced: [],
pro: [],
};
if (teams) {
teams.forEach((team) => {
const level = team.performanceLevel || 'intermediate';
if (byLevel[level]) {
byLevel[level].push(team);
}
});
}
return byLevel;
}, [teams]);
// Select top teams by rating for the preview section
const topTeams = useMemo(() => {
if (!teams) return [];
const sortedByRating = [...teams].sort((a, b) => {
// Rating is not currently part of TeamSummaryViewModel in this build.
// Keep deterministic ordering by name until a rating field is exposed.
return a.name.localeCompare(b.name);
});
return sortedByRating.slice(0, 5);
}, [teams]);
const handleTeamClick = (teamId: string) => {
if (teamId.startsWith('demo-team-')) {
return;
}
router.push(`/teams/${teamId}`);
};
const handleCreateSuccess = (teamId: string) => {
setShowCreateForm(false);
router.push(`/teams/${teamId}`);
};
// Filter by search query
const filteredTeams = teams ? teams.filter((team) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
team.name.toLowerCase().includes(query) ||
(team.description ?? '').toLowerCase().includes(query) ||
(team.region ?? '').toLowerCase().includes(query) ||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
);
}) : [];
// Group teams by skill level
const teamsByLevel = useMemo(() => {
return SKILL_LEVELS.reduce(
(acc, level) => {
const fromGroup = groupsBySkillLevel[level] ?? [];
acc[level] = filteredTeams.filter((team) =>
fromGroup.some((groupTeam) => groupTeam.id === team.id),
);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
} as Record<string, TeamSummaryViewModel[]>,
);
}, [groupsBySkillLevel, filteredTeams]);
const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
const handleSkillLevelClick = (level: SkillLevel) => {
const element = document.getElementById(`level-${level}`);
element?.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
const handleBrowseTeams = () => {
const element = document.getElementById('teams-list');
element?.scrollIntoView({ behavior: 'smooth' });
};
if (showCreateForm) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="mb-6">
<Button variant="secondary" onClick={() => setShowCreateForm(false)}>
Back to Teams
</Button>
</div>
<Card>
<h2 className="text-2xl font-bold text-white mb-6">Create New Team</h2>
<CreateTeamForm onCancel={() => setShowCreateForm(false)} onSuccess={handleCreateSuccess} />
</Card>
</div>
);
}
return (
<StateContainer
data={teams}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading teams...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No teams yet',
description: 'Be the first to create a racing team. Gather drivers and compete together in endurance events.',
action: { label: 'Create Your First Team', onClick: () => setShowCreateForm(true) }
}
}}
>
{(teamsData) => (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teamsData}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
{/* Teams by Skill Level */}
{teamsData.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
</div>
)}
</div>
)}
</StateContainer>
);
}

View File

@@ -1,59 +0,0 @@
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamsTemplate from '@/templates/TeamsTemplate';
// This is a static component that receives data as props
// It can be used in server components or parent components that fetch data
// For client-side data fetching, use TeamsInteractive instead
interface TeamsStaticProps {
teams: TeamSummaryViewModel[];
isLoading?: boolean;
}
export default function TeamsStatic({ teams, isLoading = false }: TeamsStaticProps) {
// Calculate derived data that would normally be done in the template
const teamsBySkillLevel = teams.reduce(
(acc, team) => {
const level = team.performanceLevel || 'intermediate';
if (!acc[level]) {
acc[level] = [];
}
acc[level].push(team);
return acc;
},
{
beginner: [],
intermediate: [],
advanced: [],
pro: [],
} as Record<string, TeamSummaryViewModel[]>,
);
const topTeams = [...teams]
.sort((a, b) => a.name.localeCompare(b.name))
.slice(0, 5);
const recruitingCount = teams.filter((t) => t.isRecruiting).length;
// For static rendering, we don't have interactive state
// So we pass empty values and handlers that won't be used
return (
<TeamsTemplate
teams={teams}
isLoading={isLoading}
searchQuery=""
showCreateForm={false}
teamsByLevel={teamsBySkillLevel}
topTeams={topTeams}
recruitingCount={recruitingCount}
filteredTeams={teams}
onSearchChange={() => {}}
onShowCreateForm={() => {}}
onHideCreateForm={() => {}}
onTeamClick={() => {}}
onCreateSuccess={() => {}}
onBrowseTeams={() => {}}
onSkillLevelClick={() => {}}
/>
);
}

View File

@@ -1,139 +0,0 @@
'use client';
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { useTeamDetails } from '@/hooks/team/useTeamDetails';
import { useTeamMembers } from '@/hooks/team/useTeamMembers';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Users } from 'lucide-react';
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailInteractive() {
const params = useParams();
const teamId = params.id as string;
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
const teamService = useInject(TEAM_SERVICE_TOKEN);
const [activeTab, setActiveTab] = useState<Tab>('overview');
// Fetch team details using DI + React-Query
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useTeamDetails(teamId, currentDriverId);
// Fetch team members using DI + React-Query
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useTeamMembers(
teamId,
currentDriverId,
teamDetails?.ownerId || ''
);
const isLoading = teamLoading || membersLoading;
const error = teamError || membersError;
const retry = async () => {
await teamRetry();
await membersRetry();
};
// Determine admin status
const isAdmin = teamDetails?.isOwner ||
(memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
const handleUpdate = () => {
retry();
};
const handleRemoveMember = async (driverId: string) => {
if (!confirm('Are you sure you want to remove this member?')) {
return;
}
try {
const performer = await teamService.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or admins can remove members');
}
const membership = await teamService.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot remove the team owner');
}
await teamService.removeMembership(teamId, driverId);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to remove member');
}
};
const handleChangeRole = async (driverId: string, newRole: 'owner' | 'admin' | 'member') => {
try {
const performer = await teamService.getMembership(teamId, currentDriverId);
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
throw new Error('Only owners or admins can update roles');
}
const membership = await teamService.getMembership(teamId, driverId);
if (!membership) {
throw new Error('Member not found');
}
if (membership.role === 'owner') {
throw new Error('Cannot change the owner role');
}
// Convert 'admin' to 'manager' for the service
const serviceRole = newRole === 'admin' ? 'manager' : newRole;
await teamService.updateMembership(teamId, driverId, serviceRole);
handleUpdate();
} catch (error) {
alert(error instanceof Error ? error.message : 'Failed to change role');
}
};
const handleGoBack = () => {
window.history.back();
};
return (
<StateContainer
data={teamDetails}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading team details...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'Team not found',
description: 'The team may have been deleted or you may not have access',
action: { label: 'Back to Teams', onClick: () => router.push('/teams') }
}
}}
>
{(teamData) => (
<TeamDetailTemplate
team={teamData!}
memberships={memberships || []}
activeTab={activeTab}
loading={isLoading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
)}
</StateContainer>
);
}

View File

@@ -1,43 +0,0 @@
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
// This is a server component that can be used for static rendering
// It receives pre-fetched data and renders the template
interface TeamDetailStaticProps {
team: TeamDetailsViewModel | null;
memberships: TeamMemberViewModel[];
currentDriverId: string;
isLoading?: boolean;
}
export default function TeamDetailStatic({
team,
memberships,
currentDriverId,
isLoading = false
}: TeamDetailStaticProps) {
// Determine admin status
const isAdmin = team ? (
team.isOwner ||
memberships.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'))
) : false;
// For static rendering, we don't have interactive state
// So we pass empty values and handlers that won't be used
return (
<TeamDetailTemplate
team={team}
memberships={memberships}
activeTab="overview"
loading={isLoading}
isAdmin={isAdmin}
onTabChange={() => {}}
onUpdate={() => {}}
onRemoveMember={() => {}}
onChangeRole={() => {}}
onGoBack={() => {}}
/>
);
}

View File

@@ -1,3 +1,102 @@
import TeamDetailInteractive from './TeamDetailInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TeamService } from '@/lib/services/teams/TeamService';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
export default TeamDetailInteractive;
// Template wrapper to adapt TeamDetailTemplate for SSR
interface TeamDetailData {
team: TeamDetailsViewModel;
memberships: TeamMemberViewModel[];
isAdmin: boolean;
}
function TeamDetailTemplateWrapper({ data }: { data: TeamDetailData }) {
return (
<TeamDetailTemplate
team={data.team}
memberships={data.memberships}
activeTab="overview"
loading={false}
isAdmin={data.isAdmin}
// Event handlers are no-ops for SSR (client will handle real interactions)
onTabChange={() => {}}
onUpdate={() => {}}
onRemoveMember={() => {}}
onChangeRole={() => {}}
onGoBack={() => {}}
/>
);
}
export default async function Page({ params }: { params: { id: string } }) {
// Validate params
if (!params.id) {
notFound();
}
// Fetch data using PageDataFetcher.fetchManual
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',
});
// Create API client
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new TeamService(teamsApiClient);
// For server-side, we need a current driver ID
// This would typically come from session, but for server components we'll use a placeholder
const currentDriverId = ''; // Placeholder - would need session handling
// Fetch team details
const teamData = await service.getTeamDetails(params.id, currentDriverId);
if (!teamData) {
return null;
}
// Fetch team members
const membersData = await service.getTeamMembers(params.id, currentDriverId, teamData.ownerId || '');
// Determine admin status
const isAdmin = teamData.isOwner ||
(membersData || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
return {
team: teamData,
memberships: membersData || [],
isAdmin,
};
});
if (!data) {
notFound();
}
return (
<PageWrapper
data={data}
Template={TeamDetailTemplateWrapper}
loading={{ variant: 'skeleton', message: 'Loading team details...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
title: 'Team not found',
description: 'The team you are looking for does not exist or has been removed.',
}}
/>
);
}

View File

@@ -1,45 +0,0 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
interface TeamLeaderboardInteractiveProps {
teams: TeamSummaryViewModel[];
}
export default function TeamLeaderboardInteractive({ teams }: TeamLeaderboardInteractiveProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [filterLevel, setFilterLevel] = useState<SkillLevel | 'all'>('all');
const [sortBy, setSortBy] = useState<SortBy>('rating');
const handleTeamClick = (teamId: string) => {
if (teamId.startsWith('demo-team-')) {
return;
}
router.push(`/teams/${teamId}`);
};
const handleBackToTeams = () => {
router.push('/teams');
};
return (
<TeamLeaderboardTemplate
teams={teams}
searchQuery={searchQuery}
filterLevel={filterLevel}
sortBy={sortBy}
onSearchChange={setSearchQuery}
onFilterLevelChange={setFilterLevel}
onSortChange={setSortBy}
onTeamClick={handleTeamClick}
onBackToTeams={handleBackToTeams}
/>
);
}

View File

@@ -1,27 +0,0 @@
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import TeamLeaderboardInteractive from './TeamLeaderboardInteractive';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
// ============================================================================
export default async function TeamLeaderboardStatic() {
// Create services for server-side data fetching
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
const teamService = serviceFactory.createTeamService();
// Fetch data server-side
let teams: TeamSummaryViewModel[] = [];
try {
teams = await teamService.getAllTeams();
} catch (error) {
console.error('Failed to load team leaderboard:', error);
teams = [];
}
// Pass data to Interactive wrapper which handles client-side interactions
return <TeamLeaderboardInteractive teams={teams} />;
}

View File

@@ -1,9 +1,97 @@
import TeamLeaderboardStatic from './TeamLeaderboardStatic';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import TeamLeaderboardTemplate from '@/templates/TeamLeaderboardTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { TeamService } from '@/lib/services/teams/TeamService';
import { Trophy } from 'lucide-react';
import { redirect , useRouter } from 'next/navigation';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { useState } from 'react';
// ============================================================================
// 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
// ============================================================================
export default function TeamLeaderboardPage() {
return <TeamLeaderboardStatic />;
export default async function TeamLeaderboardPage() {
// Fetch data using PageDataFetcher
const teamsData = await PageDataFetcher.fetch<TeamService, 'getAllTeams'>(
TEAM_SERVICE_TOKEN,
'getAllTeams'
);
// Prepare data for template
const data: TeamSummaryViewModel[] | null = teamsData as TeamSummaryViewModel[] | null;
const hasData = (teamsData as any)?.length > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const error = null;
const retry = async () => {
// In server components, we can't retry without a reload
redirect('/teams/leaderboard');
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={TeamLeaderboardPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading team leaderboard...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No teams found',
description: 'There are no teams in the system yet.',
}}
/>
);
}

View File

@@ -1,3 +1,97 @@
import TeamsInteractive from './TeamsInteractive';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import TeamsTemplate from '@/templates/TeamsTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { TeamService } from '@/lib/services/teams/TeamService';
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { notFound } from 'next/navigation';
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
export default TeamsInteractive;
// Helper to compute derived data for SSR
function computeDerivedData(teams: TeamSummaryViewModel[]) {
// Group teams by performance level (skill level)
const teamsByLevel = teams.reduce((acc, team) => {
const level = team.performanceLevel || 'intermediate';
if (!acc[level]) {
acc[level] = [];
}
acc[level].push(team);
return acc;
}, {} as Record<string, TeamSummaryViewModel[]>);
// Get top teams (by rating, descending)
const topTeams = [...teams]
.filter(t => t.rating !== undefined)
.sort((a, b) => (b.rating || 0) - (a.rating || 0))
.slice(0, 5);
// Count recruiting teams
const recruitingCount = teams.filter(t => t.isRecruiting).length;
// For SSR, filtered teams = all teams (no search filter applied server-side)
const filteredTeams = teams;
return {
teamsByLevel,
topTeams,
recruitingCount,
filteredTeams,
};
}
// Template wrapper for SSR
function TeamsTemplateWrapper({ data }: { data: TeamSummaryViewModel[] }) {
const derived = computeDerivedData(data);
// Provide default values for SSR
// The template will handle client-side state management
return (
<TeamsTemplate
teams={data}
isLoading={false}
searchQuery=""
showCreateForm={false}
teamsByLevel={derived.teamsByLevel}
topTeams={derived.topTeams}
recruitingCount={derived.recruitingCount}
filteredTeams={derived.filteredTeams}
// No-op handlers for SSR (client will override)
onSearchChange={() => {}}
onShowCreateForm={() => {}}
onHideCreateForm={() => {}}
onTeamClick={() => {}}
onCreateSuccess={() => {}}
onBrowseTeams={() => {}}
onSkillLevelClick={() => {}}
/>
);
}
export default async function Page() {
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',
});
// Create API client
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
// Create service
const service = new TeamService(teamsApiClient);
return await service.getAllTeams();
});
if (!data) {
notFound();
}
return <PageWrapper data={data} Template={TeamsTemplateWrapper} />;
}

View File

@@ -38,6 +38,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const confirmed = window.confirm(`Register for ${race.track ?? race.name}?`);
if (!confirmed) return;
if (!currentDriverId) return;
try {
await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
@@ -52,6 +53,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const confirmed = window.confirm('Withdraw from this race?');
if (!confirmed) return;
if (!currentDriverId) return;
try {
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
@@ -265,4 +267,4 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
}}
</StateContainer>
);
}
}

View File

@@ -11,6 +11,15 @@ interface MembershipStatusProps {
export default function MembershipStatus({ leagueId, className = '' }: MembershipStatusProps) {
const currentDriverId = useEffectiveDriverId();
if (!currentDriverId) {
return (
<span className={`px-3 py-1 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50 ${className}`}>
Not a Member
</span>
);
}
const membership = getMembership(leagueId, currentDriverId);
if (!membership) {

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import { EmptyStateProps } from './types';
import { EmptyStateProps, EmptyStateAction } from './types';
import Button from '@/components/ui/Button';
// Illustration components (simple SVG representations)
@@ -81,11 +81,11 @@ export function EmptyState({
<div className="text-gray-500">
<IllustrationComponent />
</div>
) : (
) : Icon ? (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-8 h-8 text-gray-500" />
</div>
)}
) : null}
</div>
{/* Title */}
@@ -144,9 +144,11 @@ export function EmptyState({
>
<div className="max-w-sm mx-auto space-y-3">
{/* Minimal icon */}
<div className="flex justify-center">
<Icon className="w-10 h-10 text-gray-600" />
</div>
{Icon && (
<div className="flex justify-center">
<Icon className="w-10 h-10 text-gray-600" />
</div>
)}
<h3 className="text-lg font-medium text-gray-300">
{title}
</h3>
@@ -182,13 +184,13 @@ export function EmptyState({
<div className="text-gray-500 flex justify-center">
<IllustrationComponent />
</div>
) : (
) : Icon ? (
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-10 h-10 text-gray-500" />
</div>
</div>
)}
) : null}
</div>
<h2 className="text-3xl font-bold text-white mb-4">
@@ -289,10 +291,12 @@ export function FullPageEmptyState({ icon, title, description, action, className
* Pre-configured empty states for common scenarios
*/
import { Activity, Search, Lock } from 'lucide-react';
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Activity}
icon={Activity}
title="No data available"
description="There is nothing to display here at the moment"
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
@@ -304,7 +308,7 @@ export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Search}
icon={Search}
title="No results found"
description="Try adjusting your search or filters"
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
@@ -316,7 +320,7 @@ export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Lock}
icon={Lock}
title="Access denied"
description="You don't have permission to view this content"
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}

View File

@@ -1,196 +1,183 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
import React from 'react';
import { AlertCircle, RefreshCw, ArrowLeft, Home, Bug } from 'lucide-react';
import { ErrorDisplayProps } from './types';
import Button from '@/components/ui/Button';
import { ApiError } from '@/lib/api/base/ApiError';
/**
* ErrorDisplay Component
*
* Provides standardized error display with 4 variants:
* - full-screen: Full page error with navigation
* - inline: Compact inline error message
* - card: Error card for grid layouts
* - toast: Toast notification style
* Provides consistent error state handling with multiple variants:
* - full-screen: Centered error page with full viewport
* - card: Compact card-style error display
* - inline: Small inline error message
*
* Features:
* - Auto-detects retryable errors
* - Shows user-friendly messages
* - Provides navigation options
* - Displays technical details in development
* - Fully accessible with ARIA labels and keyboard navigation
* - Automatic error message extraction
* - Retry functionality
* - Navigation options (back, home)
* - Technical details toggle
* - API error specific handling
*/
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
showRetry: showRetryProp,
showNavigation = true,
actions = [],
className = '',
showRetry = true,
showNavigation = true,
hideTechnicalDetails = false,
ariaLabel = 'Error notification',
className = '',
}: ErrorDisplayProps) {
const router = useRouter();
const [isRetrying, setIsRetrying] = useState(false);
// Auto-detect retry capability
const isRetryable = showRetryProp ?? error.isRetryable();
const isConnectivity = error.isConnectivityIssue();
const userMessage = error.getUserMessage();
const isDev = process.env.NODE_ENV === 'development' && !hideTechnicalDetails;
const handleRetry = async () => {
if (onRetry) {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
}
// Extract error information
const getErrorInfo = () => {
const isApiError = error instanceof ApiError;
return {
title: isApiError ? 'API Error' : 'Unexpected Error',
message: error.message || 'Something went wrong',
statusCode: isApiError ? error.context.statusCode : undefined,
details: isApiError ? error.context.responseText : undefined,
isApiError,
};
};
const handleGoBack = () => {
router.back();
};
const errorInfo = getErrorInfo();
const handleGoHome = () => {
router.push('/');
};
const handleClose = () => {
// For toast variant, this would typically close the notification
// Implementation depends on how toast notifications are managed
window.history.back();
};
// Icon based on error type
const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
// Default actions
const defaultActions = [
...(showRetry && onRetry ? [{ label: 'Retry', onClick: onRetry, variant: 'primary' as const }] : []),
...(showNavigation ? [
{ label: 'Go Back', onClick: () => window.history.back(), variant: 'secondary' as const },
{ label: 'Home', onClick: () => window.location.href = '/', variant: 'outline' as const },
] : []),
...actions,
];
// Render different variants
switch (variant) {
case 'full-screen':
return (
<div
className="min-h-screen bg-deep-graphite flex items-center justify-center p-4"
className={`fixed inset-0 z-50 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-500/20 rounded-lg">
<ErrorIcon className="w-6 h-6 text-red-400" />
</div>
<div>
<h1 className="text-xl font-bold text-white">
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
</h1>
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
</div>
<div className="max-w-lg w-full text-center">
{/* Icon */}
<div className="flex justify-center mb-6">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-red-500/10 border border-red-500/30">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
{/* Technical Details (Development Only) */}
{isDev && (
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
<div className="mt-2 space-y-1">
<div>Type: {error.type}</div>
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
{error.context.retryCount !== undefined && (
<div>Retries: {error.context.retryCount}</div>
)}
{error.context.wasRetry && <div>Was Retry: true</div>}
</div>
</details>
{/* Title */}
<h2 className="text-3xl font-bold text-white mb-3">
{errorInfo.title}
</h2>
{/* Message */}
<p className="text-gray-400 text-lg mb-6 leading-relaxed">
{errorInfo.message}
</p>
{/* API Error Details */}
{errorInfo.isApiError && errorInfo.statusCode && (
<div className="mb-6 inline-flex items-center gap-2 px-4 py-2 bg-iron-gray/40 rounded-lg text-sm text-gray-300">
<span className="font-mono">HTTP {errorInfo.statusCode}</span>
{errorInfo.details && !hideTechnicalDetails && (
<span className="text-gray-500">- {errorInfo.details}</span>
)}
</div>
)}
{/* Actions */}
{defaultActions.length > 0 && (
<div className="flex flex-col sm:flex-row gap-3 justify-center">
{defaultActions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
className={`
px-6 py-3 rounded-lg font-medium transition-all
${action.variant === 'primary' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
${action.variant === 'secondary' ? 'bg-iron-gray hover:bg-iron-gray/80 text-white' : ''}
${action.variant === 'outline' ? 'border border-gray-600 hover:border-gray-500 text-gray-300' : ''}
${action.variant === 'ghost' ? 'text-gray-400 hover:text-gray-300' : ''}
`}
>
{action.label}
</button>
))}
</div>
)}
{/* Technical Details Toggle (for development) */}
{!hideTechnicalDetails && process.env.NODE_ENV === 'development' && error.stack && (
<details className="mt-8 text-left">
<summary className="cursor-pointer text-sm text-gray-500 hover:text-gray-400">
Technical Details
</summary>
<pre className="mt-2 p-4 bg-black/50 rounded-lg text-xs text-gray-400 overflow-x-auto">
{error.stack}
</pre>
</details>
)}
</div>
</div>
);
case 'card':
return (
<div
className={`bg-iron-gray/40 border border-red-500/30 rounded-xl p-6 ${className}`}
role="alert"
aria-live="assertive"
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="flex-shrink-0">
<AlertCircle className="w-6 h-6 text-red-500" />
</div>
{/* Content */}
<div className="flex-1">
<h3 className="font-semibold text-white mb-1">
{errorInfo.title}
</h3>
<p className="text-sm text-gray-400 mb-3">
{errorInfo.message}
</p>
{/* API Error Details */}
{errorInfo.isApiError && errorInfo.statusCode && (
<div className="text-xs font-mono text-gray-500 mb-3">
HTTP {errorInfo.statusCode}
{errorInfo.details && !hideTechnicalDetails && ` - ${errorInfo.details}`}
</div>
)}
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{isRetryable && onRetry && (
<Button
variant="danger"
onClick={handleRetry}
disabled={isRetrying}
className="w-full"
>
{isRetrying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Try Again
</>
)}
</Button>
)}
{showNavigation && (
<div className="flex gap-2">
<Button
variant="secondary"
onClick={handleGoBack}
className="flex-1"
{/* Actions */}
{defaultActions.length > 0 && (
<div className="flex gap-2">
{defaultActions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
className={`
px-3 py-1.5 rounded text-sm font-medium
${action.variant === 'primary' ? 'bg-red-500 hover:bg-red-600 text-white' : ''}
${action.variant === 'secondary' ? 'bg-deep-graphite hover:bg-black/60 text-white' : ''}
${action.variant === 'outline' ? 'border border-gray-600 hover:border-gray-500 text-gray-300' : ''}
${action.variant === 'ghost' ? 'text-gray-400 hover:text-gray-300' : ''}
`}
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
variant="secondary"
onClick={handleGoHome}
className="flex-1"
>
<Home className="w-4 h-4" />
Home
</Button>
</div>
)}
{/* Custom Actions */}
{actions.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
{actions.map((action, index) => (
<Button
key={index}
variant={action.variant || 'secondary'}
onClick={action.onClick}
disabled={action.disabled}
className="w-full"
>
{action.icon && <action.icon className="w-4 h-4" />}
{action.label}
</Button>
))}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
If this persists, please contact support at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
aria-label="Email support"
>
support@gridpilot.com
</a>
{action.label}
</button>
))}
</div>
)}
</div>
</div>
</div>
@@ -199,145 +186,20 @@ export function ErrorDisplay({
case 'inline':
return (
<div
className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className}`}
className={`inline-flex items-center gap-2 px-3 py-2 bg-red-500/10 border border-red-500/30 rounded-lg ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="flex items-start gap-3">
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-red-200 font-medium">{userMessage}</p>
{isDev && (
<p className="text-xs text-red-300/70 mt-1 font-mono">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-3">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0" />
<span className="text-sm text-red-400">{errorInfo.message}</span>
{onRetry && showRetry && (
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-200 p-1 rounded hover:bg-iron-gray/50"
aria-label="Dismiss error"
onClick={onRetry}
className="ml-2 text-xs text-red-300 hover:text-red-200 underline"
>
<X className="w-4 h-4" />
Retry
</button>
</div>
</div>
);
case 'card':
return (
<div
className={`bg-iron-gray border border-red-500/30 rounded-xl overflow-hidden ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="bg-red-500/10 border-b border-red-500/20 p-4">
<div className="flex items-center gap-2">
<ErrorIcon className="w-5 h-5 text-red-400" />
<h3 className="text-white font-semibold">Error</h3>
</div>
</div>
<div className="p-4 space-y-3">
<p className="text-gray-300 text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 font-mono">
{error.type} | Status: {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex flex-wrap gap-2 pt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50 flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
{isRetrying ? 'Retrying' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
</div>
);
case 'toast':
return (
<div
className={`bg-iron-gray border-l-4 border-red-500 rounded-r-lg shadow-lg p-4 flex items-start gap-3 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 mt-1">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-2.5 py-1 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? '...' : 'Retry'}
</button>
)}
{actions.slice(0, 2).map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-2.5 py-1 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-white p-1 rounded hover:bg-iron-gray/50 flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
)}
</div>
);
@@ -347,57 +209,44 @@ export function ErrorDisplay({
}
/**
* Convenience component for full-screen error display
* Convenience component for API error display
*/
export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
export function ApiErrorDisplay({
error,
onRetry,
variant = 'full-screen',
hideTechnicalDetails = false,
}: {
error: ApiError;
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
hideTechnicalDetails?: boolean;
}) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="full-screen"
{...props}
variant={variant}
hideTechnicalDetails={hideTechnicalDetails}
/>
);
}
/**
* Convenience component for inline error display
* Convenience component for network error display
*/
export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
export function NetworkErrorDisplay({
onRetry,
variant = 'full-screen',
}: {
onRetry?: () => void;
variant?: 'full-screen' | 'card' | 'inline';
}) {
return (
<ErrorDisplay
error={error}
error={new Error('Network connection failed. Please check your internet connection.')}
onRetry={onRetry}
variant="inline"
{...props}
/>
);
}
/**
* Convenience component for card error display
*/
export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="card"
{...props}
/>
);
}
/**
* Convenience component for toast error display
*/
export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="toast"
{...props}
variant={variant}
/>
);
}

View File

@@ -0,0 +1,276 @@
import React, { ReactNode } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
import { Inbox, List, LucideIcon } from 'lucide-react';
// ==================== PAGEWRAPPER TYPES ====================
export interface PageWrapperLoadingConfig {
variant?: 'skeleton' | 'full-screen';
message?: string;
}
export interface PageWrapperErrorConfig {
variant?: 'full-screen' | 'card';
card?: {
title?: string;
description?: string;
};
}
export interface PageWrapperEmptyConfig {
icon?: LucideIcon;
title?: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export interface PageWrapperProps<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?: ReactNode;
/** Additional CSS classes */
className?: string;
}
/**
* PageWrapper Component
*
* A comprehensive wrapper component that handles all page states:
* - Loading states (skeleton or full-screen)
* - Error states (full-screen or card)
* - Empty states (with icon, title, description, and action)
* - Success state (renders Template component with data)
* - Flexible children support for custom content
*
* Usage Example:
* ```typescript
* <PageWrapper
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* Template={MyTemplateComponent}
* loading={{ variant: 'skeleton', message: 'Loading...' }}
* error={{ variant: 'full-screen' }}
* empty={{
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }}
* >
* <AdditionalContent />
* </PageWrapper>
* ```
*/
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"
/>
{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 || 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>
);
}
/**
* Convenience component for list data with automatic empty state handling
*/
export function ListPageWrapper<TData extends any[]>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
}: PageWrapperProps<TData>) {
const listEmpty = empty || {
icon: List,
title: 'No items found',
description: 'This list is currently empty',
};
return (
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
Template={Template}
loading={loading}
errorConfig={errorConfig}
empty={listEmpty}
className={className}
>
{children}
</PageWrapper>
);
}
/**
* Convenience component for detail pages with enhanced error handling
*/
export function DetailPageWrapper<TData>({
data,
isLoading = false,
error = null,
retry,
Template,
loading,
errorConfig,
empty,
children,
className = '',
onBack,
onRefresh,
}: PageWrapperProps<TData> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
// Create enhanced error config with additional actions
const enhancedErrorConfig: PageWrapperErrorConfig = {
...errorConfig,
};
return (
<PageWrapper
data={data}
isLoading={isLoading}
error={error}
retry={retry}
Template={Template}
loading={loading}
errorConfig={enhancedErrorConfig}
empty={empty}
className={className}
>
{children}
</PageWrapper>
);
}

View File

@@ -5,6 +5,7 @@ import { StateContainerProps, StateContainerConfig } from './types';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
import { Inbox, AlertCircle, Grid, List } from 'lucide-react';
/**
* StateContainer Component
@@ -121,7 +122,7 @@ export function StateContainer<T>({
return (
<div className={className}>
<EmptyState
icon={require('lucide-react').Inbox}
icon={Inbox}
title="No data available"
description="There is nothing to display here"
/>
@@ -133,7 +134,7 @@ export function StateContainer<T>({
<div className={className}>
<EmptyState
icon={emptyConfig.icon}
title={emptyConfig.title}
title={emptyConfig.title || 'No data available'}
description={emptyConfig.description}
action={emptyConfig.action}
variant="default"
@@ -148,7 +149,7 @@ export function StateContainer<T>({
return (
<div className={className}>
<EmptyState
icon={require('lucide-react').AlertCircle}
icon={AlertCircle}
title="Unexpected state"
description="No data available but no error or loading state"
/>
@@ -187,7 +188,7 @@ export function ListStateContainer<T>({
const listConfig: StateContainerConfig<T[]> = {
...config,
empty: emptyConfig || {
icon: require('lucide-react').List,
icon: List,
title: 'No items found',
description: 'This list is currently empty',
},
@@ -367,7 +368,7 @@ export function GridStateContainer<T>({
},
...config,
empty: emptyConfig || {
icon: require('lucide-react').Grid,
icon: Grid,
title: 'No items to display',
description: 'Try adjusting your filters or search',
},

View File

@@ -0,0 +1,61 @@
'use client';
import React from 'react';
import { PageWrapper, PageWrapperProps } from './PageWrapper';
/**
* Stateful Page Wrapper - CLIENT SIDE ONLY
* Adds loading/error state management for client-side fetching
*
* Usage:
* ```typescript
* 'use client';
*
* export default function ProfilePage() {
* const { data, isLoading, error, refetch } = usePageData(...);
*
* return (
* <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}
className={className}
>
{children}
</PageWrapper>
);
}
// Re-export types for convenience
export type { PageWrapperProps, PageWrapperLoadingConfig, PageWrapperErrorConfig, PageWrapperEmptyConfig } from './PageWrapper';

View File

@@ -1,7 +1,7 @@
'use client';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
// ==================== EMPTY STATE TYPES ====================
@@ -9,12 +9,12 @@ import { ApiError } from '@/lib/api/base/ApiError';
export interface EmptyStateAction {
label: string;
onClick: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
icon?: LucideIcon;
}
export interface EmptyStateProps {
icon: LucideIcon;
icon?: LucideIcon;
title: string;
description?: string;
action?: EmptyStateAction;
@@ -24,9 +24,9 @@ export interface EmptyStateProps {
ariaLabel?: string;
}
// ==================== LOADING STATE TYPES ====================
// ==================== LOADING WRAPPER TYPES ====================
export interface LoadingCardConfig {
export interface LoadingWrapperCardConfig {
count?: number;
height?: string;
className?: string;
@@ -38,67 +38,58 @@ export interface LoadingWrapperProps {
className?: string;
size?: 'sm' | 'md' | 'lg';
skeletonCount?: number;
cardConfig?: LoadingCardConfig;
cardConfig?: LoadingWrapperCardConfig;
ariaLabel?: string;
}
// ==================== ERROR STATE TYPES ====================
// ==================== ERROR DISPLAY TYPES ====================
export interface ErrorAction {
export interface ErrorDisplayAction {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final';
icon?: LucideIcon;
disabled?: boolean;
variant: 'primary' | 'secondary' | 'outline' | 'ghost';
}
export interface ErrorDisplayProps {
error: ApiError;
error: ApiError | Error;
onRetry?: () => void;
variant?: 'full-screen' | 'inline' | 'card' | 'toast';
variant?: 'full-screen' | 'card' | 'inline';
actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
actions?: ErrorAction[];
className?: string;
hideTechnicalDetails?: boolean;
ariaLabel?: string;
className?: string;
}
// ==================== STATE CONTAINER TYPES ====================
export interface StateContainerConfig<T> {
loading?: {
variant?: LoadingWrapperProps['variant'];
message?: string;
size?: LoadingWrapperProps['size'];
skeletonCount?: number;
};
loading?: Pick<LoadingWrapperProps, 'variant' | 'message' | 'size' | 'skeletonCount'>;
error?: {
variant?: ErrorDisplayProps['variant'];
variant?: 'full-screen' | 'card' | 'inline';
actions?: ErrorDisplayAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
actions?: ErrorAction[];
};
empty?: {
icon: LucideIcon;
title: string;
icon?: LucideIcon;
title?: string;
description?: string;
action?: EmptyStateAction;
illustration?: EmptyStateProps['illustration'];
};
customRender?: {
loading?: () => ReactNode;
error?: (error: ApiError) => ReactNode;
error?: (error: Error) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps<T> {
data: T | null | undefined;
isLoading: boolean;
error: ApiError | null;
retry: () => void;
data: T | undefined;
isLoading?: boolean;
error?: Error | null;
retry?: () => void;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
className?: string;
@@ -106,11 +97,40 @@ export interface StateContainerProps<T> {
isEmpty?: (data: T) => boolean;
}
// ==================== CONVENIENCE PROP TYPES ====================
// ==================== PAGE WRAPPER TYPES ====================
// For components that only need specific subsets of props
export type MinimalEmptyStateProps = Omit<EmptyStateProps, 'variant'>;
export type MinimalLoadingProps = Pick<LoadingWrapperProps, 'message' | 'className'>;
export type InlineLoadingProps = Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>;
export type SkeletonLoadingProps = Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>;
export type CardLoadingProps = Pick<LoadingWrapperProps, 'cardConfig' | 'className'>;
export interface PageWrapperLoadingConfig {
variant?: 'skeleton' | 'full-screen';
message?: string;
}
export interface PageWrapperErrorConfig {
variant?: 'full-screen' | 'card';
card?: {
title?: string;
description?: string;
};
}
export interface PageWrapperEmptyConfig {
icon?: LucideIcon;
title?: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
}
export interface PageWrapperProps<TData> {
data: TData | undefined;
isLoading?: boolean;
error?: Error | null;
retry?: () => void;
Template: React.ComponentType<{ data: TData }>;
loading?: PageWrapperLoadingConfig;
errorConfig?: PageWrapperErrorConfig;
empty?: PageWrapperEmptyConfig;
children?: ReactNode;
className?: string;
}

View File

@@ -1,20 +0,0 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DASHBOARD_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
export function useDashboardOverview(
options?: Omit<UseQueryOptions<DashboardOverviewViewModel, ApiError>, 'queryKey' | 'queryFn'>
) {
const dashboardService = useInject(DASHBOARD_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['dashboardOverview'],
queryFn: () => dashboardService.getDashboardOverview(),
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -1,5 +1,5 @@
export { useCurrentDriver } from './useCurrentDriver';
export { useDriverLeaderboard } from './useDriverLeaderboard';
export { useDriverLeaderboard } from '@/lib/hooks/useDriverLeaderboard';
export { useDriverProfile } from './useDriverProfile';
export { useUpdateDriverProfile } from './useUpdateDriverProfile';
export { useCreateDriver } from './useCreateDriver';

View File

@@ -1,15 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useDriverLeaderboard() {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['driverLeaderboard'],
queryFn: () => driverService.getDriverLeaderboard(),
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,47 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN, TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useDriverProfilePageData(driverId: string) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const teamService = useInject(TEAM_SERVICE_TOKEN);
return usePageDataMultiple({
driverProfile: {
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
enabled: !!driverId,
},
teamMemberships: {
queryKey: ['teamMemberships', driverId],
queryFn: async () => {
if (!driverId) return [];
const allTeams = await teamService.getAllTeams();
let teamMemberships: Array<{
team: { id: string; name: string };
role: string;
joinedAt: Date;
}> = [];
for (const team of allTeams) {
const teamMembers = await teamService.getTeamMembers(team.id, driverId, '');
const membership = teamMembers?.find(member => member.driverId === driverId);
if (membership) {
teamMemberships.push({
team: {
id: team.id,
name: team.name,
},
role: membership.role,
joinedAt: new Date(membership.joinedAt),
});
}
}
return teamMemberships;
},
enabled: !!driverId,
},
});
}

View File

@@ -1,15 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useAllLeaguesWithSponsors() {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['allLeaguesWithSponsors'],
queryFn: () => leagueService.getAllLeagues(),
});
return enhanceQueryResult(queryResult);
}

View File

@@ -1,21 +0,0 @@
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
export function useLeagueDetailWithSponsors(
leagueId: string,
options?: Omit<UseQueryOptions<LeagueDetailPageViewModel | null, ApiError>, 'queryKey' | 'queryFn'>
) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueDetailWithSponsors', leagueId],
queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
...options,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,38 @@
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
export function useLeagueAdminStatus(leagueId: string, currentDriverId: string) {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
return usePageData({
queryKey: ['admin-check', leagueId, currentDriverId],
queryFn: async () => {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
},
enabled: !!leagueId && !!currentDriverId,
});
}
export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return usePageData({
queryKey: ['leagueSeasons', leagueId],
queryFn: () => leagueService.getLeagueSeasonSummaries(leagueId),
enabled: !!leagueId && !!isAdmin,
});
}
export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: string, isAdmin: boolean) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
return usePageData({
queryKey: ['adminSchedule', leagueId, selectedSeasonId],
queryFn: () => leagueService.getAdminSchedule(leagueId, selectedSeasonId),
enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
});
}

View File

@@ -0,0 +1,21 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useLeagueSponsorshipsPageData(leagueId: string, currentDriverId: string) {
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
return usePageDataMultiple({
league: {
queryKey: ['leagueDetail', leagueId, currentDriverId],
queryFn: () => leagueService.getLeagueDetail(leagueId, currentDriverId),
},
membership: {
queryKey: ['leagueMembership', leagueId, currentDriverId],
queryFn: () => leagueMembershipService.fetchLeagueMemberships(leagueId).then(() => {
return leagueMembershipService.getMembership(leagueId, currentDriverId);
}),
},
});
}

View File

@@ -0,0 +1,46 @@
import { usePageMutation } from '@/lib/page/usePageData';
export function useLeagueStewardingMutations(onRefetch: () => void) {
const acceptProtestMutation = usePageMutation(
async (variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
// TODO: Implement protest review and penalty application
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,
// stewardId: currentDriverId,
// decision: 'uphold',
// decisionNotes: variables.stewardNotes,
// });
// await leagueStewardingService.applyPenalty({
// raceId: variables.raceId,
// driverId: variables.accusedDriverId,
// stewardId: currentDriverId,
// type: variables.penaltyType,
// value: variables.penaltyValue,
// reason: variables.reason,
// protestId: variables.protestId,
// notes: variables.stewardNotes,
// });
},
{
onSuccess: () => onRefetch(),
}
);
const rejectProtestMutation = usePageMutation(
async (variables: { protestId: string; stewardNotes: string }) => {
// TODO: Implement protest rejection
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,
// stewardId: currentDriverId,
// decision: 'dismiss',
// decisionNotes: variables.stewardNotes,
// });
},
{
onSuccess: () => onRefetch(),
}
);
return { acceptProtestMutation, rejectProtestMutation };
}

View File

@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueWallet(leagueId: string) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueWallet', leagueId],
queryFn: () => leagueWalletService.getWalletForLeague(leagueId),
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,47 @@
import { usePageData, usePageMutation } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useLeagueWalletPageData(leagueId: string) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const queryResult = usePageData({
queryKey: ['leagueWallet', leagueId],
queryFn: () => leagueWalletService.getWalletForLeague(leagueId),
enabled: !!leagueId,
});
return queryResult;
}
export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
const withdrawMutation = usePageMutation(
async ({ amount }: { amount: number }) => {
if (!data) throw new Error('Wallet data not available');
const result = await leagueWalletService.withdraw(
leagueId,
amount,
data.currency,
'season-2', // Current active season
'bank-account-***1234'
);
if (!result.success) {
throw new Error(result.message || 'Withdrawal failed');
}
return result;
},
{
onSuccess: () => {
// Refetch wallet data after successful withdrawal
refetch();
},
}
);
return withdrawMutation;
}

View File

@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useLeagueProtests(leagueId: string) {
const protestService = useInject(PROTEST_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['leagueProtests', leagueId],
queryFn: () => protestService.getLeagueProtests(leagueId),
enabled: !!leagueId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,12 @@
import { usePageData } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useAllRacesPageData() {
const raceService = useInject(RACE_SERVICE_TOKEN);
return usePageData({
queryKey: ['races', 'all'],
queryFn: () => raceService.getAllRacesPageData(),
});
}

View File

@@ -1,15 +0,0 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useCancelRace(
options?: Omit<UseMutationOptions<void, ApiError, string>, 'mutationFn'>
) {
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, string>({
mutationFn: (raceId) => raceService.cancelRace(raceId),
...options,
});
}

View File

@@ -1,15 +0,0 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useCompleteRace(
options?: Omit<UseMutationOptions<void, ApiError, string>, 'mutationFn'>
) {
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, string>({
mutationFn: (raceId) => raceService.completeRace(raceId),
...options,
});
}

View File

@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useRaceDetail(raceId: string, driverId: string) {
const raceService = useInject(RACE_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['raceDetail', raceId, driverId],
queryFn: () => raceService.getRaceDetail(raceId, driverId),
enabled: !!raceId && !!driverId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useRaceResultsDetail(raceId: string, currentUserId?: string) {
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['raceResultsDetail', raceId, currentUserId],
queryFn: () => raceResultsService.getResultsDetail(raceId, currentUserId),
enabled: !!raceId,
});
return enhanceQueryResult(queryResult);
}

View File

@@ -0,0 +1,20 @@
import { usePageDataMultiple } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens';
export function useRaceResultsPageData(raceId: string, currentDriverId: string) {
const raceResultsService = useInject(RACE_RESULTS_SERVICE_TOKEN);
return usePageDataMultiple({
results: {
queryKey: ['raceResultsDetail', raceId, currentDriverId],
queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId),
enabled: !!raceId,
},
sof: {
queryKey: ['raceWithSOF', raceId],
queryFn: () => raceResultsService.getWithSOF(raceId),
enabled: !!raceId,
},
});
}

View File

@@ -1,16 +0,0 @@
import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { RACE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
export function useRaceStewardingData(raceId: string, currentDriverId?: string) {
const raceStewardingService = useInject(RACE_STEWARDING_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['raceStewardingData', raceId, currentDriverId],
queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId || ''),
enabled: !!raceId,
});
return enhanceQueryResult(queryResult);
}

Some files were not shown because too many files have changed in this diff Show More