wip
This commit is contained in:
23
.eslintrc.json
Normal file
23
.eslintrc.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"root": true,
|
||||||
|
"env": {
|
||||||
|
"es2022": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"parserOptions": {
|
||||||
|
"sourceType": "module",
|
||||||
|
"ecmaVersion": 2022
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.ts", "**/*.tsx"],
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"plugins": ["@typescript-eslint"],
|
||||||
|
"extends": [],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -44,3 +44,6 @@ tmp/
|
|||||||
temp/
|
temp/
|
||||||
.vercel
|
.vercel
|
||||||
.env*.local
|
.env*.local
|
||||||
|
|
||||||
|
|
||||||
|
userData/
|
||||||
@@ -11,12 +11,14 @@ import type { AutomationEnginePort } from '@gridpilot/automation/application/por
|
|||||||
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
|
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
|
||||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
|
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
|
||||||
|
import type { CheckoutServicePort } from '@gridpilot/automation/application/ports/CheckoutServicePort';
|
||||||
import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase';
|
import { StartAutomationSessionUseCase } from '@gridpilot/automation/application/use-cases/StartAutomationSessionUseCase';
|
||||||
import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase';
|
import { CheckAuthenticationUseCase } from '@gridpilot/automation/application/use-cases/CheckAuthenticationUseCase';
|
||||||
import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase';
|
import { InitiateLoginUseCase } from '@gridpilot/automation/application/use-cases/InitiateLoginUseCase';
|
||||||
import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase';
|
import { ClearSessionUseCase } from '@gridpilot/automation/application/use-cases/ClearSessionUseCase';
|
||||||
import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase';
|
import { ConfirmCheckoutUseCase } from '@gridpilot/automation/application/use-cases/ConfirmCheckoutUseCase';
|
||||||
import { OverlaySyncService } from '@gridpilot/automation/application/services/OverlaySyncService';
|
import { OverlaySyncService } from '@gridpilot/automation/application/services/OverlaySyncService';
|
||||||
|
import type { IAutomationLifecycleEmitter } from '@gridpilot/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
import { InMemorySessionRepository } from '@gridpilot/automation/infrastructure/repositories/InMemorySessionRepository';
|
import { InMemorySessionRepository } from '@gridpilot/automation/infrastructure/repositories/InMemorySessionRepository';
|
||||||
@@ -187,13 +189,19 @@ export function configureDIContainer(): void {
|
|||||||
browserAutomation
|
browserAutomation
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Checkout Service (singleton, backed by browser automation)
|
||||||
|
container.registerInstance<CheckoutServicePort>(
|
||||||
|
DI_TOKENS.CheckoutService,
|
||||||
|
browserAutomation as unknown as CheckoutServicePort
|
||||||
|
);
|
||||||
|
|
||||||
// Automation Engine (singleton)
|
// Automation Engine (singleton)
|
||||||
const sessionRepository = container.resolve<SessionRepositoryPort>(DI_TOKENS.SessionRepository);
|
const sessionRepository = container.resolve<SessionRepositoryPort>(DI_TOKENS.SessionRepository);
|
||||||
let automationEngine: AutomationEnginePort;
|
let automationEngine: AutomationEnginePort;
|
||||||
|
|
||||||
if (fixtureMode) {
|
if (fixtureMode) {
|
||||||
automationEngine = new AutomationEngineAdapter(
|
automationEngine = new AutomationEngineAdapter(
|
||||||
browserAutomation as any,
|
browserAutomation,
|
||||||
sessionRepository
|
sessionRepository
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -247,16 +255,16 @@ export function configureDIContainer(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Overlay Sync Service - create singleton instance directly
|
// Overlay Sync Service - create singleton instance directly
|
||||||
const lifecycleEmitter = browserAutomation as any;
|
const lifecycleEmitter = browserAutomation as unknown as IAutomationLifecycleEmitter;
|
||||||
const publisher = {
|
const publisher = {
|
||||||
publish: async (_event: any) => {
|
publish: async (event: unknown) => {
|
||||||
try {
|
try {
|
||||||
logger.debug?.('OverlaySyncPublisher.publish', _event);
|
logger.debug?.('OverlaySyncPublisher.publish', { event });
|
||||||
} catch {
|
} catch {
|
||||||
// swallow
|
// swallow
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
} as any;
|
};
|
||||||
const overlaySyncService = new OverlaySyncService({
|
const overlaySyncService = new OverlaySyncService({
|
||||||
lifecycleEmitter,
|
lifecycleEmitter,
|
||||||
publisher,
|
publisher,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import type { IBrowserAutomation } from '@gridpilot/automation/application/ports
|
|||||||
import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort';
|
import type { AutomationEnginePort } from '@gridpilot/automation/application/ports/AutomationEnginePort';
|
||||||
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
|
import type { AuthenticationServicePort } from '@gridpilot/automation/application/ports/AuthenticationServicePort';
|
||||||
import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort';
|
import type { CheckoutConfirmationPort } from '@gridpilot/automation/application/ports/CheckoutConfirmationPort';
|
||||||
|
import type { CheckoutServicePort } from '@gridpilot/automation/application/ports/CheckoutServicePort';
|
||||||
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
import type { LoggerPort } from '@gridpilot/automation/application/ports/LoggerPort';
|
||||||
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
|
import type { OverlaySyncPort } from '@gridpilot/automation/application/ports/OverlaySyncPort';
|
||||||
|
|
||||||
@@ -145,9 +146,9 @@ export class DIContainer {
|
|||||||
|
|
||||||
public setConfirmCheckoutUseCase(checkoutConfirmationPort: CheckoutConfirmationPort): void {
|
public setConfirmCheckoutUseCase(checkoutConfirmationPort: CheckoutConfirmationPort): void {
|
||||||
this.ensureInitialized();
|
this.ensureInitialized();
|
||||||
const browserAutomation = getDIContainer().resolve<IBrowserAutomation>(DI_TOKENS.BrowserAutomation);
|
const checkoutService = getDIContainer().resolve<CheckoutServicePort>(DI_TOKENS.CheckoutService);
|
||||||
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
|
this.confirmCheckoutUseCase = new ConfirmCheckoutUseCase(
|
||||||
browserAutomation as any,
|
checkoutService,
|
||||||
checkoutConfirmationPort
|
checkoutConfirmationPort
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,7 +189,7 @@ export class DIContainer {
|
|||||||
new Error(result.error || 'Unknown error'),
|
new Error(result.error || 'Unknown error'),
|
||||||
{ mode: this.automationMode }
|
{ mode: this.automationMode }
|
||||||
);
|
);
|
||||||
return { success: false, error: result.error };
|
return { success: false, error: result.error ?? 'Unknown error' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const isConnected = playwrightAdapter.isConnected();
|
const isConnected = playwrightAdapter.isConnected();
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const DI_TOKENS = {
|
|||||||
|
|
||||||
// Services
|
// Services
|
||||||
OverlaySyncPort: Symbol.for('OverlaySyncPort'),
|
OverlaySyncPort: Symbol.for('OverlaySyncPort'),
|
||||||
|
CheckoutService: Symbol.for('CheckoutServicePort'),
|
||||||
|
|
||||||
// Infrastructure
|
// Infrastructure
|
||||||
FixtureServer: Symbol.for('FixtureServer'),
|
FixtureServer: Symbol.for('FixtureServer'),
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { ipcMain } from 'electron';
|
import { ipcMain } from 'electron';
|
||||||
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
import type { BrowserWindow, IpcMainInvokeEvent } from 'electron';
|
||||||
import { DIContainer } from './di-container';
|
import { DIContainer } from './di-container';
|
||||||
import type { HostedSessionConfig } from '@/packages/domain/entities/HostedSessionConfig';
|
import type { HostedSessionConfig } from 'packages/automation/domain/types/HostedSessionConfig';
|
||||||
import { StepId } from '@/packages/domain/value-objects/StepId';
|
import { StepId } from 'packages/automation/domain/value-objects/StepId';
|
||||||
import { AuthenticationState } from '@/packages/domain/value-objects/AuthenticationState';
|
import { AuthenticationState } from 'packages/automation/domain/value-objects/AuthenticationState';
|
||||||
import { ElectronCheckoutConfirmationAdapter } from '@/packages/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
import { ElectronCheckoutConfirmationAdapter } from 'packages/automation/infrastructure/adapters/ipc/ElectronCheckoutConfirmationAdapter';
|
||||||
|
import type { OverlayAction } from 'packages/automation/application/ports/OverlaySyncPort';
|
||||||
|
import type { IAutomationLifecycleEmitter } from 'packages/automation/infrastructure/adapters/IAutomationLifecycleEmitter';
|
||||||
|
|
||||||
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
let progressMonitorInterval: NodeJS.Timeout | null = null;
|
||||||
let lifecycleSubscribed = false;
|
let lifecycleSubscribed = false;
|
||||||
@@ -95,8 +97,8 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Call confirmLoginComplete on the adapter if it exists
|
// Call confirmLoginComplete on the adapter if it exists
|
||||||
if ('confirmLoginComplete' in authService) {
|
if ('confirmLoginComplete' in authService && typeof authService.confirmLoginComplete === 'function') {
|
||||||
const result = await (authService as any).confirmLoginComplete();
|
const result = await authService.confirmLoginComplete();
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
logger.error('Confirm login failed', result.unwrapErr());
|
logger.error('Confirm login failed', result.unwrapErr());
|
||||||
return { success: false, error: result.unwrapErr().message };
|
return { success: false, error: result.unwrapErr().message };
|
||||||
@@ -334,7 +336,7 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
// Ensure runtime automation wiring reflects the new browser mode
|
// Ensure runtime automation wiring reflects the new browser mode
|
||||||
if ('refreshBrowserAutomation' in container) {
|
if ('refreshBrowserAutomation' in container) {
|
||||||
// Call method to refresh adapters/use-cases that depend on browser mode
|
// Call method to refresh adapters/use-cases that depend on browser mode
|
||||||
(container as any).refreshBrowserAutomation();
|
container.refreshBrowserAutomation();
|
||||||
}
|
}
|
||||||
logger.info('Browser mode updated', { mode });
|
logger.info('Browser mode updated', { mode });
|
||||||
return { success: true, mode };
|
return { success: true, mode };
|
||||||
@@ -349,9 +351,9 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Handle overlay action requests from renderer and forward to the OverlaySyncService
|
// Handle overlay action requests from renderer and forward to the OverlaySyncService
|
||||||
ipcMain.handle('overlay-action-request', async (_event: IpcMainInvokeEvent, action: any) => {
|
ipcMain.handle('overlay-action-request', async (_event: IpcMainInvokeEvent, action: OverlayAction) => {
|
||||||
try {
|
try {
|
||||||
const overlayPort = (container as any).getOverlaySyncPort ? container.getOverlaySyncPort() : null;
|
const overlayPort = 'getOverlaySyncPort' in container ? container.getOverlaySyncPort() : null;
|
||||||
if (!overlayPort) {
|
if (!overlayPort) {
|
||||||
logger.warn('OverlaySyncPort not available');
|
logger.warn('OverlaySyncPort not available');
|
||||||
return { id: action?.id ?? 'unknown', status: 'failed', reason: 'OverlaySyncPort not available' };
|
return { id: action?.id ?? 'unknown', status: 'failed', reason: 'OverlaySyncPort not available' };
|
||||||
@@ -361,16 +363,20 @@ export function setupIpcHandlers(mainWindow: BrowserWindow): void {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
const err = e instanceof Error ? e : new Error(String(e));
|
const err = e instanceof Error ? e : new Error(String(e));
|
||||||
logger.error('Overlay action request failed', err);
|
logger.error('Overlay action request failed', err);
|
||||||
return { id: action?.id ?? 'unknown', status: 'failed', reason: err.message };
|
const id = typeof action === 'object' && action !== null && 'id' in action
|
||||||
|
? // eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
(action as { id?: string }).id ?? 'unknown'
|
||||||
|
: 'unknown';
|
||||||
|
return { id, status: 'failed', reason: err.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to automation adapter lifecycle events and relay to renderer
|
// Subscribe to automation adapter lifecycle events and relay to renderer
|
||||||
try {
|
try {
|
||||||
if (!lifecycleSubscribed) {
|
if (!lifecycleSubscribed) {
|
||||||
const browserAutomation = container.getBrowserAutomation() as any;
|
const lifecycleEmitter = container.getBrowserAutomation() as unknown as IAutomationLifecycleEmitter;
|
||||||
if (browserAutomation && typeof browserAutomation.onLifecycle === 'function') {
|
if (typeof lifecycleEmitter.onLifecycle === 'function') {
|
||||||
browserAutomation.onLifecycle((ev: any) => {
|
lifecycleEmitter.onLifecycle((ev) => {
|
||||||
try {
|
try {
|
||||||
if (mainWindow && mainWindow.webContents) {
|
if (mainWindow && mainWindow.webContents) {
|
||||||
mainWindow.webContents.send('automation-event', ev);
|
mainWindow.webContents.send('automation-event', ev);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { contextBridge, ipcRenderer } from 'electron';
|
import { contextBridge, ipcRenderer } from 'electron';
|
||||||
import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
|
import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
|
||||||
import type { AuthenticationState } from '../../../packages/domain/value-objects/AuthenticationState';
|
import type { AuthenticationState } from '../../../packages/automation/domain/value-objects/AuthenticationState';
|
||||||
|
|
||||||
export interface AuthStatusEvent {
|
export interface AuthStatusEvent {
|
||||||
state: AuthenticationState;
|
state: AuthenticationState;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { LoginPrompt } from './components/LoginPrompt';
|
|||||||
import { BrowserModeToggle } from './components/BrowserModeToggle';
|
import { BrowserModeToggle } from './components/BrowserModeToggle';
|
||||||
import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog';
|
import { CheckoutConfirmationDialog } from './components/CheckoutConfirmationDialog';
|
||||||
import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen';
|
import { RaceCreationSuccessScreen } from './components/RaceCreationSuccessScreen';
|
||||||
import type { HostedSessionConfig } from '../../../packages/domain/entities/HostedSessionConfig';
|
import type { HostedSessionConfig } from '../../../packages/automation/domain/types/HostedSessionConfig';
|
||||||
|
|
||||||
interface SessionProgress {
|
interface SessionProgress {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -138,7 +138,13 @@ export function App() {
|
|||||||
|
|
||||||
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
const handleStartAutomation = async (config: HostedSessionConfig) => {
|
||||||
setIsRunning(true);
|
setIsRunning(true);
|
||||||
const result = await window.electronAPI.startAutomation(config);
|
const result = await window.electronAPI.startAutomation(config) as {
|
||||||
|
success: boolean;
|
||||||
|
sessionId?: string;
|
||||||
|
error?: string;
|
||||||
|
authRequired?: boolean;
|
||||||
|
authState?: AuthState;
|
||||||
|
};
|
||||||
|
|
||||||
if (result.success && result.sessionId) {
|
if (result.success && result.sessionId) {
|
||||||
setSessionId(result.sessionId);
|
setSessionId(result.sessionId);
|
||||||
@@ -147,8 +153,8 @@ export function App() {
|
|||||||
|
|
||||||
setIsRunning(false);
|
setIsRunning(false);
|
||||||
|
|
||||||
if ((result as any).authRequired) {
|
if ('authRequired' in result && result.authRequired) {
|
||||||
const nextAuthState = (result as any).authState as AuthState | undefined;
|
const nextAuthState = result.authState as AuthState | undefined;
|
||||||
setAuthState(nextAuthState ?? 'EXPIRED');
|
setAuthState(nextAuthState ?? 'EXPIRED');
|
||||||
setAuthError(result.error ?? 'Authentication required before starting automation.');
|
setAuthError(result.error ?? 'Authentication required before starting automation.');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ type LoginStatus = 'idle' | 'waiting' | 'success' | 'error';
|
|||||||
|
|
||||||
interface LoginPromptProps {
|
interface LoginPromptProps {
|
||||||
authState: string;
|
authState: string;
|
||||||
errorMessage?: string;
|
errorMessage: string | undefined;
|
||||||
onLogin: () => void;
|
onLogin: () => void;
|
||||||
onRetry: () => void;
|
onRetry: () => void;
|
||||||
loginStatus?: LoginStatus;
|
loginStatus?: LoginStatus;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import type { HostedSessionConfig } from '../../../../packages/domain/entities/HostedSessionConfig';
|
import type { HostedSessionConfig } from '../../../../packages/automation/domain/types/HostedSessionConfig';
|
||||||
|
|
||||||
interface SessionCreationFormProps {
|
interface SessionCreationFormProps {
|
||||||
onSubmit: (config: HostedSessionConfig) => void;
|
onSubmit: (config: HostedSessionConfig) => void;
|
||||||
@@ -112,7 +112,14 @@ export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormP
|
|||||||
<label style={labelStyle}>Weather Type</label>
|
<label style={labelStyle}>Weather Type</label>
|
||||||
<select
|
<select
|
||||||
value={config.weatherType}
|
value={config.weatherType}
|
||||||
onChange={(e) => setConfig({ ...config, weatherType: e.target.value as any })}
|
onChange={(e) =>
|
||||||
|
setConfig(prev =>
|
||||||
|
({
|
||||||
|
...prev,
|
||||||
|
weatherType: e.target.value as HostedSessionConfig['weatherType'],
|
||||||
|
} as HostedSessionConfig)
|
||||||
|
)
|
||||||
|
}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
@@ -125,7 +132,14 @@ export function SessionCreationForm({ onSubmit, disabled }: SessionCreationFormP
|
|||||||
<label style={labelStyle}>Time of Day</label>
|
<label style={labelStyle}>Time of Day</label>
|
||||||
<select
|
<select
|
||||||
value={config.timeOfDay}
|
value={config.timeOfDay}
|
||||||
onChange={(e) => setConfig({ ...config, timeOfDay: e.target.value as any })}
|
onChange={(e) =>
|
||||||
|
setConfig(prev =>
|
||||||
|
({
|
||||||
|
...prev,
|
||||||
|
timeOfDay: e.target.value as HostedSessionConfig['timeOfDay'],
|
||||||
|
} as HostedSessionConfig)
|
||||||
|
)
|
||||||
|
}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
|
|||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
// Use electronAPI overlayActionRequest to obtain ack
|
// Use electronAPI overlayActionRequest to obtain ack
|
||||||
if ((window as any).electronAPI?.overlayActionRequest) {
|
if (window.electronAPI?.overlayActionRequest) {
|
||||||
const ack = await (window as any).electronAPI.overlayActionRequest(action);
|
const ack = await window.electronAPI.overlayActionRequest(action);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setAckStatusByStep(prev => ({ ...prev, [currentStep]: ack.status }));
|
setAckStatusByStep(prev => ({ ...prev, [currentStep]: ack.status }));
|
||||||
} else {
|
} else {
|
||||||
@@ -91,8 +91,8 @@ export function SessionProgressMonitor({ sessionId, progress, isRunning }: Sessi
|
|||||||
|
|
||||||
// Subscribe to automation events for optional live updates
|
// Subscribe to automation events for optional live updates
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if ((window as any).electronAPI?.onAutomationEvent) {
|
if (window.electronAPI?.onAutomationEvent) {
|
||||||
const off = (window as any).electronAPI.onAutomationEvent((ev: any) => {
|
const off = window.electronAPI.onAutomationEvent((ev) => {
|
||||||
if (ev && ev.payload && ev.payload.actionId && ev.type) {
|
if (ev && ev.payload && ev.payload.actionId && ev.type) {
|
||||||
setAutomationEventMsg(`${ev.type} ${ev.payload.actionId}`);
|
setAutomationEventMsg(`${ev.type} ${ev.payload.actionId}`);
|
||||||
} else if (ev && ev.type) {
|
} else if (ev && ev.type) {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
"rules": {
|
"rules": {
|
||||||
"react/no-unescaped-entities": "off",
|
"react/no-unescaped-entities": "off",
|
||||||
"@next/next/no-img-element": "warn",
|
"@next/next/no-img-element": "warn",
|
||||||
"react-hooks/exhaustive-deps": "warn"
|
"react-hooks/exhaustive-deps": "warn",
|
||||||
|
"@typescript-eslint/no-explicit-any": "error"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,7 +27,10 @@ export async function POST(request: Request) {
|
|||||||
return jsonError(400, 'Invalid request body');
|
return jsonError(400, 'Invalid request body');
|
||||||
}
|
}
|
||||||
|
|
||||||
const email = (body as any)?.email;
|
const email =
|
||||||
|
typeof body === 'object' && body !== null && 'email' in body
|
||||||
|
? (body as { email: unknown }).email
|
||||||
|
: undefined;
|
||||||
|
|
||||||
if (typeof email !== 'string' || !email.trim()) {
|
if (typeof email !== 'string' || !email.trim()) {
|
||||||
return jsonError(400, 'Invalid email address');
|
return jsonError(400, 'Invalid email address');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ export async function GET(request: Request) {
|
|||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const code = url.searchParams.get('code') ?? undefined;
|
const code = url.searchParams.get('code') ?? undefined;
|
||||||
const state = url.searchParams.get('state') ?? undefined;
|
const state = url.searchParams.get('state') ?? undefined;
|
||||||
const returnTo = url.searchParams.get('returnTo') ?? undefined;
|
const rawReturnTo = url.searchParams.get('returnTo');
|
||||||
|
const returnTo = rawReturnTo ?? undefined;
|
||||||
|
|
||||||
if (!code || !state) {
|
if (!code || !state) {
|
||||||
return NextResponse.redirect('/auth/iracing');
|
return NextResponse.redirect('/auth/iracing');
|
||||||
@@ -23,7 +24,8 @@ export async function GET(request: Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const authService = getAuthService();
|
const authService = getAuthService();
|
||||||
await authService.loginWithIracingCallback({ code, state, returnTo });
|
const loginInput = returnTo ? { code, state, returnTo } : { code, state };
|
||||||
|
await authService.loginWithIracingCallback(loginInput);
|
||||||
|
|
||||||
cookieStore.delete(STATE_COOKIE);
|
cookieStore.delete(STATE_COOKIE);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, use } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter, useParams } from 'next/navigation';
|
import { useRouter, useParams } from 'next/navigation';
|
||||||
@@ -43,12 +43,16 @@ import {
|
|||||||
getGetAllTeamsUseCase,
|
getGetAllTeamsUseCase,
|
||||||
getGetTeamMembersUseCase,
|
getGetTeamMembersUseCase,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { AllTeamsPresenter } from '@/lib/presenters/AllTeamsPresenter';
|
||||||
|
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
|
||||||
import { Driver, EntityMappers, type Team } from '@gridpilot/racing';
|
import { Driver, EntityMappers, type Team } from '@gridpilot/racing';
|
||||||
import type { DriverDTO } from '@gridpilot/racing';
|
import type { DriverDTO } from '@gridpilot/racing';
|
||||||
|
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
||||||
|
import type { TeamMemberViewModel } from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// TYPES
|
// TYPES
|
||||||
@@ -134,14 +138,22 @@ function getDemoExtendedProfile(driverId: string): DriverExtendedProfile {
|
|||||||
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
|
const timezones = ['EST (UTC-5)', 'CET (UTC+1)', 'PST (UTC-8)', 'GMT (UTC+0)', 'JST (UTC+9)'];
|
||||||
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
|
const hours = ['Evenings (18:00-23:00)', 'Weekends only', 'Late nights (22:00-02:00)', 'Flexible schedule'];
|
||||||
|
|
||||||
|
const socialHandles = socialOptions[hash % socialOptions.length] ?? [];
|
||||||
|
const achievements = achievementSets[hash % achievementSets.length] ?? [];
|
||||||
|
const racingStyle = styles[hash % styles.length] ?? 'Consistent Pacer';
|
||||||
|
const favoriteTrack = tracks[hash % tracks.length] ?? 'Unknown Track';
|
||||||
|
const favoriteCar = cars[hash % cars.length] ?? 'Unknown Car';
|
||||||
|
const timezone = timezones[hash % timezones.length] ?? 'UTC';
|
||||||
|
const availableHours = hours[hash % hours.length] ?? 'Flexible schedule';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
socialHandles: socialOptions[hash % socialOptions.length],
|
socialHandles,
|
||||||
achievements: achievementSets[hash % achievementSets.length],
|
achievements,
|
||||||
racingStyle: styles[hash % styles.length],
|
racingStyle,
|
||||||
favoriteTrack: tracks[hash % tracks.length],
|
favoriteTrack,
|
||||||
favoriteCar: cars[hash % cars.length],
|
favoriteCar,
|
||||||
timezone: timezones[hash % timezones.length],
|
timezone,
|
||||||
availableHours: hours[hash % hours.length],
|
availableHours,
|
||||||
lookingForTeam: hash % 3 === 0,
|
lookingForTeam: hash % 3 === 0,
|
||||||
openToRequests: hash % 2 === 0,
|
openToRequests: hash % 2 === 0,
|
||||||
};
|
};
|
||||||
@@ -301,11 +313,46 @@ function HorizontalBarChart({ data, maxValue }: BarChartProps) {
|
|||||||
// MAIN PAGE
|
// MAIN PAGE
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default function DriverDetailPage({
|
interface DriverProfileStatsViewModel {
|
||||||
searchParams,
|
rating: number;
|
||||||
}: {
|
wins: number;
|
||||||
searchParams: any;
|
podiums: number;
|
||||||
}) {
|
dnfs: number;
|
||||||
|
totalRaces: number;
|
||||||
|
avgFinish: number;
|
||||||
|
bestFinish: number;
|
||||||
|
worstFinish: number;
|
||||||
|
consistency: number;
|
||||||
|
percentile: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverProfileFriendViewModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
country: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DriverProfileExtendedViewModel extends DriverExtendedProfile {}
|
||||||
|
|
||||||
|
interface DriverProfileViewModel {
|
||||||
|
currentDriver?: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
iracingId?: string | null;
|
||||||
|
country: string;
|
||||||
|
bio?: string | null;
|
||||||
|
joinedAt: string | Date;
|
||||||
|
globalRank?: number;
|
||||||
|
totalDrivers?: number;
|
||||||
|
};
|
||||||
|
stats?: DriverProfileStatsViewModel;
|
||||||
|
extendedProfile?: DriverProfileExtendedViewModel;
|
||||||
|
socialSummary?: {
|
||||||
|
friends: DriverProfileFriendViewModel[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DriverDetailPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const driverId = params.id as string;
|
const driverId = params.id as string;
|
||||||
@@ -318,24 +365,16 @@ export default function DriverDetailPage({
|
|||||||
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
|
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
|
||||||
const [friends, setFriends] = useState<Driver[]>([]);
|
const [friends, setFriends] = useState<Driver[]>([]);
|
||||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||||
const [profileData, setProfileData] = useState<any>(null);
|
const [profileData, setProfileData] = useState<ProfileOverviewViewModel | null>(null);
|
||||||
|
|
||||||
const unwrappedSearchParams = use(searchParams) as URLSearchParams | undefined;
|
const search =
|
||||||
|
typeof window !== 'undefined'
|
||||||
const from =
|
? new URLSearchParams(window.location.search)
|
||||||
typeof unwrappedSearchParams?.get === 'function'
|
|
||||||
? unwrappedSearchParams.get('from') ?? undefined
|
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
const leagueId =
|
const from = search?.get('from') ?? undefined;
|
||||||
typeof unwrappedSearchParams?.get === 'function'
|
const leagueId = search?.get('leagueId') ?? undefined;
|
||||||
? unwrappedSearchParams.get('leagueId') ?? undefined
|
const raceId = search?.get('raceId') ?? undefined;
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const raceId =
|
|
||||||
typeof unwrappedSearchParams?.get === 'function'
|
|
||||||
? unwrappedSearchParams.get('raceId') ?? undefined
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
let backLink: string | null = null;
|
let backLink: string | null = null;
|
||||||
|
|
||||||
@@ -362,8 +401,7 @@ export default function DriverDetailPage({
|
|||||||
try {
|
try {
|
||||||
// Use GetProfileOverviewUseCase to load all profile data
|
// Use GetProfileOverviewUseCase to load all profile data
|
||||||
const profileUseCase = getGetProfileOverviewUseCase();
|
const profileUseCase = getGetProfileOverviewUseCase();
|
||||||
await profileUseCase.execute({ driverId });
|
const profileViewModel = await profileUseCase.execute({ driverId });
|
||||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
|
||||||
|
|
||||||
if (!profileViewModel || !profileViewModel.currentDriver) {
|
if (!profileViewModel || !profileViewModel.currentDriver) {
|
||||||
setError('Driver not found');
|
setError('Driver not found');
|
||||||
@@ -375,7 +413,7 @@ export default function DriverDetailPage({
|
|||||||
const driverData: DriverDTO = {
|
const driverData: DriverDTO = {
|
||||||
id: profileViewModel.currentDriver.id,
|
id: profileViewModel.currentDriver.id,
|
||||||
name: profileViewModel.currentDriver.name,
|
name: profileViewModel.currentDriver.name,
|
||||||
iracingId: profileViewModel.currentDriver.iracingId,
|
iracingId: profileViewModel.currentDriver.iracingId ?? '',
|
||||||
country: profileViewModel.currentDriver.country,
|
country: profileViewModel.currentDriver.country,
|
||||||
bio: profileViewModel.currentDriver.bio || '',
|
bio: profileViewModel.currentDriver.bio || '',
|
||||||
joinedAt: profileViewModel.currentDriver.joinedAt,
|
joinedAt: profileViewModel.currentDriver.joinedAt,
|
||||||
@@ -383,30 +421,37 @@ export default function DriverDetailPage({
|
|||||||
setDriver(driverData);
|
setDriver(driverData);
|
||||||
setProfileData(profileViewModel);
|
setProfileData(profileViewModel);
|
||||||
|
|
||||||
// Load team data
|
// Load ALL team memberships using caller-owned presenters
|
||||||
const teamUseCase = getGetDriverTeamUseCase();
|
|
||||||
await teamUseCase.execute({ driverId });
|
|
||||||
const teamViewModel = teamUseCase.presenter.getViewModel();
|
|
||||||
setTeamData(teamViewModel.result);
|
|
||||||
|
|
||||||
// Load ALL team memberships
|
|
||||||
const allTeamsUseCase = getGetAllTeamsUseCase();
|
const allTeamsUseCase = getGetAllTeamsUseCase();
|
||||||
await allTeamsUseCase.execute();
|
const allTeamsPresenter = new AllTeamsPresenter();
|
||||||
const allTeamsViewModel = allTeamsUseCase.presenter.getViewModel();
|
await allTeamsUseCase.execute(undefined as void, allTeamsPresenter);
|
||||||
const allTeams = allTeamsViewModel.teams;
|
const allTeamsViewModel = allTeamsPresenter.getViewModel();
|
||||||
const membershipsUseCase = getGetTeamMembersUseCase();
|
const allTeams = allTeamsViewModel?.teams ?? [];
|
||||||
|
|
||||||
|
const membershipsUseCase = getGetTeamMembersUseCase();
|
||||||
const memberships: TeamMembershipInfo[] = [];
|
const memberships: TeamMembershipInfo[] = [];
|
||||||
|
|
||||||
for (const team of allTeams) {
|
for (const team of allTeams) {
|
||||||
await membershipsUseCase.execute({ teamId: team.id });
|
const teamMembersPresenter = new TeamMembersPresenter();
|
||||||
const membersViewModel = membershipsUseCase.presenter.getViewModel();
|
await membershipsUseCase.execute({ teamId: team.id }, teamMembersPresenter);
|
||||||
const members = membersViewModel.members;
|
const membersResult = teamMembersPresenter.getViewModel();
|
||||||
const membership = members.find((m) => m.driverId === driverId);
|
const members = membersResult?.members ?? [];
|
||||||
|
const membership = members.find(
|
||||||
|
(member: TeamMemberViewModel) => member.driverId === driverId,
|
||||||
|
);
|
||||||
if (membership) {
|
if (membership) {
|
||||||
memberships.push({
|
memberships.push({
|
||||||
team,
|
team: {
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
tag: team.tag,
|
||||||
|
description: team.description,
|
||||||
|
ownerId: '',
|
||||||
|
leagues: team.leagues,
|
||||||
|
createdAt: new Date(),
|
||||||
|
} as Team,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
joinedAt: membership.joinedAt,
|
joinedAt: new Date(membership.joinedAt),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -459,7 +504,30 @@ export default function DriverDetailPage({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const extendedProfile = profileData?.extendedProfile || getDemoExtendedProfile(driver.id);
|
const demoExtended = getDemoExtendedProfile(driver.id);
|
||||||
|
const extendedProfile: DriverExtendedProfile = {
|
||||||
|
socialHandles: profileData?.extendedProfile?.socialHandles ?? demoExtended.socialHandles,
|
||||||
|
achievements:
|
||||||
|
profileData?.extendedProfile?.achievements
|
||||||
|
? profileData.extendedProfile.achievements.map((achievement) => ({
|
||||||
|
id: achievement.id,
|
||||||
|
title: achievement.title,
|
||||||
|
description: achievement.description,
|
||||||
|
icon: achievement.icon,
|
||||||
|
rarity: achievement.rarity,
|
||||||
|
earnedAt: new Date(achievement.earnedAt),
|
||||||
|
}))
|
||||||
|
: demoExtended.achievements,
|
||||||
|
racingStyle: profileData?.extendedProfile?.racingStyle ?? demoExtended.racingStyle,
|
||||||
|
favoriteTrack: profileData?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack,
|
||||||
|
favoriteCar: profileData?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar,
|
||||||
|
timezone: profileData?.extendedProfile?.timezone ?? demoExtended.timezone,
|
||||||
|
availableHours: profileData?.extendedProfile?.availableHours ?? demoExtended.availableHours,
|
||||||
|
lookingForTeam:
|
||||||
|
profileData?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam,
|
||||||
|
openToRequests:
|
||||||
|
profileData?.extendedProfile?.openToRequests ?? demoExtended.openToRequests,
|
||||||
|
};
|
||||||
const stats = profileData?.stats || null;
|
const stats = profileData?.stats || null;
|
||||||
const globalRank = profileData?.currentDriver?.globalRank || 1;
|
const globalRank = profileData?.currentDriver?.globalRank || 1;
|
||||||
|
|
||||||
@@ -627,7 +695,7 @@ export default function DriverDetailPage({
|
|||||||
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
<div className="mt-6 pt-6 border-t border-charcoal-outline/50">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
<span className="text-sm text-gray-500 mr-2">Connect:</span>
|
||||||
{extendedProfile.socialHandles.map((social) => {
|
{extendedProfile.socialHandles.map((social: SocialHandle) => {
|
||||||
const Icon = getSocialIcon(social.platform);
|
const Icon = getSocialIcon(social.platform);
|
||||||
return (
|
return (
|
||||||
<a
|
<a
|
||||||
@@ -724,7 +792,7 @@ export default function DriverDetailPage({
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
value={stats.consistency}
|
value={stats.consistency ?? 0}
|
||||||
max={100}
|
max={100}
|
||||||
label="Consistency"
|
label="Consistency"
|
||||||
color="text-primary-blue"
|
color="text-primary-blue"
|
||||||
@@ -766,7 +834,9 @@ export default function DriverDetailPage({
|
|||||||
<Target className="w-4 h-4 text-primary-blue" />
|
<Target className="w-4 h-4 text-primary-blue" />
|
||||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
|
<p className="text-2xl font-bold text-primary-blue">
|
||||||
|
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -888,7 +958,7 @@ export default function DriverDetailPage({
|
|||||||
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
|
<span className="ml-auto text-sm text-gray-500">{extendedProfile.achievements.length} earned</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
{extendedProfile.achievements.map((achievement) => {
|
{extendedProfile.achievements.map((achievement: Achievement) => {
|
||||||
const Icon = getAchievementIcon(achievement.icon);
|
const Icon = getAchievementIcon(achievement.icon);
|
||||||
const rarityClasses = getRarityColor(achievement.rarity);
|
const rarityClasses = getRarityColor(achievement.rarity);
|
||||||
return (
|
return (
|
||||||
@@ -1033,7 +1103,9 @@ export default function DriverDetailPage({
|
|||||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||||
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
|
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||||
|
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||||
|
|||||||
@@ -71,11 +71,15 @@ interface TopThreePodiumProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
|
function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
|
||||||
const top3 = drivers.slice(0, 3);
|
if (drivers.length < 3) return null;
|
||||||
|
|
||||||
if (top3.length < 3) return null;
|
const top3 = drivers.slice(0, 3) as [DriverListItem, DriverListItem, DriverListItem];
|
||||||
|
|
||||||
const podiumOrder = [top3[1], top3[0], top3[2]]; // 2nd, 1st, 3rd
|
const podiumOrder: [DriverListItem, DriverListItem, DriverListItem] = [
|
||||||
|
top3[1],
|
||||||
|
top3[0],
|
||||||
|
top3[2],
|
||||||
|
]; // 2nd, 1st, 3rd
|
||||||
const podiumHeights = ['h-32', 'h-40', 'h-24'];
|
const podiumHeights = ['h-32', 'h-40', 'h-24'];
|
||||||
const podiumColors = [
|
const podiumColors = [
|
||||||
'from-gray-400/20 to-gray-500/10 border-gray-400/40',
|
'from-gray-400/20 to-gray-500/10 border-gray-400/40',
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
import { getGetDriversLeaderboardUseCase, getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||||
|
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||||
import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
|
import type { DriverLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/IDriversLeaderboardPresenter';
|
||||||
import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
import type { TeamLeaderboardItemViewModel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -286,14 +287,16 @@ export default function LeaderboardsPage() {
|
|||||||
try {
|
try {
|
||||||
const driversUseCase = getGetDriversLeaderboardUseCase();
|
const driversUseCase = getGetDriversLeaderboardUseCase();
|
||||||
const teamsUseCase = getGetTeamsLeaderboardUseCase();
|
const teamsUseCase = getGetTeamsLeaderboardUseCase();
|
||||||
|
const teamsPresenter = new TeamsLeaderboardPresenter();
|
||||||
|
|
||||||
await driversUseCase.execute();
|
await driversUseCase.execute();
|
||||||
await teamsUseCase.execute();
|
await teamsUseCase.execute(undefined as void, teamsPresenter);
|
||||||
|
|
||||||
const driversViewModel = driversUseCase.presenter.getViewModel();
|
const driversViewModel = driversUseCase.presenter.getViewModel();
|
||||||
const teamsViewModel = teamsUseCase.presenter.getViewModel();
|
const teamsViewModel = teamsPresenter.getViewModel();
|
||||||
|
|
||||||
setDrivers(driversViewModel.drivers);
|
setDrivers(driversViewModel.drivers);
|
||||||
setTeams(teamsViewModel.teams);
|
setTeams(teamsViewModel ? teamsViewModel.teams : []);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load leaderboard data:', error);
|
console.error('Failed to load leaderboard data:', error);
|
||||||
setDrivers([]);
|
setDrivers([]);
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ import type { League } from '@gridpilot/racing/domain/entities/League';
|
|||||||
// Main sponsor info for "by XYZ" display
|
// Main sponsor info for "by XYZ" display
|
||||||
interface MainSponsorInfo {
|
interface MainSponsorInfo {
|
||||||
name: string;
|
name: string;
|
||||||
logoUrl?: string;
|
logoUrl: string;
|
||||||
websiteUrl?: string;
|
websiteUrl: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueLayout({
|
export default function LeagueLayout({
|
||||||
@@ -80,8 +80,8 @@ export default function LeagueLayout({
|
|||||||
if (sponsor) {
|
if (sponsor) {
|
||||||
setMainSponsor({
|
setMainSponsor({
|
||||||
name: sponsor.name,
|
name: sponsor.name,
|
||||||
logoUrl: sponsor.logoUrl,
|
logoUrl: sponsor.logoUrl ?? '',
|
||||||
websiteUrl: sponsor.websiteUrl,
|
websiteUrl: sponsor.websiteUrl ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ export default function LeagueDetailPage() {
|
|||||||
const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase();
|
const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase();
|
||||||
await getLeagueScoringConfigUseCase.execute({ leagueId });
|
await getLeagueScoringConfigUseCase.execute({ leagueId });
|
||||||
const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel();
|
const scoringViewModel = getLeagueScoringConfigUseCase.presenter.getViewModel();
|
||||||
setScoringConfig(scoringViewModel);
|
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
|
||||||
|
|
||||||
// Load all drivers for standings and map to DTOs for UI components
|
// Load all drivers for standings and map to DTOs for UI components
|
||||||
const allDrivers = await driverRepo.findAll();
|
const allDrivers = await driverRepo.findAll();
|
||||||
@@ -157,23 +157,23 @@ export default function LeagueDetailPage() {
|
|||||||
|
|
||||||
if (activeSeason) {
|
if (activeSeason) {
|
||||||
const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id);
|
const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id);
|
||||||
const activeSponsorships = sponsorships.filter(s => s.status === 'active');
|
const activeSponsorships = sponsorships.filter((s) => s.status === 'active');
|
||||||
|
|
||||||
const sponsorInfos: SponsorInfo[] = [];
|
const sponsorInfos: SponsorInfo[] = [];
|
||||||
for (const sponsorship of activeSponsorships) {
|
for (const sponsorship of activeSponsorships) {
|
||||||
const sponsor = await sponsorRepo.findById(sponsorship.sponsorId);
|
const sponsor = await sponsorRepo.findById(sponsorship.sponsorId);
|
||||||
if (sponsor) {
|
if (sponsor) {
|
||||||
// Get tagline from demo data if available
|
const testingSupportModule = await import('@gridpilot/testing-support');
|
||||||
const demoSponsors = (await import('@gridpilot/testing-support')).sponsors;
|
const demoSponsors = testingSupportModule.sponsors as Array<{ id: string; tagline?: string }>;
|
||||||
const demoSponsor = demoSponsors.find((s: any) => s.id === sponsor.id);
|
const demoSponsor = demoSponsors.find((demo) => demo.id === sponsor.id);
|
||||||
|
|
||||||
sponsorInfos.push({
|
sponsorInfos.push({
|
||||||
id: sponsor.id,
|
id: sponsor.id,
|
||||||
name: sponsor.name,
|
name: sponsor.name,
|
||||||
logoUrl: sponsor.logoUrl,
|
logoUrl: sponsor.logoUrl ?? '',
|
||||||
websiteUrl: sponsor.websiteUrl,
|
websiteUrl: sponsor.websiteUrl ?? '',
|
||||||
tier: sponsorship.tier,
|
tier: sponsorship.tier,
|
||||||
tagline: demoSponsor?.tagline,
|
tagline: demoSponsor?.tagline ?? '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export default function LeagueRulebookPage() {
|
|||||||
|
|
||||||
await scoringUseCase.execute({ leagueId });
|
await scoringUseCase.execute({ leagueId });
|
||||||
const scoringViewModel = scoringUseCase.presenter.getViewModel();
|
const scoringViewModel = scoringUseCase.presenter.getViewModel();
|
||||||
setScoringConfig(scoringViewModel);
|
setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load scoring config:', err);
|
console.error('Failed to load scoring config:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import {
|
|||||||
getListLeagueScoringPresetsUseCase,
|
getListLeagueScoringPresetsUseCase,
|
||||||
getTransferLeagueOwnershipUseCase
|
getTransferLeagueOwnershipUseCase
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
||||||
|
import { LeagueScoringPresetsPresenter } from '@/lib/presenters/LeagueScoringPresetsPresenter';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||||
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
|
import { ScoringPatternSection, ChampionshipsSection } from '@/components/leagues/LeagueScoringSection';
|
||||||
@@ -70,13 +72,17 @@ export default function LeagueSettingsPage() {
|
|||||||
|
|
||||||
setLeague(leagueData);
|
setLeague(leagueData);
|
||||||
|
|
||||||
await useCase.execute({ leagueId });
|
const configPresenter = new LeagueFullConfigPresenter();
|
||||||
const configViewModel = useCase.presenter.getViewModel();
|
await useCase.execute({ leagueId }, configPresenter);
|
||||||
setConfigForm(configViewModel);
|
const configViewModel = configPresenter.getViewModel();
|
||||||
|
if (configViewModel) {
|
||||||
|
setConfigForm(configViewModel as LeagueConfigFormModel);
|
||||||
|
}
|
||||||
|
|
||||||
await presetsUseCase.execute();
|
const presetsPresenter = new LeagueScoringPresetsPresenter();
|
||||||
const presetsViewModel = presetsUseCase.presenter.getViewModel();
|
await presetsUseCase.execute(undefined as void, presetsPresenter);
|
||||||
setPresets(presetsViewModel);
|
const presetsViewModel = presetsPresenter.getViewModel();
|
||||||
|
setPresets(presetsViewModel.presets);
|
||||||
|
|
||||||
const entity = await driverRepo.findById(leagueData.ownerId);
|
const entity = await driverRepo.findById(leagueData.ownerId);
|
||||||
if (entity) {
|
if (entity) {
|
||||||
|
|||||||
@@ -37,8 +37,15 @@ export default function LeagueStandingsPage() {
|
|||||||
const membershipRepo = getLeagueMembershipRepository();
|
const membershipRepo = getLeagueMembershipRepository();
|
||||||
|
|
||||||
await getLeagueDriverSeasonStatsUseCase.execute({ leagueId });
|
await getLeagueDriverSeasonStatsUseCase.execute({ leagueId });
|
||||||
const standingsViewModel = getLeagueDriverSeasonStatsUseCase.presenter.getViewModel();
|
type GetLeagueDriverSeasonStatsUseCaseType = {
|
||||||
setStandings(standingsViewModel);
|
presenter: {
|
||||||
|
getViewModel(): { stats: LeagueDriverSeasonStatsDTO[] };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const typedUseCase =
|
||||||
|
getLeagueDriverSeasonStatsUseCase as GetLeagueDriverSeasonStatsUseCaseType;
|
||||||
|
const standingsViewModel = typedUseCase.presenter.getViewModel();
|
||||||
|
setStandings(standingsViewModel.stats);
|
||||||
|
|
||||||
const allDrivers = await driverRepo.findAll();
|
const allDrivers = await driverRepo.findAll();
|
||||||
const driverDtos: DriverDTO[] = allDrivers
|
const driverDtos: DriverDTO[] = allDrivers
|
||||||
@@ -48,8 +55,19 @@ export default function LeagueStandingsPage() {
|
|||||||
|
|
||||||
// Load league memberships from repository (consistent with other data)
|
// Load league memberships from repository (consistent with other data)
|
||||||
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
|
const allMemberships = await membershipRepo.getLeagueMembers(leagueId);
|
||||||
// Convert to the format expected by StandingsTable
|
|
||||||
const membershipData: LeagueMembership[] = allMemberships.map(m => ({
|
type RawMembership = {
|
||||||
|
id: string | number;
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
role: MembershipRole;
|
||||||
|
status: LeagueMembership['status'];
|
||||||
|
joinedAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert to the format expected by StandingsTable (website-level LeagueMembership)
|
||||||
|
const membershipData: LeagueMembership[] = (allMemberships as RawMembership[]).map((m) => ({
|
||||||
|
id: String(m.id),
|
||||||
leagueId: m.leagueId,
|
leagueId: m.leagueId,
|
||||||
driverId: m.driverId,
|
driverId: m.driverId,
|
||||||
role: m.role,
|
role: m.role,
|
||||||
|
|||||||
@@ -246,12 +246,15 @@ export default function ProtestReviewPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
const selectedPenalty = PENALTY_TYPES.find(p => p.type === penaltyType);
|
||||||
|
const penaltyValueToUse =
|
||||||
|
selectedPenalty && selectedPenalty.requiresValue ? penaltyValue : 0;
|
||||||
|
|
||||||
await penaltyUseCase.execute({
|
await penaltyUseCase.execute({
|
||||||
raceId: protest.raceId,
|
raceId: protest.raceId,
|
||||||
driverId: protest.accusedDriverId,
|
driverId: protest.accusedDriverId,
|
||||||
stewardId: currentDriverId,
|
stewardId: currentDriverId,
|
||||||
type: penaltyType,
|
type: penaltyType,
|
||||||
value: selectedPenalty?.requiresValue ? penaltyValue : undefined,
|
value: penaltyValueToUse,
|
||||||
reason: protest.incident.description,
|
reason: protest.incident.description,
|
||||||
protestId: protest.id,
|
protestId: protest.id,
|
||||||
notes: stewardNotes,
|
notes: stewardNotes,
|
||||||
|
|||||||
@@ -35,8 +35,8 @@ export default function CreateLeaguePage() {
|
|||||||
|
|
||||||
const handleStepChange = (stepName: StepName) => {
|
const handleStepChange = (stepName: StepName) => {
|
||||||
const params = new URLSearchParams(
|
const params = new URLSearchParams(
|
||||||
searchParams && typeof (searchParams as any).toString === 'function'
|
searchParams && typeof searchParams.toString === 'function'
|
||||||
? (searchParams as any).toString()
|
? searchParams.toString()
|
||||||
: '',
|
: '',
|
||||||
);
|
);
|
||||||
params.set('step', stepName);
|
params.set('step', stepName);
|
||||||
|
|||||||
@@ -391,8 +391,11 @@ export default function LeaguesPage() {
|
|||||||
try {
|
try {
|
||||||
const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase();
|
const useCase = getGetAllLeaguesWithCapacityAndScoringUseCase();
|
||||||
await useCase.execute();
|
await useCase.execute();
|
||||||
const viewModel = useCase.presenter.getViewModel();
|
const presenter = useCase.presenter as unknown as {
|
||||||
setRealLeagues(viewModel);
|
getViewModel(): { leagues: LeagueSummaryDTO[] };
|
||||||
|
};
|
||||||
|
const viewModel = presenter.getViewModel();
|
||||||
|
setRealLeagues(viewModel.leagues);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load leagues:', error);
|
console.error('Failed to load leagues:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -284,15 +284,14 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
// Use GetProfileOverviewUseCase to load all profile data
|
// Use GetProfileOverviewUseCase to load all profile data
|
||||||
const profileUseCase = getGetProfileOverviewUseCase();
|
const profileUseCase = getGetProfileOverviewUseCase();
|
||||||
await profileUseCase.execute({ driverId: currentDriverId });
|
const profileViewModel = await profileUseCase.execute({ driverId: currentDriverId });
|
||||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
|
||||||
|
|
||||||
if (profileViewModel && profileViewModel.currentDriver) {
|
if (profileViewModel && profileViewModel.currentDriver) {
|
||||||
// Set driver from ViewModel instead of direct repository access
|
// Set driver from ViewModel instead of direct repository access
|
||||||
const driverData: DriverDTO = {
|
const driverData: DriverDTO = {
|
||||||
id: profileViewModel.currentDriver.id,
|
id: profileViewModel.currentDriver.id,
|
||||||
name: profileViewModel.currentDriver.name,
|
name: profileViewModel.currentDriver.name,
|
||||||
iracingId: profileViewModel.currentDriver.iracingId,
|
iracingId: profileViewModel.currentDriver.iracingId ?? '',
|
||||||
country: profileViewModel.currentDriver.country,
|
country: profileViewModel.currentDriver.country,
|
||||||
bio: profileViewModel.currentDriver.bio || '',
|
bio: profileViewModel.currentDriver.bio || '',
|
||||||
joinedAt: profileViewModel.currentDriver.joinedAt,
|
joinedAt: profileViewModel.currentDriver.joinedAt,
|
||||||
@@ -335,11 +334,14 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updateProfileUseCase = getUpdateDriverProfileUseCase();
|
const updateProfileUseCase = getUpdateDriverProfileUseCase();
|
||||||
const updatedDto = await updateProfileUseCase.execute({
|
const input: { driverId: string; bio?: string; country?: string } = { driverId: driver.id };
|
||||||
driverId: driver.id,
|
if (typeof updates.bio === 'string') {
|
||||||
bio: updates.bio,
|
input.bio = updates.bio;
|
||||||
country: updates.country,
|
}
|
||||||
});
|
if (typeof updates.country === 'string') {
|
||||||
|
input.country = updates.country;
|
||||||
|
}
|
||||||
|
const updatedDto = await updateProfileUseCase.execute(input);
|
||||||
|
|
||||||
if (updatedDto) {
|
if (updatedDto) {
|
||||||
setDriver(updatedDto);
|
setDriver(updatedDto);
|
||||||
@@ -468,7 +470,9 @@ export default function ProfilePage() {
|
|||||||
<>
|
<>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-primary-blue/10 border border-primary-blue/30">
|
||||||
<Star className="w-4 h-4 text-primary-blue" />
|
<Star className="w-4 h-4 text-primary-blue" />
|
||||||
<span className="font-mono font-bold text-primary-blue">{stats.rating}</span>
|
<span className="font-mono font-bold text-primary-blue">
|
||||||
|
{stats.rating ?? 0}
|
||||||
|
</span>
|
||||||
<span className="text-xs text-gray-400">Rating</span>
|
<span className="text-xs text-gray-400">Rating</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-yellow-400/10 border border-yellow-400/30">
|
||||||
@@ -644,7 +648,7 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<CircularProgress
|
<CircularProgress
|
||||||
value={stats.consistency}
|
value={stats.consistency ?? 0}
|
||||||
max={100}
|
max={100}
|
||||||
label="Consistency"
|
label="Consistency"
|
||||||
color="text-primary-blue"
|
color="text-primary-blue"
|
||||||
@@ -684,7 +688,9 @@ export default function ProfilePage() {
|
|||||||
<Target className="w-4 h-4 text-primary-blue" />
|
<Target className="w-4 h-4 text-primary-blue" />
|
||||||
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
<span className="text-xs text-gray-500 uppercase">Avg Finish</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-primary-blue">P{stats.avgFinish.toFixed(1)}</p>
|
<p className="text-2xl font-bold text-primary-blue">
|
||||||
|
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -758,7 +764,9 @@ export default function ProfilePage() {
|
|||||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wider">Podiums</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline text-center">
|
||||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.consistency}%</div>
|
<div className="text-3xl font-bold text-primary-blue mb-1">
|
||||||
|
{stats.consistency ?? 0}%
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
<div className="text-xs text-gray-500 uppercase tracking-wider">Consistency</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -878,7 +886,11 @@ export default function ProfilePage() {
|
|||||||
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
<p className="text-white font-semibold text-sm">{achievement.title}</p>
|
||||||
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
<p className="text-gray-400 text-xs mt-0.5">{achievement.description}</p>
|
||||||
<p className="text-gray-500 text-xs mt-1">
|
<p className="text-gray-500 text-xs mt-1">
|
||||||
{new Date(achievement.earnedAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
{new Date(achievement.earnedAt).toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -887,6 +899,7 @@ export default function ProfilePage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Friends Preview */}
|
{/* Friends Preview */}
|
||||||
{socialSummary && socialSummary.friends.length > 0 && (
|
{socialSummary && socialSummary.friends.length > 0 && (
|
||||||
@@ -987,7 +1000,9 @@ export default function ProfilePage() {
|
|||||||
<Activity className="w-4 h-4 text-primary-blue" />
|
<Activity className="w-4 h-4 text-primary-blue" />
|
||||||
<span className="text-xs text-gray-500 uppercase">Consistency</span>
|
<span className="text-xs text-gray-500 uppercase">Consistency</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-primary-blue">{stats.consistency}%</p>
|
<p className="text-2xl font-bold text-primary-blue">
|
||||||
|
{stats.consistency ?? 0}%
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
<div className="p-4 rounded-xl bg-deep-graphite border border-charcoal-outline">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
@@ -1015,7 +1030,9 @@ export default function ProfilePage() {
|
|||||||
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
<div className="text-xs text-gray-400 uppercase">Best Finish</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
<div className="p-4 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||||
<div className="text-4xl font-bold text-primary-blue mb-1">P{stats.avgFinish.toFixed(1)}</div>
|
<div className="text-4xl font-bold text-primary-blue mb-1">
|
||||||
|
P{(stats.avgFinish ?? 0).toFixed(1)}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
<div className="text-xs text-gray-400 uppercase">Avg Finish</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
<div className="p-4 rounded-xl bg-gradient-to-br from-warning-amber/20 to-warning-amber/5 border border-warning-amber/30 text-center">
|
||||||
@@ -1044,7 +1061,9 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
<div className="p-6 rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/30 text-center">
|
||||||
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
|
<Star className="w-8 h-8 text-primary-blue mx-auto mb-3" />
|
||||||
<div className="text-3xl font-bold text-primary-blue mb-1">{stats.rating}</div>
|
<div className="text-3xl font-bold text-primary-blue mb-1">
|
||||||
|
{stats.rating ?? 0}
|
||||||
|
</div>
|
||||||
<div className="text-sm text-gray-400">Rating</div>
|
<div className="text-sm text-gray-400">Rating</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
|
<div className="p-6 rounded-xl bg-gradient-to-br from-purple-400/20 to-purple-600/5 border border-purple-400/30 text-center">
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
getLeagueMembershipRepository,
|
getLeagueMembershipRepository,
|
||||||
getTeamMembershipRepository,
|
getTeamMembershipRepository,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||||
import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react';
|
import { Handshake, User, Users, Trophy, ChevronRight, Building, AlertTriangle } from 'lucide-react';
|
||||||
@@ -51,12 +52,17 @@ export default function SponsorshipRequestsPage() {
|
|||||||
const allSections: EntitySection[] = [];
|
const allSections: EntitySection[] = [];
|
||||||
|
|
||||||
// 1. Driver's own sponsorship requests
|
// 1. Driver's own sponsorship requests
|
||||||
const driverResult = await query.execute({
|
const driverPresenter = new PendingSponsorshipRequestsPresenter();
|
||||||
|
await useCase.execute(
|
||||||
|
{
|
||||||
entityType: 'driver',
|
entityType: 'driver',
|
||||||
entityId: currentDriverId,
|
entityId: currentDriverId,
|
||||||
});
|
},
|
||||||
|
driverPresenter,
|
||||||
|
);
|
||||||
|
const driverResult = driverPresenter.getViewModel();
|
||||||
|
|
||||||
if (driverResult.requests.length > 0) {
|
if (driverResult && driverResult.requests.length > 0) {
|
||||||
const driver = await driverRepo.findById(currentDriverId);
|
const driver = await driverRepo.findById(currentDriverId);
|
||||||
allSections.push({
|
allSections.push({
|
||||||
entityType: 'driver',
|
entityType: 'driver',
|
||||||
@@ -74,12 +80,17 @@ export default function SponsorshipRequestsPage() {
|
|||||||
// Load sponsorship requests for this league's active season
|
// Load sponsorship requests for this league's active season
|
||||||
try {
|
try {
|
||||||
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
|
// For simplicity, we'll query by season entityType - in production you'd get the active season ID
|
||||||
const leagueResult = await query.execute({
|
const leaguePresenter = new PendingSponsorshipRequestsPresenter();
|
||||||
|
await useCase.execute(
|
||||||
|
{
|
||||||
entityType: 'season',
|
entityType: 'season',
|
||||||
entityId: league.id, // Using league ID as a proxy for now
|
entityId: league.id, // Using league ID as a proxy for now
|
||||||
});
|
},
|
||||||
|
leaguePresenter,
|
||||||
|
);
|
||||||
|
const leagueResult = leaguePresenter.getViewModel();
|
||||||
|
|
||||||
if (leagueResult.requests.length > 0) {
|
if (leagueResult && leagueResult.requests.length > 0) {
|
||||||
allSections.push({
|
allSections.push({
|
||||||
entityType: 'season',
|
entityType: 'season',
|
||||||
entityId: league.id,
|
entityId: league.id,
|
||||||
@@ -98,12 +109,17 @@ export default function SponsorshipRequestsPage() {
|
|||||||
for (const team of allTeams) {
|
for (const team of allTeams) {
|
||||||
const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId);
|
const membership = await teamMembershipRepo.getMembership(team.id, currentDriverId);
|
||||||
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
|
if (membership && (membership.role === 'owner' || membership.role === 'manager')) {
|
||||||
const teamResult = await query.execute({
|
const teamPresenter = new PendingSponsorshipRequestsPresenter();
|
||||||
|
await useCase.execute(
|
||||||
|
{
|
||||||
entityType: 'team',
|
entityType: 'team',
|
||||||
entityId: team.id,
|
entityId: team.id,
|
||||||
});
|
},
|
||||||
|
teamPresenter,
|
||||||
|
);
|
||||||
|
const teamResult = teamPresenter.getViewModel();
|
||||||
|
|
||||||
if (teamResult.requests.length > 0) {
|
if (teamResult && teamResult.requests.length > 0) {
|
||||||
allSections.push({
|
allSections.push({
|
||||||
entityType: 'team',
|
entityType: 'team',
|
||||||
entityId: team.id,
|
entityId: team.id,
|
||||||
@@ -138,11 +154,14 @@ export default function SponsorshipRequestsPage() {
|
|||||||
|
|
||||||
const handleReject = async (requestId: string, reason?: string) => {
|
const handleReject = async (requestId: string, reason?: string) => {
|
||||||
const useCase = getRejectSponsorshipRequestUseCase();
|
const useCase = getRejectSponsorshipRequestUseCase();
|
||||||
await useCase.execute({
|
const input: { requestId: string; respondedBy: string; reason?: string } = {
|
||||||
requestId,
|
requestId,
|
||||||
respondedBy: currentDriverId,
|
respondedBy: currentDriverId,
|
||||||
reason,
|
};
|
||||||
});
|
if (typeof reason === 'string') {
|
||||||
|
input.reason = reason;
|
||||||
|
}
|
||||||
|
await useCase.execute(input);
|
||||||
await loadAllRequests();
|
await loadAllRequests();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -928,7 +928,7 @@ export default function RaceDetailPage() {
|
|||||||
isOpen={showProtestModal}
|
isOpen={showProtestModal}
|
||||||
onClose={() => setShowProtestModal(false)}
|
onClose={() => setShowProtestModal(false)}
|
||||||
raceId={race.id}
|
raceId={race.id}
|
||||||
leagueId={league?.id}
|
leagueId={league ? league.id : ''}
|
||||||
protestingDriverId={currentDriverId}
|
protestingDriverId={currentDriverId}
|
||||||
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
|
participants={entryList.map(d => ({ id: d.id, name: d.name }))}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -115,13 +115,17 @@ export default function RaceResultsPage() {
|
|||||||
setPointsSystem(viewModel.pointsSystem);
|
setPointsSystem(viewModel.pointsSystem);
|
||||||
setFastestLapTime(viewModel.fastestLapTime);
|
setFastestLapTime(viewModel.fastestLapTime);
|
||||||
setCurrentDriverId(viewModel.currentDriverId);
|
setCurrentDriverId(viewModel.currentDriverId);
|
||||||
setPenalties(
|
const mappedPenalties: PenaltyData[] = viewModel.penalties.map((p) => {
|
||||||
viewModel.penalties.map((p) => ({
|
const base: PenaltyData = {
|
||||||
driverId: p.driverId,
|
driverId: p.driverId,
|
||||||
type: p.type as PenaltyTypeDTO,
|
type: p.type as PenaltyTypeDTO,
|
||||||
value: p.value,
|
};
|
||||||
})),
|
if (typeof p.value === 'number') {
|
||||||
);
|
return { ...base, value: p.value };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
});
|
||||||
|
setPenalties(mappedPenalties);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -287,9 +291,9 @@ export default function RaceResultsPage() {
|
|||||||
results={results}
|
results={results}
|
||||||
drivers={drivers}
|
drivers={drivers}
|
||||||
pointsSystem={pointsSystem}
|
pointsSystem={pointsSystem}
|
||||||
fastestLapTime={fastestLapTime}
|
fastestLapTime={fastestLapTime ?? 0}
|
||||||
penalties={penalties}
|
penalties={penalties}
|
||||||
currentDriverId={currentDriverId}
|
currentDriverId={currentDriverId ?? ''}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
|
||||||
|
import { RaceProtestsPresenter } from '@/lib/presenters/RaceProtestsPresenter';
|
||||||
|
import { RacePenaltiesPresenter } from '@/lib/presenters/RacePenaltiesPresenter';
|
||||||
import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
import type { RaceProtestViewModel } from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
||||||
import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
import type { RacePenaltyViewModel } from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
||||||
import type { League } from '@gridpilot/racing/domain/entities/League';
|
import type { League } from '@gridpilot/racing/domain/entities/League';
|
||||||
@@ -42,6 +44,8 @@ export default function RaceStewardingPage() {
|
|||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
|
const driversById: Record<string, { name?: string }> = {};
|
||||||
|
|
||||||
const [race, setRace] = useState<Race | null>(null);
|
const [race, setRace] = useState<Race | null>(null);
|
||||||
const [league, setLeague] = useState<League | null>(null);
|
const [league, setLeague] = useState<League | null>(null);
|
||||||
const [protests, setProtests] = useState<RaceProtestViewModel[]>([]);
|
const [protests, setProtests] = useState<RaceProtestViewModel[]>([]);
|
||||||
@@ -78,13 +82,15 @@ export default function RaceStewardingPage() {
|
|||||||
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await protestsUseCase.execute(raceId);
|
const protestsPresenter = new RaceProtestsPresenter();
|
||||||
const protestsViewModel = protestsUseCase.presenter.getViewModel();
|
await protestsUseCase.execute({ raceId }, protestsPresenter);
|
||||||
setProtests(protestsViewModel.protests);
|
const protestsViewModel = protestsPresenter.getViewModel();
|
||||||
|
setProtests(protestsViewModel?.protests ?? []);
|
||||||
|
|
||||||
await penaltiesUseCase.execute(raceId);
|
const penaltiesPresenter = new RacePenaltiesPresenter();
|
||||||
const penaltiesViewModel = penaltiesUseCase.presenter.getViewModel();
|
await penaltiesUseCase.execute({ raceId }, penaltiesPresenter);
|
||||||
setPenalties(penaltiesViewModel.penalties);
|
const penaltiesViewModel = penaltiesPresenter.getViewModel();
|
||||||
|
setPenalties(penaltiesViewModel?.penalties ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load data:', err);
|
console.error('Failed to load data:', err);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -105,8 +105,9 @@ export default function AllRacesPage() {
|
|||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
}, [statusFilter, leagueFilter, searchQuery]);
|
}, [statusFilter, leagueFilter, searchQuery]);
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -114,8 +115,9 @@ export default function AllRacesPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
const formatTime = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleTimeString('en-US', {
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -93,8 +93,11 @@ export default function RacesPage() {
|
|||||||
// Group races by date for calendar view
|
// Group races by date for calendar view
|
||||||
const racesByDate = useMemo(() => {
|
const racesByDate = useMemo(() => {
|
||||||
const grouped = new Map<string, RaceListItemViewModel[]>();
|
const grouped = new Map<string, RaceListItemViewModel[]>();
|
||||||
filteredRaces.forEach(race => {
|
filteredRaces.forEach((race) => {
|
||||||
const dateKey = new Date(race.scheduledAt).toISOString().split('T')[0];
|
if (typeof race.scheduledAt !== 'string') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const dateKey = race.scheduledAt.split('T')[0]!;
|
||||||
if (!grouped.has(dateKey)) {
|
if (!grouped.has(dateKey)) {
|
||||||
grouped.set(dateKey, []);
|
grouped.set(dateKey, []);
|
||||||
}
|
}
|
||||||
@@ -108,23 +111,26 @@ export default function RacesPage() {
|
|||||||
const recentResults = pageData?.recentResults ?? [];
|
const recentResults = pageData?.recentResults ?? [];
|
||||||
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
|
const stats = pageData?.stats ?? { total: 0, scheduled: 0, running: 0, completed: 0 };
|
||||||
|
|
||||||
const formatDate = (date: Date) => {
|
const formatDate = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatTime = (date: Date) => {
|
const formatTime = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleTimeString('en-US', {
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleTimeString('en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatFullDate = (date: Date) => {
|
const formatFullDate = (date: Date | string) => {
|
||||||
return new Date(date).toLocaleDateString('en-US', {
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
weekday: 'long',
|
weekday: 'long',
|
||||||
month: 'long',
|
month: 'long',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
@@ -132,9 +138,10 @@ export default function RacesPage() {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const getRelativeTime = (date: Date) => {
|
const getRelativeTime = (date?: Date | string) => {
|
||||||
|
if (!date) return '';
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const targetDate = new Date(date);
|
const targetDate = typeof date === 'string' ? new Date(date) : date;
|
||||||
const diffMs = targetDate.getTime() - now.getTime();
|
const diffMs = targetDate.getTime() - now.getTime();
|
||||||
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
|
||||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||||
@@ -144,7 +151,7 @@ export default function RacesPage() {
|
|||||||
if (diffHours < 24) return `In ${diffHours}h`;
|
if (diffHours < 24) return `In ${diffHours}h`;
|
||||||
if (diffDays === 1) return 'Tomorrow';
|
if (diffDays === 1) return 'Tomorrow';
|
||||||
if (diffDays < 7) return `In ${diffDays} days`;
|
if (diffDays < 7) return `In ${diffDays} days`;
|
||||||
return formatDate(date);
|
return formatDate(targetDate);
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusConfig = {
|
const statusConfig = {
|
||||||
@@ -368,6 +375,9 @@ export default function RacesPage() {
|
|||||||
{/* Races for this date */}
|
{/* Races for this date */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{dayRaces.map((race) => {
|
{dayRaces.map((race) => {
|
||||||
|
if (!race.scheduledAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const config = statusConfig[race.status];
|
const config = statusConfig[race.status];
|
||||||
const StatusIcon = config.icon;
|
const StatusIcon = config.icon;
|
||||||
|
|
||||||
@@ -385,9 +395,13 @@ export default function RacesPage() {
|
|||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
{/* Time Column */}
|
{/* Time Column */}
|
||||||
<div className="flex-shrink-0 text-center min-w-[60px]">
|
<div className="flex-shrink-0 text-center min-w-[60px]">
|
||||||
<p className="text-lg font-bold text-white">{formatTime(new Date(race.scheduledAt))}</p>
|
<p className="text-lg font-bold text-white">
|
||||||
|
{formatTime(race.scheduledAt)}
|
||||||
|
</p>
|
||||||
<p className={`text-xs ${config.color}`}>
|
<p className={`text-xs ${config.color}`}>
|
||||||
{race.status === 'running' ? 'LIVE' : getRelativeTime(new Date(race.scheduledAt))}
|
{race.status === 'running'
|
||||||
|
? 'LIVE'
|
||||||
|
: getRelativeTime(race.scheduledAt)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -427,7 +441,7 @@ export default function RacesPage() {
|
|||||||
{/* League Link */}
|
{/* League Link */}
|
||||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||||
<Link
|
<Link
|
||||||
href={`/leagues/${race.leagueId}`}
|
href={`/leagues/${race.leagueId ?? ''}`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
className="inline-flex items-center gap-2 text-sm text-primary-blue hover:underline"
|
||||||
>
|
>
|
||||||
@@ -482,7 +496,12 @@ export default function RacesPage() {
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{upcomingRaces.map((race) => (
|
{upcomingRaces.map((race) => {
|
||||||
|
if (!race.scheduledAt) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const scheduledAtDate = new Date(race.scheduledAt);
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={race.id}
|
key={race.id}
|
||||||
onClick={() => router.push(`/races/${race.id}`)}
|
onClick={() => router.push(`/races/${race.id}`)}
|
||||||
@@ -490,16 +509,17 @@ export default function RacesPage() {
|
|||||||
>
|
>
|
||||||
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
<div className="flex-shrink-0 w-10 h-10 bg-primary-blue/10 rounded-lg flex items-center justify-center">
|
||||||
<span className="text-sm font-bold text-primary-blue">
|
<span className="text-sm font-bold text-primary-blue">
|
||||||
{new Date(race.scheduledAt).getDate()}
|
{scheduledAtDate.getDate()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
<p className="text-sm font-medium text-white truncate">{race.track}</p>
|
||||||
<p className="text-xs text-gray-500">{formatTime(new Date(race.scheduledAt))}</p>
|
<p className="text-xs text-gray-500">{formatTime(scheduledAtDate)}</p>
|
||||||
</div>
|
</div>
|
||||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
import Breadcrumbs from '@/components/layout/Breadcrumbs';
|
||||||
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
||||||
import { getImageService } from '@/lib/di-container';
|
import { getImageService } from '@/lib/di-container';
|
||||||
|
import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter';
|
||||||
import TeamRoster from '@/components/teams/TeamRoster';
|
import TeamRoster from '@/components/teams/TeamRoster';
|
||||||
import TeamStandings from '@/components/teams/TeamStandings';
|
import TeamStandings from '@/components/teams/TeamStandings';
|
||||||
import TeamAdmin from '@/components/teams/TeamAdmin';
|
import TeamAdmin from '@/components/teams/TeamAdmin';
|
||||||
@@ -19,9 +20,17 @@ import {
|
|||||||
getTeamMembershipRepository,
|
getTeamMembershipRepository,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import type { Team, TeamMembership, TeamRole } from '@gridpilot/racing';
|
import type { Team } from '@gridpilot/racing';
|
||||||
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
|
import { Users, Trophy, TrendingUp, Star, Zap } from 'lucide-react';
|
||||||
|
|
||||||
|
type TeamRole = 'owner' | 'manager' | 'driver';
|
||||||
|
|
||||||
|
interface TeamMembership {
|
||||||
|
driverId: string;
|
||||||
|
role: TeamRole;
|
||||||
|
joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||||
|
|
||||||
export default function TeamDetailPage() {
|
export default function TeamDetailPage() {
|
||||||
@@ -42,16 +51,32 @@ export default function TeamDetailPage() {
|
|||||||
const detailsUseCase = getGetTeamDetailsUseCase();
|
const detailsUseCase = getGetTeamDetailsUseCase();
|
||||||
const membersUseCase = getGetTeamMembersUseCase();
|
const membersUseCase = getGetTeamMembersUseCase();
|
||||||
|
|
||||||
await detailsUseCase.execute({ teamId, driverId: currentDriverId });
|
await detailsUseCase.execute(teamId, currentDriverId);
|
||||||
const detailsViewModel = detailsUseCase.presenter.getViewModel();
|
const detailsPresenter = detailsUseCase.presenter;
|
||||||
|
const detailsViewModel = detailsPresenter
|
||||||
|
? (detailsPresenter as any).getViewModel?.() as { team: Team } | null
|
||||||
|
: null;
|
||||||
|
|
||||||
await membersUseCase.execute({ teamId });
|
if (!detailsViewModel) {
|
||||||
const membersViewModel = membersUseCase.presenter.getViewModel();
|
setTeam(null);
|
||||||
const teamMemberships = membersViewModel.members;
|
setMemberships([]);
|
||||||
|
setIsAdmin(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const teamMembersPresenter = new TeamMembersPresenter();
|
||||||
|
await membersUseCase.execute({ teamId }, teamMembersPresenter);
|
||||||
|
const membersViewModel = teamMembersPresenter.getViewModel();
|
||||||
|
|
||||||
|
const teamMemberships: TeamMembership[] = (membersViewModel?.members ?? []).map((m) => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
role: m.role as TeamRole,
|
||||||
|
joinedAt: new Date(m.joinedAt),
|
||||||
|
}));
|
||||||
|
|
||||||
const adminStatus =
|
const adminStatus =
|
||||||
teamMemberships.some(
|
teamMemberships.some(
|
||||||
(m) =>
|
(m: TeamMembership) =>
|
||||||
m.driverId === currentDriverId &&
|
m.driverId === currentDriverId &&
|
||||||
(m.role === 'owner' || m.role === 'manager'),
|
(m.role === 'owner' || m.role === 'manager'),
|
||||||
) ?? false;
|
) ?? false;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import Button from '@/components/ui/Button';
|
|||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||||
|
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||||
import type {
|
import type {
|
||||||
TeamLeaderboardItemViewModel,
|
TeamLeaderboardItemViewModel,
|
||||||
SkillLevel,
|
SkillLevel,
|
||||||
@@ -36,6 +37,23 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
|||||||
|
|
||||||
type TeamDisplayData = TeamLeaderboardItemViewModel;
|
type TeamDisplayData = TeamLeaderboardItemViewModel;
|
||||||
|
|
||||||
|
const getSafeRating = (team: TeamDisplayData): number => {
|
||||||
|
const value = typeof team.rating === 'number' ? team.rating : 0;
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeTotalWins = (team: TeamDisplayData): number => {
|
||||||
|
const raw = team.totalWins;
|
||||||
|
const value = typeof raw === 'number' ? raw : 0;
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSafeTotalRaces = (team: TeamDisplayData): number => {
|
||||||
|
const raw = team.totalRaces;
|
||||||
|
const value = typeof raw === 'number' ? raw : 0;
|
||||||
|
return Number.isFinite(value) ? value : 0;
|
||||||
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SKILL LEVEL CONFIG
|
// SKILL LEVEL CONFIG
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -103,11 +121,15 @@ interface TopThreePodiumProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
||||||
const top3 = teams.slice(0, 3);
|
const top3 = teams.slice(0, 3) as [TeamDisplayData, TeamDisplayData, TeamDisplayData];
|
||||||
if (top3.length < 3) return null;
|
if (teams.length < 3) return null;
|
||||||
|
|
||||||
// Display order: 2nd, 1st, 3rd
|
// Display order: 2nd, 1st, 3rd
|
||||||
const podiumOrder = [top3[1], top3[0], top3[2]];
|
const podiumOrder: [TeamDisplayData, TeamDisplayData, TeamDisplayData] = [
|
||||||
|
top3[1],
|
||||||
|
top3[0],
|
||||||
|
top3[2],
|
||||||
|
];
|
||||||
const podiumHeights = ['h-28', 'h-36', 'h-20'];
|
const podiumHeights = ['h-28', 'h-36', 'h-20'];
|
||||||
const podiumPositions = [2, 1, 3];
|
const podiumPositions = [2, 1, 3];
|
||||||
|
|
||||||
@@ -159,7 +181,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
|||||||
|
|
||||||
<div className="flex items-end justify-center gap-4 md:gap-8">
|
<div className="flex items-end justify-center gap-4 md:gap-8">
|
||||||
{podiumOrder.map((team, index) => {
|
{podiumOrder.map((team, index) => {
|
||||||
const position = podiumPositions[index];
|
const position = podiumPositions[index] ?? 0;
|
||||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||||
const LevelIcon = levelConfig?.icon || Shield;
|
const LevelIcon = levelConfig?.icon || Shield;
|
||||||
|
|
||||||
@@ -172,7 +194,7 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
|||||||
>
|
>
|
||||||
{/* Team card */}
|
{/* Team card */}
|
||||||
<div
|
<div
|
||||||
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position)} border ${getBorderColor(position)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
className={`relative mb-4 p-4 rounded-xl bg-gradient-to-br ${getGradient(position ?? 0)} border ${getBorderColor(position ?? 0)} transition-all group-hover:scale-105 group-hover:shadow-lg`}
|
||||||
>
|
>
|
||||||
{/* Crown for 1st place */}
|
{/* Crown for 1st place */}
|
||||||
{position === 1 && (
|
{position === 1 && (
|
||||||
@@ -198,14 +220,14 @@ function TopThreePodium({ teams, onTeamClick }: TopThreePodiumProps) {
|
|||||||
|
|
||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
<p className={`text-lg md:text-xl font-mono font-bold ${getPositionColor(position)} text-center`}>
|
||||||
{team.rating?.toLocaleString() ?? '—'}
|
{getSafeRating(team).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
|
<div className="flex items-center justify-center gap-3 mt-2 text-xs text-gray-400">
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Trophy className="w-3 h-3 text-performance-green" />
|
<Trophy className="w-3 h-3 text-performance-green" />
|
||||||
{team.totalWins}
|
{getSafeTotalWins(team)}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-1">
|
<span className="flex items-center gap-1">
|
||||||
<Users className="w-3 h-3 text-purple-400" />
|
<Users className="w-3 h-3 text-purple-400" />
|
||||||
@@ -246,9 +268,14 @@ export default function TeamLeaderboardPage() {
|
|||||||
const loadTeams = async () => {
|
const loadTeams = async () => {
|
||||||
try {
|
try {
|
||||||
const useCase = getGetTeamsLeaderboardUseCase();
|
const useCase = getGetTeamsLeaderboardUseCase();
|
||||||
await useCase.execute();
|
const presenter = new TeamsLeaderboardPresenter();
|
||||||
const viewModel = useCase.presenter.getViewModel();
|
|
||||||
|
await useCase.execute(undefined as void, presenter);
|
||||||
|
|
||||||
|
const viewModel = presenter.getViewModel();
|
||||||
|
if (viewModel) {
|
||||||
setTeams(viewModel.teams);
|
setTeams(viewModel.teams);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load teams:', error);
|
console.error('Failed to load teams:', error);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -286,17 +313,30 @@ export default function TeamLeaderboardPage() {
|
|||||||
})
|
})
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
case 'rating':
|
case 'rating': {
|
||||||
return (b.rating ?? 0) - (a.rating ?? 0);
|
const aRating = getSafeRating(a);
|
||||||
case 'wins':
|
const bRating = getSafeRating(b);
|
||||||
return b.totalWins - a.totalWins;
|
return bRating - aRating;
|
||||||
|
}
|
||||||
|
case 'wins': {
|
||||||
|
const aWinsSort = getSafeTotalWins(a);
|
||||||
|
const bWinsSort = getSafeTotalWins(b);
|
||||||
|
return bWinsSort - aWinsSort;
|
||||||
|
}
|
||||||
case 'winRate': {
|
case 'winRate': {
|
||||||
const aRate = a.totalRaces > 0 ? a.totalWins / a.totalRaces : 0;
|
const aRaces = getSafeTotalRaces(a);
|
||||||
const bRate = b.totalRaces > 0 ? b.totalWins / b.totalRaces : 0;
|
const bRaces = getSafeTotalRaces(b);
|
||||||
|
const aWins = getSafeTotalWins(a);
|
||||||
|
const bWins = getSafeTotalWins(b);
|
||||||
|
const aRate = aRaces > 0 ? aWins / aRaces : 0;
|
||||||
|
const bRate = bRaces > 0 ? bWins / bRaces : 0;
|
||||||
return bRate - aRate;
|
return bRate - aRate;
|
||||||
}
|
}
|
||||||
case 'races':
|
case 'races': {
|
||||||
return b.totalRaces - a.totalRaces;
|
const aRacesSort = getSafeTotalRaces(a);
|
||||||
|
const bRacesSort = getSafeTotalRaces(b);
|
||||||
|
return bRacesSort - aRacesSort;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -468,7 +508,10 @@ export default function TeamLeaderboardPage() {
|
|||||||
<span className="text-xs text-gray-500">Total Wins</span>
|
<span className="text-xs text-gray-500">Total Wins</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalWins, 0)}
|
{filteredAndSortedTeams.reduce<number>(
|
||||||
|
(sum, t) => sum + getSafeTotalWins(t),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
<div className="p-4 rounded-xl bg-iron-gray/30 border border-charcoal-outline">
|
||||||
@@ -477,7 +520,10 @@ export default function TeamLeaderboardPage() {
|
|||||||
<span className="text-xs text-gray-500">Total Races</span>
|
<span className="text-xs text-gray-500">Total Races</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-2xl font-bold text-white">
|
<p className="text-2xl font-bold text-white">
|
||||||
{filteredAndSortedTeams.reduce((sum, t) => sum + t.totalRaces, 0)}
|
{filteredAndSortedTeams.reduce<number>(
|
||||||
|
(sum, t) => sum + getSafeTotalRaces(t),
|
||||||
|
0,
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -499,7 +545,10 @@ export default function TeamLeaderboardPage() {
|
|||||||
{filteredAndSortedTeams.map((team, index) => {
|
{filteredAndSortedTeams.map((team, index) => {
|
||||||
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
const levelConfig = SKILL_LEVELS.find((l) => l.id === team.performanceLevel);
|
||||||
const LevelIcon = levelConfig?.icon || Shield;
|
const LevelIcon = levelConfig?.icon || Shield;
|
||||||
const winRate = team.totalRaces > 0 ? ((team.totalWins / team.totalRaces) * 100).toFixed(1) : '0.0';
|
const totalRaces = getSafeTotalRaces(team);
|
||||||
|
const totalWins = getSafeTotalWins(team);
|
||||||
|
const winRate =
|
||||||
|
totalRaces > 0 ? ((totalWins / totalRaces) * 100).toFixed(1) : '0.0';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
@@ -565,15 +614,19 @@ export default function TeamLeaderboardPage() {
|
|||||||
|
|
||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||||
<span className={`font-mono font-semibold ${sortBy === 'rating' ? 'text-purple-400' : 'text-white'}`}>
|
<span
|
||||||
{team.rating?.toLocaleString() ?? '—'}
|
className={`font-mono font-semibold ${
|
||||||
|
sortBy === 'rating' ? 'text-purple-400' : 'text-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{getSafeRating(team).toLocaleString()}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Wins */}
|
{/* Wins */}
|
||||||
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
<div className="col-span-2 lg:col-span-1 flex items-center justify-center">
|
||||||
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
|
<span className={`font-mono font-semibold ${sortBy === 'wins' ? 'text-purple-400' : 'text-white'}`}>
|
||||||
{team.totalWins}
|
{getSafeTotalWins(team)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import Input from '@/components/ui/Input';
|
|||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
import CreateTeamForm from '@/components/teams/CreateTeamForm';
|
||||||
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
import { getGetTeamsLeaderboardUseCase } from '@/lib/di-container';
|
||||||
|
import { TeamsLeaderboardPresenter } from '@/lib/presenters/TeamsLeaderboardPresenter';
|
||||||
import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
import type { TeamLeaderboardItemViewModel, SkillLevel } from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -204,7 +205,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
|
|||||||
key={team.id}
|
key={team.id}
|
||||||
id={team.id}
|
id={team.id}
|
||||||
name={team.name}
|
name={team.name}
|
||||||
description={team.description}
|
description={team.description ?? ''}
|
||||||
memberCount={team.memberCount}
|
memberCount={team.memberCount}
|
||||||
rating={team.rating}
|
rating={team.rating}
|
||||||
totalWins={team.totalWins}
|
totalWins={team.totalWins}
|
||||||
@@ -212,7 +213,7 @@ function SkillLevelSection({ level, teams, onTeamClick, defaultExpanded = false
|
|||||||
performanceLevel={team.performanceLevel}
|
performanceLevel={team.performanceLevel}
|
||||||
isRecruiting={team.isRecruiting}
|
isRecruiting={team.isRecruiting}
|
||||||
specialization={team.specialization}
|
specialization={team.specialization}
|
||||||
region={team.region}
|
region={team.region ?? ''}
|
||||||
languages={team.languages}
|
languages={team.languages}
|
||||||
onClick={() => onTeamClick(team.id)}
|
onClick={() => onTeamClick(team.id)}
|
||||||
/>
|
/>
|
||||||
@@ -449,11 +450,16 @@ export default function TeamsPage() {
|
|||||||
const loadTeams = async () => {
|
const loadTeams = async () => {
|
||||||
try {
|
try {
|
||||||
const useCase = getGetTeamsLeaderboardUseCase();
|
const useCase = getGetTeamsLeaderboardUseCase();
|
||||||
await useCase.execute();
|
const presenter = new TeamsLeaderboardPresenter();
|
||||||
const viewModel = useCase.presenter.getViewModel();
|
|
||||||
|
await useCase.execute(undefined as void, presenter);
|
||||||
|
|
||||||
|
const viewModel = presenter.getViewModel();
|
||||||
|
if (viewModel) {
|
||||||
setRealTeams(viewModel.teams);
|
setRealTeams(viewModel.teams);
|
||||||
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
|
setGroupsBySkillLevel(viewModel.groupsBySkillLevel);
|
||||||
setTopTeams(viewModel.topTeams);
|
setTopTeams(viewModel.topTeams);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load teams:', error);
|
console.error('Failed to load teams:', error);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -179,13 +179,18 @@ export default function DevToolbar() {
|
|||||||
leagueRepository.findAll(),
|
leagueRepository.findAll(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const completedRaces = allRaces.filter((race: any) => race.status === 'completed');
|
const completedRaces = allRaces.filter((race) => race.status === 'completed');
|
||||||
const scheduledRaces = allRaces.filter((race: any) => race.status === 'scheduled');
|
const scheduledRaces = allRaces.filter((race) => race.status === 'scheduled');
|
||||||
|
|
||||||
const primaryRace = completedRaces[0] ?? allRaces[0];
|
const primaryRace = completedRaces[0] ?? allRaces[0];
|
||||||
const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace;
|
const secondaryRace = scheduledRaces[0] ?? allRaces[1] ?? primaryRace;
|
||||||
const primaryLeague = allLeagues[0];
|
const primaryLeague = allLeagues[0];
|
||||||
|
|
||||||
|
const notificationDeadline =
|
||||||
|
selectedUrgency === 'modal'
|
||||||
|
? new Date(Date.now() + 48 * 60 * 60 * 1000)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
let title: string;
|
let title: string;
|
||||||
let body: string;
|
let body: string;
|
||||||
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
|
let notificationType: 'protest_filed' | 'protest_defense_requested' | 'protest_vote_required';
|
||||||
@@ -227,7 +232,7 @@ export default function DevToolbar() {
|
|||||||
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
{ label: 'View Protest', type: 'primary' as const, href: actionUrl, actionId: 'view' },
|
||||||
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
{ label: 'Dismiss', type: 'secondary' as const, actionId: 'dismiss' },
|
||||||
]
|
]
|
||||||
: undefined;
|
: [];
|
||||||
|
|
||||||
await sendNotification.execute({
|
await sendNotification.execute({
|
||||||
recipientId: currentDriverId,
|
recipientId: currentDriverId,
|
||||||
@@ -240,12 +245,9 @@ export default function DevToolbar() {
|
|||||||
actions,
|
actions,
|
||||||
data: {
|
data: {
|
||||||
protestId: `demo-protest-${Date.now()}`,
|
protestId: `demo-protest-${Date.now()}`,
|
||||||
raceId: primaryRace?.id,
|
raceId: primaryRace?.id ?? '',
|
||||||
leagueId: primaryLeague?.id,
|
leagueId: primaryLeague?.id ?? '',
|
||||||
deadline:
|
...(notificationDeadline ? { deadline: notificationDeadline } : {}),
|
||||||
selectedUrgency === 'modal'
|
|
||||||
? new Date(Date.now() + 48 * 60 * 60 * 1000)
|
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -70,13 +70,14 @@ export default function CreateDriverForm() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const driverRepo = getDriverRepository();
|
const driverRepo = getDriverRepository();
|
||||||
|
const bio = formData.bio.trim();
|
||||||
|
|
||||||
const driver = Driver.create({
|
const driver = Driver.create({
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
iracingId: formData.iracingId.trim(),
|
iracingId: formData.iracingId.trim(),
|
||||||
name: formData.name.trim(),
|
name: formData.name.trim(),
|
||||||
country: formData.country.trim().toUpperCase(),
|
country: formData.country.trim().toUpperCase(),
|
||||||
bio: formData.bio.trim() || undefined,
|
...(bio ? { bio } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await driverRepo.create(driver);
|
await driverRepo.create(driver);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ export default function DriverCard(props: DriverCardProps) {
|
|||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
className="hover:border-charcoal-outline/60 transition-colors cursor-pointer"
|
||||||
onClick={onClick}
|
{...(onClick ? { onClick } : {})}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
|
|||||||
@@ -9,8 +9,10 @@ import DriverRankings from './DriverRankings';
|
|||||||
import PerformanceMetrics from './PerformanceMetrics';
|
import PerformanceMetrics from './PerformanceMetrics';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
import { getLeagueRankings, getGetDriverTeamUseCase, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||||
|
import { DriverTeamPresenter } from '@/lib/presenters/DriverTeamPresenter';
|
||||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||||
import type { GetDriverTeamQueryResultDTO } from '@gridpilot/racing/application/dto/TeamCommandAndQueryDTO';
|
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||||
|
import type { DriverTeamViewModel } from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||||
|
|
||||||
interface DriverProfileProps {
|
interface DriverProfileProps {
|
||||||
driver: DriverDTO;
|
driver: DriverDTO;
|
||||||
@@ -18,23 +20,39 @@ interface DriverProfileProps {
|
|||||||
onEditClick?: () => void;
|
onEditClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface DriverProfileStatsViewModel {
|
||||||
|
rating: number;
|
||||||
|
wins: number;
|
||||||
|
podiums: number;
|
||||||
|
dnfs: number;
|
||||||
|
totalRaces: number;
|
||||||
|
avgFinish: number;
|
||||||
|
bestFinish: number;
|
||||||
|
worstFinish: number;
|
||||||
|
consistency: number;
|
||||||
|
percentile: number;
|
||||||
|
overallRank?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
|
||||||
|
|
||||||
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
|
||||||
const [profileData, setProfileData] = useState<any>(null);
|
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
||||||
const [teamData, setTeamData] = useState<GetDriverTeamQueryResultDTO | null>(null);
|
const [teamData, setTeamData] = useState<DriverTeamViewModel | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
// Load profile data using GetProfileOverviewUseCase
|
// Load profile data using GetProfileOverviewUseCase
|
||||||
const profileUseCase = getGetProfileOverviewUseCase();
|
const profileUseCase = getGetProfileOverviewUseCase();
|
||||||
await profileUseCase.execute({ driverId: driver.id });
|
const profileViewModel = await profileUseCase.execute({ driverId: driver.id });
|
||||||
const profileViewModel = profileUseCase.presenter.getViewModel();
|
|
||||||
setProfileData(profileViewModel);
|
setProfileData(profileViewModel);
|
||||||
|
|
||||||
// Load team data
|
// Load team data using caller-owned presenter
|
||||||
const teamUseCase = getGetDriverTeamUseCase();
|
const teamUseCase = getGetDriverTeamUseCase();
|
||||||
await teamUseCase.execute({ driverId: driver.id });
|
const driverTeamPresenter = new DriverTeamPresenter();
|
||||||
const teamViewModel = teamUseCase.presenter.getViewModel();
|
await teamUseCase.execute({ driverId: driver.id }, driverTeamPresenter);
|
||||||
setTeamData(teamViewModel.result);
|
const teamResult = driverTeamPresenter.getViewModel();
|
||||||
|
setTeamData(teamResult ?? null);
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
}, [driver.id]);
|
}, [driver.id]);
|
||||||
@@ -44,27 +62,27 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
|||||||
const leagueRank = primaryLeagueId
|
const leagueRank = primaryLeagueId
|
||||||
? getLeagueRankings(driver.id, primaryLeagueId)
|
? getLeagueRankings(driver.id, primaryLeagueId)
|
||||||
: { rank: 0, totalDrivers: 0, percentile: 0 };
|
: { rank: 0, totalDrivers: 0, percentile: 0 };
|
||||||
const globalRank = profileData?.currentDriver?.globalRank || null;
|
const globalRank = profileData?.currentDriver?.globalRank ?? null;
|
||||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||||
|
|
||||||
const performanceStats = driverStats ? {
|
const performanceStats = driverStats ? {
|
||||||
winRate: (driverStats.wins / driverStats.totalRaces) * 100,
|
winRate: driverStats.totalRaces > 0 ? (driverStats.wins / driverStats.totalRaces) * 100 : 0,
|
||||||
podiumRate: (driverStats.podiums / driverStats.totalRaces) * 100,
|
podiumRate: driverStats.totalRaces > 0 ? (driverStats.podiums / driverStats.totalRaces) * 100 : 0,
|
||||||
dnfRate: (driverStats.dnfs / driverStats.totalRaces) * 100,
|
dnfRate: driverStats.totalRaces > 0 ? (driverStats.dnfs / driverStats.totalRaces) * 100 : 0,
|
||||||
avgFinish: driverStats.avgFinish,
|
avgFinish: driverStats.avgFinish ?? 0,
|
||||||
consistency: driverStats.consistency,
|
consistency: driverStats.consistency ?? 0,
|
||||||
bestFinish: driverStats.bestFinish,
|
bestFinish: driverStats.bestFinish ?? 0,
|
||||||
worstFinish: driverStats.worstFinish,
|
worstFinish: driverStats.worstFinish ?? 0,
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
const rankings = driverStats ? [
|
const rankings = driverStats ? [
|
||||||
{
|
{
|
||||||
type: 'overall' as const,
|
type: 'overall' as const,
|
||||||
name: 'Overall Ranking',
|
name: 'Overall Ranking',
|
||||||
rank: globalRank || driverStats.overallRank || 0,
|
rank: globalRank ?? driverStats.overallRank ?? 0,
|
||||||
totalDrivers: totalDrivers,
|
totalDrivers,
|
||||||
percentile: driverStats.percentile,
|
percentile: driverStats.percentile ?? 0,
|
||||||
rating: driverStats.rating,
|
rating: driverStats.rating ?? 0,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'league' as const,
|
type: 'league' as const,
|
||||||
@@ -72,7 +90,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
|||||||
rank: leagueRank.rank,
|
rank: leagueRank.rank,
|
||||||
totalDrivers: leagueRank.totalDrivers,
|
totalDrivers: leagueRank.totalDrivers,
|
||||||
percentile: leagueRank.percentile,
|
percentile: leagueRank.percentile,
|
||||||
rating: driverStats.rating,
|
rating: driverStats.rating ?? 0,
|
||||||
},
|
},
|
||||||
] : [];
|
] : [];
|
||||||
|
|
||||||
@@ -84,7 +102,7 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
|||||||
rating={driverStats?.rating ?? null}
|
rating={driverStats?.rating ?? null}
|
||||||
rank={driverStats?.overallRank ?? null}
|
rank={driverStats?.overallRank ?? null}
|
||||||
isOwnProfile={isOwnProfile}
|
isOwnProfile={isOwnProfile}
|
||||||
onEditClick={isOwnProfile ? onEditClick : undefined}
|
onEditClick={onEditClick ?? (() => {})}
|
||||||
teamName={teamData?.team.name ?? null}
|
teamName={teamData?.team.name ?? null}
|
||||||
teamTag={teamData?.team.tag ?? null}
|
teamTag={teamData?.team.tag ?? null}
|
||||||
/>
|
/>
|
||||||
@@ -103,7 +121,11 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
|||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
|
<h3 className="text-lg font-semibold text-white mb-4">Career Statistics</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<StatCard label="Rating" value={driverStats.rating.toString()} color="text-primary-blue" />
|
<StatCard
|
||||||
|
label="Rating"
|
||||||
|
value={(driverStats.rating ?? 0).toString()}
|
||||||
|
color="text-primary-blue"
|
||||||
|
/>
|
||||||
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
|
<StatCard label="Total Races" value={driverStats.totalRaces.toString()} color="text-white" />
|
||||||
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
|
<StatCard label="Wins" value={driverStats.wins.toString()} color="text-green-400" />
|
||||||
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
|
<StatCard label="Podiums" value={driverStats.podiums.toString()} color="text-warning-amber" />
|
||||||
@@ -130,14 +152,21 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
|
<h3 className="text-lg font-semibold text-white mb-4">Performance by Class</h3>
|
||||||
<ProfileStats stats={driverStats ? {
|
{driverStats && (
|
||||||
|
<ProfileStats
|
||||||
|
stats={{
|
||||||
totalRaces: driverStats.totalRaces,
|
totalRaces: driverStats.totalRaces,
|
||||||
wins: driverStats.wins,
|
wins: driverStats.wins,
|
||||||
podiums: driverStats.podiums,
|
podiums: driverStats.podiums,
|
||||||
dnfs: driverStats.dnfs,
|
dnfs: driverStats.dnfs,
|
||||||
avgFinish: driverStats.avgFinish,
|
avgFinish: driverStats.avgFinish ?? 0,
|
||||||
completionRate: ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
completionRate:
|
||||||
} : undefined} />
|
driverStats.totalRaces > 0
|
||||||
|
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||||
|
: 0,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<CareerHighlights />
|
<CareerHighlights />
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
|||||||
<Card>
|
<Card>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{paginatedResults.map(({ race, result, league }) => {
|
{paginatedResults.map(({ race, result, league }) => {
|
||||||
if (!result) return null;
|
if (!result || !league) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceResultCard
|
<RaceResultCard
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import RankBadge from './RankBadge';
|
|||||||
import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
import { getLeagueRankings, getGetProfileOverviewUseCase } from '@/lib/di-container';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
||||||
|
import type { ProfileOverviewViewModel } from '@gridpilot/racing/application/presenters/IProfileOverviewPresenter';
|
||||||
|
|
||||||
interface ProfileStatsProps {
|
interface ProfileStatsProps {
|
||||||
driverId?: string;
|
driverId?: string;
|
||||||
@@ -18,15 +19,16 @@ interface ProfileStatsProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DriverProfileOverviewViewModel = ProfileOverviewViewModel | null;
|
||||||
|
|
||||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||||
const [profileData, setProfileData] = useState<any>(null);
|
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (driverId) {
|
if (driverId) {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const profileUseCase = getGetProfileOverviewUseCase();
|
const profileUseCase = getGetProfileOverviewUseCase();
|
||||||
await profileUseCase.execute({ driverId });
|
const vm = await profileUseCase.execute({ driverId });
|
||||||
const vm = profileUseCase.presenter.getViewModel();
|
|
||||||
setProfileData(vm);
|
setProfileData(vm);
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
@@ -34,21 +36,24 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
|||||||
}, [driverId]);
|
}, [driverId]);
|
||||||
|
|
||||||
const driverStats = profileData?.stats || null;
|
const driverStats = profileData?.stats || null;
|
||||||
const totalDrivers = profileData?.currentDriver?.totalDrivers || 0;
|
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||||
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
||||||
const leagueRank =
|
const leagueRank =
|
||||||
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
|
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
|
||||||
|
|
||||||
const defaultStats = stats || (driverStats
|
const defaultStats =
|
||||||
|
stats ||
|
||||||
|
(driverStats
|
||||||
? {
|
? {
|
||||||
totalRaces: driverStats.totalRaces,
|
totalRaces: driverStats.totalRaces,
|
||||||
wins: driverStats.wins,
|
wins: driverStats.wins,
|
||||||
podiums: driverStats.podiums,
|
podiums: driverStats.podiums,
|
||||||
dnfs: driverStats.dnfs,
|
dnfs: driverStats.dnfs,
|
||||||
avgFinish: driverStats.avgFinish,
|
avgFinish: driverStats.avgFinish ?? 0,
|
||||||
completionRate:
|
completionRate:
|
||||||
((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) *
|
driverStats.totalRaces > 0
|
||||||
100,
|
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||||
|
: 0,
|
||||||
}
|
}
|
||||||
: null);
|
: null);
|
||||||
|
|
||||||
@@ -91,17 +96,19 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
|||||||
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
<div className="p-4 rounded-lg bg-deep-graphite border border-charcoal-outline">
|
||||||
<div className="flex items-center justify-between mb-3">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<RankBadge rank={driverStats.overallRank} size="lg" />
|
<RankBadge rank={driverStats.overallRank ?? 0} size="lg" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
<div className="text-white font-medium text-lg">Overall Ranking</div>
|
||||||
<div className="text-sm text-gray-400">
|
<div className="text-sm text-gray-400">
|
||||||
{driverStats.overallRank} of {totalDrivers} drivers
|
{driverStats.overallRank ?? 0} of {totalDrivers} drivers
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className={`text-sm font-medium ${getPercentileColor(driverStats.percentile)}`}>
|
<div
|
||||||
{getPercentileLabel(driverStats.percentile)}
|
className={`text-sm font-medium ${getPercentileColor(driverStats.percentile ?? 0)}`}
|
||||||
|
>
|
||||||
|
{getPercentileLabel(driverStats.percentile ?? 0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-gray-500">Global Percentile</div>
|
<div className="text-xs text-gray-500">Global Percentile</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,7 +116,9 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
|||||||
|
|
||||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
|
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-charcoal-outline">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-2xl font-bold text-primary-blue">{driverStats.rating}</div>
|
<div className="text-2xl font-bold text-primary-blue">
|
||||||
|
{driverStats.rating ?? 0}
|
||||||
|
</div>
|
||||||
<div className="text-xs text-gray-400">Rating</div>
|
<div className="text-xs text-gray-400">Rating</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
import type { FeedItemDTO } from '@gridpilot/social/application/dto/FeedItemDTO';
|
||||||
import { getDriverRepository, getImageService } from '@/lib/di-container';
|
import { getDriverRepository, getImageService } from '@/lib/di-container';
|
||||||
|
|
||||||
function timeAgo(timestamp: Date): string {
|
function timeAgo(timestamp: Date | string): string {
|
||||||
const diffMs = Date.now() - timestamp.getTime();
|
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||||
|
const diffMs = Date.now() - date.getTime();
|
||||||
const diffMinutes = Math.floor(diffMs / 60000);
|
const diffMinutes = Math.floor(diffMs / 60000);
|
||||||
if (diffMinutes < 1) return 'Just now';
|
if (diffMinutes < 1) return 'Just now';
|
||||||
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
if (diffMinutes < 60) return `${diffMinutes} min ago`;
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { useRef, ReactNode } from 'react';
|
import { useRef, ReactNode } from 'react';
|
||||||
import Container from '@/components/ui/Container';
|
import Container from '@/components/ui/Container';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress';
|
import { useParallax } from '../../hooks/useScrollProgress';
|
||||||
|
|
||||||
interface AlternatingSectionProps {
|
interface AlternatingSectionProps {
|
||||||
heading: string;
|
heading: string;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import { useRef } from 'react';
|
import { useRef } from 'react';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
|
||||||
|
|
||||||
export default function DiscordCTA() {
|
export default function DiscordCTA() {
|
||||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup';
|
|||||||
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
|
import ProtestWorkflowMockup from '@/components/mockups/ProtestWorkflowMockup';
|
||||||
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
|
import LeagueDiscoveryMockup from '@/components/mockups/LeagueDiscoveryMockup';
|
||||||
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
|
import DriverProfileMockup from '@/components/mockups/DriverProfileMockup';
|
||||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
|
||||||
|
|
||||||
const features = [
|
const features = [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { useRef } from 'react';
|
|
||||||
import { useScrollProgress } from '@/hooks/useScrollProgress';
|
|
||||||
|
|
||||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
|
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || 'https://discord.gg/gridpilot';
|
||||||
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
|
const xUrl = process.env.NEXT_PUBLIC_X_URL || '#';
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useRef } from 'react';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Container from '@/components/ui/Container';
|
import Container from '@/components/ui/Container';
|
||||||
import Heading from '@/components/ui/Heading';
|
import Heading from '@/components/ui/Heading';
|
||||||
import { useScrollProgress, useParallax } from '@/hooks/useScrollProgress';
|
import { useParallax } from '../../hooks/useScrollProgress';
|
||||||
|
|
||||||
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#';
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,8 @@ function getDefaultSeasonStartDate(): string {
|
|||||||
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
|
const daysUntilSaturday = (6 - now.getDay() + 7) % 7 || 7;
|
||||||
const nextSaturday = new Date(now);
|
const nextSaturday = new Date(now);
|
||||||
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
|
nextSaturday.setDate(now.getDate() + daysUntilSaturday);
|
||||||
return nextSaturday.toISOString().split('T')[0];
|
const [datePart] = nextSaturday.toISOString().split('T');
|
||||||
|
return datePart ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultForm(): LeagueConfigFormModel {
|
function createDefaultForm(): LeagueConfigFormModel {
|
||||||
@@ -172,8 +173,6 @@ function createDefaultForm(): LeagueConfigFormModel {
|
|||||||
structure: {
|
structure: {
|
||||||
mode: 'solo',
|
mode: 'solo',
|
||||||
maxDrivers: 24,
|
maxDrivers: 24,
|
||||||
maxTeams: undefined,
|
|
||||||
driversPerTeam: undefined,
|
|
||||||
multiClassEnabled: false,
|
multiClassEnabled: false,
|
||||||
},
|
},
|
||||||
championships: {
|
championships: {
|
||||||
@@ -193,7 +192,7 @@ function createDefaultForm(): LeagueConfigFormModel {
|
|||||||
timings: {
|
timings: {
|
||||||
practiceMinutes: 20,
|
practiceMinutes: 20,
|
||||||
qualifyingMinutes: 30,
|
qualifyingMinutes: 30,
|
||||||
sprintRaceMinutes: defaultPatternId === 'sprint-main-driver' ? 20 : undefined,
|
sprintRaceMinutes: 20,
|
||||||
mainRaceMinutes: 40,
|
mainRaceMinutes: 40,
|
||||||
sessionCount: 2,
|
sessionCount: 2,
|
||||||
roundsPlanned: 8,
|
roundsPlanned: 8,
|
||||||
@@ -265,12 +264,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
const query = getListLeagueScoringPresetsQuery();
|
const query = getListLeagueScoringPresetsQuery();
|
||||||
const result = await query.execute();
|
const result = await query.execute();
|
||||||
setPresets(result);
|
setPresets(result);
|
||||||
if (result.length > 0) {
|
const firstPreset = result[0];
|
||||||
|
if (firstPreset) {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
scoring: {
|
scoring: {
|
||||||
...prev.scoring,
|
...prev.scoring,
|
||||||
patternId: prev.scoring.patternId || result[0].id,
|
patternId: prev.scoring.patternId || firstPreset.id,
|
||||||
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
|
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -338,7 +338,10 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setErrors((prev) => ({ ...prev, submit: undefined }));
|
setErrors((prev) => {
|
||||||
|
const { submit, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await createLeagueFromConfig(form);
|
const result = await createLeagueFromConfig(form);
|
||||||
@@ -577,7 +580,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
<LeagueBasicsSection
|
<LeagueBasicsSection
|
||||||
form={form}
|
form={form}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
errors={errors.basics}
|
errors={errors.basics ?? {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -587,7 +590,11 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
<LeagueVisibilitySection
|
<LeagueVisibilitySection
|
||||||
form={form}
|
form={form}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
errors={errors.basics}
|
errors={
|
||||||
|
errors.basics?.visibility
|
||||||
|
? { visibility: errors.basics.visibility }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -607,7 +614,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
<LeagueTimingsSection
|
<LeagueTimingsSection
|
||||||
form={form}
|
form={form}
|
||||||
onChange={setForm}
|
onChange={setForm}
|
||||||
errors={errors.timings}
|
errors={errors.timings ?? {}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -619,7 +626,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
|||||||
scoring={form.scoring}
|
scoring={form.scoring}
|
||||||
presets={presets}
|
presets={presets}
|
||||||
readOnly={presetsLoading}
|
readOnly={presetsLoading}
|
||||||
patternError={errors.scoring?.patternId}
|
patternError={errors.scoring?.patternId ?? ''}
|
||||||
onChangePatternId={handleScoringPresetChange}
|
onChangePatternId={handleScoringPresetChange}
|
||||||
onToggleCustomScoring={() =>
|
onToggleCustomScoring={() =>
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
|
|||||||
@@ -32,23 +32,18 @@ export default function JoinLeagueButton({
|
|||||||
const membershipRepo = getLeagueMembershipRepository();
|
const membershipRepo = getLeagueMembershipRepository();
|
||||||
|
|
||||||
if (isInviteOnly) {
|
if (isInviteOnly) {
|
||||||
// For alpha, treat "request to join" as creating a pending membership
|
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
||||||
const pending = await membershipRepo.getMembership(leagueId, currentDriverId);
|
if (existing) {
|
||||||
if (pending) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
throw new Error('Already a member or have a pending request');
|
||||||
}
|
}
|
||||||
|
|
||||||
await membershipRepo.saveMembership({
|
throw new Error(
|
||||||
leagueId,
|
'Requesting to join invite-only leagues is not available in this alpha build.',
|
||||||
driverId: currentDriverId,
|
);
|
||||||
role: 'member',
|
}
|
||||||
status: 'pending',
|
|
||||||
joinedAt: new Date(),
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const useCase = getJoinLeagueUseCase();
|
const useCase = getJoinLeagueUseCase();
|
||||||
await useCase.execute({ leagueId, driverId: currentDriverId });
|
await useCase.execute({ leagueId, driverId: currentDriverId });
|
||||||
}
|
|
||||||
|
|
||||||
onMembershipChange?.();
|
onMembershipChange?.();
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
type LeagueAdminProtestsViewModel,
|
type LeagueAdminProtestsViewModel,
|
||||||
} from '@/lib/presenters/LeagueAdminPresenter';
|
} from '@/lib/presenters/LeagueAdminPresenter';
|
||||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
|
import type { LeagueSummaryViewModel } from '@/lib/presenters/LeagueAdminPresenter';
|
||||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||||
import { LeagueScoringSection } from './LeagueScoringSection';
|
import { LeagueScoringSection } from './LeagueScoringSection';
|
||||||
@@ -37,13 +38,7 @@ import { AlertTriangle, CheckCircle, Clock, XCircle, Flag, Calendar, User, Dolla
|
|||||||
type JoinRequest = LeagueJoinRequestViewModel;
|
type JoinRequest = LeagueJoinRequestViewModel;
|
||||||
|
|
||||||
interface LeagueAdminProps {
|
interface LeagueAdminProps {
|
||||||
league: {
|
league: LeagueSummaryViewModel;
|
||||||
id: string;
|
|
||||||
ownerId: string;
|
|
||||||
settings: {
|
|
||||||
pointsSystem: string;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
onLeagueUpdate?: () => void;
|
onLeagueUpdate?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,7 +78,7 @@ export default function LeagueAdmin({ league, onLeagueUpdate }: LeagueAdminProps
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadOwner() {
|
async function loadOwner() {
|
||||||
try {
|
try {
|
||||||
const summary = await loadLeagueOwnerSummary(league);
|
const summary = await loadLeagueOwnerSummary({ ownerId: league.ownerId });
|
||||||
setOwnerSummary(summary);
|
setOwnerSummary(summary);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load league owner:', err);
|
console.error('Failed to load league owner:', err);
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import type {
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
LeagueConfigFormModel,
|
|
||||||
} from '@gridpilot/racing/application';
|
|
||||||
|
|
||||||
interface LeagueBasicsSectionProps {
|
interface LeagueBasicsSectionProps {
|
||||||
form: LeagueConfigFormModel;
|
form: LeagueConfigFormModel;
|
||||||
|
|||||||
@@ -259,13 +259,19 @@ export function LeagueDropSection({
|
|||||||
if (disabled || !onChange) return;
|
if (disabled || !onChange) return;
|
||||||
|
|
||||||
const option = DROP_OPTIONS.find((o) => o.value === strategy);
|
const option = DROP_OPTIONS.find((o) => o.value === strategy);
|
||||||
onChange({
|
const next: LeagueConfigFormModel = {
|
||||||
...form,
|
...form,
|
||||||
dropPolicy: {
|
dropPolicy:
|
||||||
|
strategy === 'none'
|
||||||
|
? {
|
||||||
strategy,
|
strategy,
|
||||||
n: strategy === 'none' ? undefined : (dropPolicy.n ?? option?.defaultN),
|
}
|
||||||
|
: {
|
||||||
|
strategy,
|
||||||
|
n: dropPolicy.n ?? option?.defaultN ?? 1,
|
||||||
},
|
},
|
||||||
});
|
};
|
||||||
|
onChange(next);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNChange = (delta: number) => {
|
const handleNChange = (delta: number) => {
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ export default function LeagueMembers({
|
|||||||
<label className="text-sm text-gray-400">Sort by:</label>
|
<label className="text-sm text-gray-400">Sort by:</label>
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
>
|
>
|
||||||
<option value="rating">Rating</option>
|
<option value="rating">Rating</option>
|
||||||
|
|||||||
@@ -358,19 +358,32 @@ export function LeagueScoringSection({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const patternPanel = (
|
const patternProps: ScoringPatternSectionProps = {
|
||||||
<ScoringPatternSection
|
scoring: form.scoring,
|
||||||
scoring={form.scoring}
|
presets,
|
||||||
presets={presets}
|
readOnly: !!readOnly,
|
||||||
readOnly={readOnly}
|
};
|
||||||
onChangePatternId={!readOnly && onChange ? handleSelectPreset : undefined}
|
|
||||||
onToggleCustomScoring={disabled ? undefined : handleToggleCustomScoring}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const championshipsPanel = (
|
if (!readOnly && onChange) {
|
||||||
<ChampionshipsSection form={form} onChange={onChange} readOnly={readOnly} />
|
patternProps.onChangePatternId = handleSelectPreset;
|
||||||
);
|
}
|
||||||
|
|
||||||
|
if (!disabled) {
|
||||||
|
patternProps.onToggleCustomScoring = handleToggleCustomScoring;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patternPanel = <ScoringPatternSection {...patternProps} />;
|
||||||
|
|
||||||
|
const championshipsProps: ChampionshipsSectionProps = {
|
||||||
|
form,
|
||||||
|
readOnly: !!readOnly,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (onChange) {
|
||||||
|
championshipsProps.onChange = onChange;
|
||||||
|
}
|
||||||
|
|
||||||
|
const championshipsPanel = <ChampionshipsSection {...championshipsProps} />;
|
||||||
|
|
||||||
if (patternOnly) {
|
if (patternOnly) {
|
||||||
return <div>{patternPanel}</div>;
|
return <div>{patternPanel}</div>;
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
getRejectSponsorshipRequestUseCase,
|
getRejectSponsorshipRequestUseCase,
|
||||||
getSeasonRepository,
|
getSeasonRepository,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
|
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
|
|
||||||
interface SponsorshipSlot {
|
interface SponsorshipSlot {
|
||||||
@@ -72,11 +73,18 @@ export function LeagueSponsorshipsSection({
|
|||||||
setRequestsLoading(true);
|
setRequestsLoading(true);
|
||||||
try {
|
try {
|
||||||
const useCase = getGetPendingSponsorshipRequestsUseCase();
|
const useCase = getGetPendingSponsorshipRequestsUseCase();
|
||||||
await useCase.execute({
|
const presenter = new PendingSponsorshipRequestsPresenter();
|
||||||
|
|
||||||
|
await useCase.execute(
|
||||||
|
{
|
||||||
entityType: 'season',
|
entityType: 'season',
|
||||||
entityId: seasonId,
|
entityId: seasonId,
|
||||||
});
|
},
|
||||||
setPendingRequests(result.requests);
|
presenter,
|
||||||
|
);
|
||||||
|
|
||||||
|
const viewModel = presenter.getViewModel();
|
||||||
|
setPendingRequests(viewModel?.requests ?? []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load pending requests:', err);
|
console.error('Failed to load pending requests:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -108,7 +116,7 @@ export function LeagueSponsorshipsSection({
|
|||||||
await useCase.execute({
|
await useCase.execute({
|
||||||
requestId,
|
requestId,
|
||||||
respondedBy: currentDriverId,
|
respondedBy: currentDriverId,
|
||||||
reason,
|
...(reason ? { reason } : {}),
|
||||||
});
|
});
|
||||||
await loadPendingRequests();
|
await loadPendingRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -118,17 +126,22 @@ export function LeagueSponsorshipsSection({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEditPrice = (index: number) => {
|
const handleEditPrice = (index: number) => {
|
||||||
|
const slot = slots[index];
|
||||||
|
if (!slot) return;
|
||||||
setEditingIndex(index);
|
setEditingIndex(index);
|
||||||
setTempPrice(slots[index].price.toString());
|
setTempPrice(slot.price.toString());
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSavePrice = (index: number) => {
|
const handleSavePrice = (index: number) => {
|
||||||
const price = parseFloat(tempPrice);
|
const price = parseFloat(tempPrice);
|
||||||
if (!isNaN(price) && price > 0) {
|
if (!isNaN(price) && price > 0) {
|
||||||
const updated = [...slots];
|
const updated = [...slots];
|
||||||
updated[index].price = price;
|
const slot = updated[index];
|
||||||
|
if (slot) {
|
||||||
|
slot.price = price;
|
||||||
setSlots(updated);
|
setSlots(updated);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
setEditingIndex(null);
|
setEditingIndex(null);
|
||||||
setTempPrice('');
|
setTempPrice('');
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -159,13 +159,10 @@ export function LeagueStructureSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextStructure.mode === 'solo') {
|
if (nextStructure.mode === 'solo') {
|
||||||
|
const { maxTeams, driversPerTeam, ...restStructure } = nextStructure;
|
||||||
nextForm = {
|
nextForm = {
|
||||||
...nextForm,
|
...nextForm,
|
||||||
structure: {
|
structure: restStructure,
|
||||||
...nextStructure,
|
|
||||||
maxTeams: undefined,
|
|
||||||
driversPerTeam: undefined,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,8 +175,6 @@ export function LeagueStructureSection({
|
|||||||
updateStructure({
|
updateStructure({
|
||||||
mode: 'solo',
|
mode: 'solo',
|
||||||
maxDrivers: structure.maxDrivers || 24,
|
maxDrivers: structure.maxDrivers || 24,
|
||||||
maxTeams: undefined,
|
|
||||||
driversPerTeam: undefined,
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const maxTeams = structure.maxTeams ?? 12;
|
const maxTeams = structure.maxTeams ?? 12;
|
||||||
|
|||||||
@@ -38,7 +38,10 @@ const TIME_ZONES = [
|
|||||||
{ value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe },
|
{ value: 'Australia/Sydney', label: 'Sydney (AU)', icon: Globe },
|
||||||
];
|
];
|
||||||
|
|
||||||
type RecurrenceStrategy = NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'];
|
type RecurrenceStrategy = Exclude<
|
||||||
|
NonNullable<LeagueConfigFormModel['timings']>['recurrenceStrategy'],
|
||||||
|
undefined
|
||||||
|
>;
|
||||||
|
|
||||||
interface LeagueTimingsSectionProps {
|
interface LeagueTimingsSectionProps {
|
||||||
form: LeagueConfigFormModel;
|
form: LeagueConfigFormModel;
|
||||||
@@ -96,12 +99,16 @@ function RaceDayPreview({
|
|||||||
const effectiveRaceTime = raceTime || '20:00';
|
const effectiveRaceTime = raceTime || '20:00';
|
||||||
|
|
||||||
const getStartTime = (sessionIndex: number) => {
|
const getStartTime = (sessionIndex: number) => {
|
||||||
const [hours, minutes] = effectiveRaceTime.split(':').map(Number);
|
const [hoursStr, minutesStr] = effectiveRaceTime.split(':');
|
||||||
|
const hours = Number(hoursStr ?? '0');
|
||||||
|
const minutes = Number(minutesStr ?? '0');
|
||||||
let totalMinutes = hours * 60 + minutes;
|
let totalMinutes = hours * 60 + minutes;
|
||||||
|
|
||||||
const active = allSessions.filter(s => s.active);
|
const active = allSessions.filter((s) => s.active);
|
||||||
for (let i = 0; i < sessionIndex; i++) {
|
for (let i = 0; i < sessionIndex; i++) {
|
||||||
totalMinutes += active[i].duration + 10; // 10 min break between sessions
|
const session = active[i];
|
||||||
|
if (!session) continue;
|
||||||
|
totalMinutes += session.duration + 10; // 10 min break between sessions
|
||||||
}
|
}
|
||||||
|
|
||||||
const h = Math.floor(totalMinutes / 60) % 24;
|
const h = Math.floor(totalMinutes / 60) % 24;
|
||||||
@@ -231,10 +238,31 @@ function YearCalendarPreview({
|
|||||||
}) {
|
}) {
|
||||||
// JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
// JavaScript getDay(): 0=Sunday, 1=Monday, 2=Tuesday, etc.
|
||||||
const dayMap: Record<Weekday, number> = {
|
const dayMap: Record<Weekday, number> = {
|
||||||
'Sun': 0, 'Mon': 1, 'Tue': 2, 'Wed': 3, 'Thu': 4, 'Fri': 5, 'Sat': 6
|
Sun: 0,
|
||||||
|
Mon: 1,
|
||||||
|
Tue: 2,
|
||||||
|
Wed: 3,
|
||||||
|
Thu: 4,
|
||||||
|
Fri: 5,
|
||||||
|
Sat: 6,
|
||||||
};
|
};
|
||||||
|
|
||||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
const months = [
|
||||||
|
'Jan',
|
||||||
|
'Feb',
|
||||||
|
'Mar',
|
||||||
|
'Apr',
|
||||||
|
'May',
|
||||||
|
'Jun',
|
||||||
|
'Jul',
|
||||||
|
'Aug',
|
||||||
|
'Sep',
|
||||||
|
'Oct',
|
||||||
|
'Nov',
|
||||||
|
'Dec',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const getMonthLabel = (index: number): string => months[index] ?? '—';
|
||||||
|
|
||||||
// Parse start and end dates
|
// Parse start and end dates
|
||||||
const seasonStart = useMemo(() => {
|
const seasonStart = useMemo(() => {
|
||||||
@@ -279,7 +307,8 @@ function YearCalendarPreview({
|
|||||||
const spacing = totalPossible / rounds;
|
const spacing = totalPossible / rounds;
|
||||||
for (let i = 0; i < rounds; i++) {
|
for (let i = 0; i < rounds; i++) {
|
||||||
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
const index = Math.min(Math.floor(i * spacing), totalPossible - 1);
|
||||||
dates.push(allPossibleDays[index]);
|
const chosen = allPossibleDays[index]!;
|
||||||
|
dates.push(chosen);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Not enough days - use all available
|
// Not enough days - use all available
|
||||||
@@ -380,7 +409,7 @@ function YearCalendarPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
view.push({
|
view.push({
|
||||||
month: months[targetMonth],
|
month: months[targetMonth] ?? '—',
|
||||||
monthIndex: targetMonth,
|
monthIndex: targetMonth,
|
||||||
year: targetYear,
|
year: targetYear,
|
||||||
days
|
days
|
||||||
@@ -391,8 +420,8 @@ function YearCalendarPreview({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the range of months that contain races
|
// Get the range of months that contain races
|
||||||
const firstRaceDate = raceDates[0];
|
const firstRaceDate = raceDates[0]!;
|
||||||
const lastRaceDate = raceDates[raceDates.length - 1];
|
const lastRaceDate = raceDates[raceDates.length - 1]!;
|
||||||
|
|
||||||
// Start from first race month, show 12 months total
|
// Start from first race month, show 12 months total
|
||||||
const startMonth = firstRaceDate.getMonth();
|
const startMonth = firstRaceDate.getMonth();
|
||||||
@@ -414,18 +443,19 @@ function YearCalendarPreview({
|
|||||||
rd.getDate() === date.getDate()
|
rd.getDate() === date.getDate()
|
||||||
);
|
);
|
||||||
const isRace = raceIndex >= 0;
|
const isRace = raceIndex >= 0;
|
||||||
|
const raceNumber = isRace ? raceIndex + 1 : undefined;
|
||||||
days.push({
|
days.push({
|
||||||
date,
|
date,
|
||||||
isRace,
|
isRace,
|
||||||
dayOfMonth: day,
|
dayOfMonth: day,
|
||||||
isStart: isSeasonStartDate(date),
|
isStart: isSeasonStartDate(date),
|
||||||
isEnd: isSeasonEndDate(date),
|
isEnd: isSeasonEndDate(date),
|
||||||
raceNumber: isRace ? raceIndex + 1 : undefined,
|
...(raceNumber !== undefined ? { raceNumber } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
view.push({
|
view.push({
|
||||||
month: months[targetMonth],
|
month: months[targetMonth] ?? '—',
|
||||||
monthIndex: targetMonth,
|
monthIndex: targetMonth,
|
||||||
year: targetYear,
|
year: targetYear,
|
||||||
days
|
days
|
||||||
@@ -438,8 +468,12 @@ function YearCalendarPreview({
|
|||||||
// Calculate season stats
|
// Calculate season stats
|
||||||
const firstRace = raceDates[0];
|
const firstRace = raceDates[0];
|
||||||
const lastRace = raceDates[raceDates.length - 1];
|
const lastRace = raceDates[raceDates.length - 1];
|
||||||
const seasonDurationWeeks = firstRace && lastRace
|
const seasonDurationWeeks =
|
||||||
? Math.ceil((lastRace.getTime() - firstRace.getTime()) / (7 * 24 * 60 * 60 * 1000))
|
firstRace && lastRace
|
||||||
|
? Math.ceil(
|
||||||
|
(lastRace.getTime() - firstRace.getTime()) /
|
||||||
|
(7 * 24 * 60 * 60 * 1000),
|
||||||
|
)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -525,12 +559,18 @@ function YearCalendarPreview({
|
|||||||
<div className="text-[9px] text-gray-500">Rounds</div>
|
<div className="text-[9px] text-gray-500">Rounds</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-bold text-white">{seasonDurationWeeks || '—'}</div>
|
<div className="text-lg font-bold text-white">
|
||||||
|
{seasonDurationWeeks || '—'}
|
||||||
|
</div>
|
||||||
<div className="text-[9px] text-gray-500">Weeks</div>
|
<div className="text-[9px] text-gray-500">Weeks</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<div className="text-lg font-bold text-primary-blue">
|
<div className="text-lg font-bold text-primary-blue">
|
||||||
{firstRace ? `${months[firstRace.getMonth()]}–${months[lastRace?.getMonth() ?? 0]}` : '—'}
|
{firstRace && lastRace
|
||||||
|
? `${getMonthLabel(firstRace.getMonth())}–${getMonthLabel(
|
||||||
|
lastRace.getMonth(),
|
||||||
|
)}`
|
||||||
|
: '—'}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[9px] text-gray-500">Duration</div>
|
<div className="text-[9px] text-gray-500">Duration</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -986,7 +1026,9 @@ export function LeagueTimingsSection({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
updateTimings({
|
updateTimings({
|
||||||
recurrenceStrategy: opt.id as RecurrenceStrategy,
|
recurrenceStrategy: opt.id as RecurrenceStrategy,
|
||||||
intervalWeeks: opt.id === 'everyNWeeks' ? 2 : undefined,
|
...(opt.id === 'everyNWeeks'
|
||||||
|
? { intervalWeeks: 2 }
|
||||||
|
: {}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
className={`
|
className={`
|
||||||
@@ -1054,7 +1096,7 @@ export function LeagueTimingsSection({
|
|||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={timings.seasonStartDate ?? ''}
|
value={timings.seasonStartDate ?? ''}
|
||||||
onChange={(e) => updateTimings({ seasonStartDate: e.target.value || undefined })}
|
onChange={(e) => updateTimings({ seasonStartDate: e.target.value })}
|
||||||
className="bg-iron-gray/30"
|
className="bg-iron-gray/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1066,7 +1108,7 @@ export function LeagueTimingsSection({
|
|||||||
<Input
|
<Input
|
||||||
type="date"
|
type="date"
|
||||||
value={timings.seasonEndDate ?? ''}
|
value={timings.seasonEndDate ?? ''}
|
||||||
onChange={(e) => updateTimings({ seasonEndDate: e.target.value || undefined })}
|
onChange={(e) => updateTimings({ seasonEndDate: e.target.value })}
|
||||||
className="bg-iron-gray/30"
|
className="bg-iron-gray/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1086,7 +1128,7 @@ export function LeagueTimingsSection({
|
|||||||
<Input
|
<Input
|
||||||
type="time"
|
type="time"
|
||||||
value={timings.raceStartTime ?? '20:00'}
|
value={timings.raceStartTime ?? '20:00'}
|
||||||
onChange={(e) => updateTimings({ raceStartTime: e.target.value || undefined })}
|
onChange={(e) => updateTimings({ raceStartTime: e.target.value })}
|
||||||
className="bg-iron-gray/30"
|
className="bg-iron-gray/30"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -1214,28 +1256,45 @@ export function LeagueTimingsSection({
|
|||||||
|
|
||||||
{/* Preview content */}
|
{/* Preview content */}
|
||||||
<div className="p-4 min-h-[300px]">
|
<div className="p-4 min-h-[300px]">
|
||||||
{previewTab === 'day' && (
|
{previewTab === 'day' && (() => {
|
||||||
|
const sprintMinutes = showSprint
|
||||||
|
? timings.sprintRaceMinutes ?? 20
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
<RaceDayPreview
|
<RaceDayPreview
|
||||||
template={showSprint ? 'sprintFeature' : 'feature'}
|
template={showSprint ? 'sprintFeature' : 'feature'}
|
||||||
practiceMin={timings.practiceMinutes ?? 20}
|
practiceMin={timings.practiceMinutes ?? 20}
|
||||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||||
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
|
{...(sprintMinutes !== undefined
|
||||||
|
? { sprintMin: sprintMinutes }
|
||||||
|
: {})}
|
||||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||||
raceTime={timings.raceStartTime}
|
{...(timings.raceStartTime
|
||||||
|
? { raceTime: timings.raceStartTime }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
{previewTab === 'year' && (
|
{previewTab === 'year' && (
|
||||||
<YearCalendarPreview
|
<YearCalendarPreview
|
||||||
weekdays={weekdays}
|
weekdays={weekdays}
|
||||||
frequency={recurrenceStrategy}
|
frequency={recurrenceStrategy}
|
||||||
rounds={timings.roundsPlanned ?? 8}
|
rounds={timings.roundsPlanned ?? 8}
|
||||||
startDate={timings.seasonStartDate}
|
{...(timings.seasonStartDate
|
||||||
endDate={timings.seasonEndDate}
|
? { startDate: timings.seasonStartDate }
|
||||||
|
: {})}
|
||||||
|
{...(timings.seasonEndDate
|
||||||
|
? { endDate: timings.seasonEndDate }
|
||||||
|
: {})}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{previewTab === 'stats' && (
|
{previewTab === 'stats' && (() => {
|
||||||
|
const sprintMinutes = showSprint
|
||||||
|
? timings.sprintRaceMinutes ?? 20
|
||||||
|
: undefined;
|
||||||
|
return (
|
||||||
<SeasonStatsPreview
|
<SeasonStatsPreview
|
||||||
rounds={timings.roundsPlanned ?? 8}
|
rounds={timings.roundsPlanned ?? 8}
|
||||||
weekdays={weekdays}
|
weekdays={weekdays}
|
||||||
@@ -1243,10 +1302,13 @@ export function LeagueTimingsSection({
|
|||||||
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
|
weekendTemplate={showSprint ? 'sprintFeature' : 'feature'}
|
||||||
practiceMin={timings.practiceMinutes ?? 20}
|
practiceMin={timings.practiceMinutes ?? 20}
|
||||||
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
qualifyingMin={timings.qualifyingMinutes ?? 15}
|
||||||
sprintMin={showSprint ? (timings.sprintRaceMinutes ?? 20) : undefined}
|
{...(sprintMinutes !== undefined
|
||||||
|
? { sprintMin: sprintMinutes }
|
||||||
|
: {})}
|
||||||
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
mainRaceMin={timings.mainRaceMinutes ?? 40}
|
||||||
/>
|
/>
|
||||||
)}
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -229,7 +229,7 @@ export default function ScheduleRaceForm({
|
|||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.sessionType}
|
value={formData.sessionType}
|
||||||
onChange={(e) => handleChange('sessionType', e.target.value as SessionType)}
|
onChange={(e) => handleChange('sessionType', e.target.value)}
|
||||||
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
className="w-full px-4 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
>
|
>
|
||||||
<option value="practice">Practice</option>
|
<option value="practice">Practice</option>
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function DriverProfileMockup() {
|
|||||||
value={stat.value}
|
value={stat.value}
|
||||||
shouldReduceMotion={shouldReduceMotion ?? false}
|
shouldReduceMotion={shouldReduceMotion ?? false}
|
||||||
delay={index * 0.1}
|
delay={index * 0.1}
|
||||||
suffix={stat.suffix}
|
suffix={stat.suffix ?? ''}
|
||||||
/>
|
/>
|
||||||
<div className="text-[8px] sm:text-[10px] md:text-xs text-gray-400 mt-0.5">{stat.label}</div>
|
<div className="text-[8px] sm:text-[10px] md:text-xs text-gray-400 mt-0.5">{stat.label}</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@@ -257,7 +257,10 @@ export default function OnboardingWizard() {
|
|||||||
generatedAvatars: [],
|
generatedAvatars: [],
|
||||||
selectedAvatarIndex: null,
|
selectedAvatarIndex: null,
|
||||||
});
|
});
|
||||||
setErrors({ ...errors, facePhoto: undefined });
|
setErrors((prev) => {
|
||||||
|
const { facePhoto, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
// Validate face
|
// Validate face
|
||||||
await validateFacePhoto(base64);
|
await validateFacePhoto(base64);
|
||||||
@@ -267,7 +270,10 @@ export default function OnboardingWizard() {
|
|||||||
|
|
||||||
const validateFacePhoto = async (photoData: string) => {
|
const validateFacePhoto = async (photoData: string) => {
|
||||||
setAvatarInfo(prev => ({ ...prev, isValidating: true }));
|
setAvatarInfo(prev => ({ ...prev, isValidating: true }));
|
||||||
setErrors(prev => ({ ...prev, facePhoto: undefined }));
|
setErrors(prev => {
|
||||||
|
const { facePhoto, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/avatar/validate-face', {
|
const response = await fetch('/api/avatar/validate-face', {
|
||||||
@@ -300,7 +306,10 @@ export default function OnboardingWizard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
|
setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }));
|
||||||
setErrors(prev => ({ ...prev, avatar: undefined }));
|
setErrors(prev => {
|
||||||
|
const { avatar, ...rest } = prev;
|
||||||
|
return rest;
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/avatar/generate', {
|
const response = await fetch('/api/avatar/generate', {
|
||||||
@@ -490,7 +499,7 @@ export default function OnboardingWizard() {
|
|||||||
setPersonalInfo({ ...personalInfo, country: value })
|
setPersonalInfo({ ...personalInfo, country: value })
|
||||||
}
|
}
|
||||||
error={!!errors.country}
|
error={!!errors.country}
|
||||||
errorMessage={errors.country}
|
errorMessage={errors.country ?? ''}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -600,17 +609,24 @@ export default function OnboardingWizard() {
|
|||||||
{/* Preview area */}
|
{/* Preview area */}
|
||||||
<div className="w-32 flex flex-col items-center justify-center">
|
<div className="w-32 flex flex-col items-center justify-center">
|
||||||
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
|
<div className="w-24 h-24 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center overflow-hidden">
|
||||||
{avatarInfo.selectedAvatarIndex !== null && avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex] ? (
|
{(() => {
|
||||||
|
const selectedAvatarUrl =
|
||||||
|
avatarInfo.selectedAvatarIndex !== null
|
||||||
|
? avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]
|
||||||
|
: undefined;
|
||||||
|
if (!selectedAvatarUrl) {
|
||||||
|
return <User className="w-8 h-8 text-gray-600" />;
|
||||||
|
}
|
||||||
|
return (
|
||||||
<Image
|
<Image
|
||||||
src={avatarInfo.generatedAvatars[avatarInfo.selectedAvatarIndex]}
|
src={selectedAvatarUrl}
|
||||||
alt="Selected avatar"
|
alt="Selected avatar"
|
||||||
width={96}
|
width={96}
|
||||||
height={96}
|
height={96}
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
);
|
||||||
<User className="w-8 h-8 text-gray-600" />
|
})()}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
|
<p className="text-xs text-gray-500 mt-2 text-center">Your avatar</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,8 +3,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Modal from '@/components/ui/Modal';
|
import Modal from '@/components/ui/Modal';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { getFileProtestUseCase, getDriverRepository } from '@/lib/di-container';
|
import { getFileProtestUseCase } from '@/lib/di-container';
|
||||||
import type { Driver } from '@gridpilot/racing/domain/entities/Driver';
|
|
||||||
import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
|
import type { ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -17,13 +16,18 @@ import {
|
|||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
type ProtestParticipant = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface FileProtestModalProps {
|
interface FileProtestModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
raceId: string;
|
raceId: string;
|
||||||
leagueId?: string;
|
leagueId?: string;
|
||||||
protestingDriverId: string;
|
protestingDriverId: string;
|
||||||
participants: Driver[];
|
participants: ProtestParticipant[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FileProtestModal({
|
export default function FileProtestModal({
|
||||||
@@ -70,18 +74,26 @@ export default function FileProtestModal({
|
|||||||
|
|
||||||
const incident: ProtestIncident = {
|
const incident: ProtestIncident = {
|
||||||
lap: parseInt(lap, 10),
|
lap: parseInt(lap, 10),
|
||||||
timeInRace: timeInRace ? parseInt(timeInRace, 10) : undefined,
|
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
|
...(timeInRace
|
||||||
|
? { timeInRace: parseInt(timeInRace, 10) }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await useCase.execute({
|
const command = {
|
||||||
raceId,
|
raceId,
|
||||||
protestingDriverId,
|
protestingDriverId,
|
||||||
accusedDriverId,
|
accusedDriverId,
|
||||||
incident,
|
incident,
|
||||||
comment: comment.trim() || undefined,
|
...(comment.trim()
|
||||||
proofVideoUrl: proofVideoUrl.trim() || undefined,
|
? { comment: comment.trim() }
|
||||||
});
|
: {}),
|
||||||
|
...(proofVideoUrl.trim()
|
||||||
|
? { proofVideoUrl: proofVideoUrl.trim() }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
await useCase.execute(command);
|
||||||
|
|
||||||
setStep('success');
|
setStep('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
|||||||
throw new Error('CSV file is empty or invalid');
|
throw new Error('CSV file is empty or invalid');
|
||||||
}
|
}
|
||||||
|
|
||||||
const header = lines[0].toLowerCase().split(',').map((h) => h.trim());
|
const headerLine = lines[0]!;
|
||||||
|
const header = headerLine.toLowerCase().split(',').map((h) => h.trim());
|
||||||
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
const requiredFields = ['driverid', 'position', 'fastestlap', 'incidents', 'startposition'];
|
||||||
|
|
||||||
for (const field of requiredFields) {
|
for (const field of requiredFields) {
|
||||||
@@ -50,7 +51,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
|||||||
|
|
||||||
const rows: CSVRow[] = [];
|
const rows: CSVRow[] = [];
|
||||||
for (let i = 1; i < lines.length; i++) {
|
for (let i = 1; i < lines.length; i++) {
|
||||||
const values = lines[i].split(',').map((v) => v.trim());
|
const line = lines[i];
|
||||||
|
if (!line) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const values = line.split(',').map((v) => v.trim());
|
||||||
|
|
||||||
if (values.length !== header.length) {
|
if (values.length !== header.length) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -63,11 +68,11 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import
|
|||||||
row[field] = values[index] ?? '';
|
row[field] = values[index] ?? '';
|
||||||
});
|
});
|
||||||
|
|
||||||
const driverId = row.driverid;
|
const driverId = row['driverid'] ?? '';
|
||||||
const position = parseInt(row.position, 10);
|
const position = parseInt(row['position'] ?? '', 10);
|
||||||
const fastestLap = parseFloat(row.fastestlap);
|
const fastestLap = parseFloat(row['fastestlap'] ?? '');
|
||||||
const incidents = parseInt(row.incidents, 10);
|
const incidents = parseInt(row['incidents'] ?? '', 10);
|
||||||
const startPosition = parseInt(row.startposition, 10);
|
const startPosition = parseInt(row['startposition'] ?? '', 10);
|
||||||
|
|
||||||
if (!driverId || driverId.length === 0) {
|
if (!driverId || driverId.length === 0) {
|
||||||
throw new Error(`Row ${i}: driverId is required`);
|
throw new Error(`Row ${i}: driverId is required`);
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ interface ResultsTableProps {
|
|||||||
results: ResultDTO[];
|
results: ResultDTO[];
|
||||||
drivers: DriverDTO[];
|
drivers: DriverDTO[];
|
||||||
pointsSystem: Record<number, number>;
|
pointsSystem: Record<number, number>;
|
||||||
fastestLapTime?: number;
|
fastestLapTime?: number | undefined;
|
||||||
penalties?: PenaltyData[];
|
penalties?: PenaltyData[];
|
||||||
currentDriverId?: string;
|
currentDriverId?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ResultsTable({
|
export default function ResultsTable({
|
||||||
|
|||||||
@@ -5,11 +5,19 @@ import Button from '@/components/ui/Button';
|
|||||||
import {
|
import {
|
||||||
getJoinTeamUseCase,
|
getJoinTeamUseCase,
|
||||||
getLeaveTeamUseCase,
|
getLeaveTeamUseCase,
|
||||||
getGetDriverTeamUseCase,
|
|
||||||
getTeamMembershipRepository,
|
getTeamMembershipRepository,
|
||||||
} from '@/lib/di-container';
|
} from '@/lib/di-container';
|
||||||
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
import { useEffectiveDriverId } from '@/lib/currentDriver';
|
||||||
import type { TeamMembership } from '@gridpilot/racing';
|
|
||||||
|
type TeamMembershipStatus = 'active' | 'pending' | 'inactive';
|
||||||
|
|
||||||
|
interface TeamMembership {
|
||||||
|
teamId: string;
|
||||||
|
driverId: string;
|
||||||
|
role: 'owner' | 'manager' | 'driver';
|
||||||
|
status: TeamMembershipStatus;
|
||||||
|
joinedAt: Date | string;
|
||||||
|
}
|
||||||
|
|
||||||
interface JoinTeamButtonProps {
|
interface JoinTeamButtonProps {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -25,25 +33,12 @@ export default function JoinTeamButton({
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const [membership, setMembership] = useState<TeamMembership | null>(null);
|
const [membership, setMembership] = useState<TeamMembership | null>(null);
|
||||||
const [currentTeamName, setCurrentTeamName] = useState<string | null>(null);
|
|
||||||
const [currentTeamId, setCurrentTeamId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const membershipRepo = getTeamMembershipRepository();
|
const membershipRepo = getTeamMembershipRepository();
|
||||||
const m = await membershipRepo.getMembership(teamId, currentDriverId);
|
const m = await membershipRepo.getMembership(teamId, currentDriverId);
|
||||||
setMembership(m);
|
setMembership(m as TeamMembership | null);
|
||||||
|
|
||||||
const driverTeamUseCase = getGetDriverTeamUseCase();
|
|
||||||
await driverTeamUseCase.execute({ driverId: currentDriverId });
|
|
||||||
const viewModel = driverTeamUseCase.presenter.getViewModel();
|
|
||||||
if (viewModel.result) {
|
|
||||||
setCurrentTeamId(viewModel.result.team.id);
|
|
||||||
setCurrentTeamName(viewModel.result.team.name);
|
|
||||||
} else {
|
|
||||||
setCurrentTeamId(null);
|
|
||||||
setCurrentTeamName(null);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
void load();
|
void load();
|
||||||
}, [teamId, currentDriverId]);
|
}, [teamId, currentDriverId]);
|
||||||
@@ -117,15 +112,6 @@ export default function JoinTeamButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Already on another team
|
|
||||||
if (currentTeamId && currentTeamId !== teamId) {
|
|
||||||
return (
|
|
||||||
<Button variant="secondary" disabled>
|
|
||||||
Already on {currentTeamName}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Can join
|
// Can join
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -39,7 +39,13 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const viewModel = await loadTeamAdminViewModel(team as any);
|
const viewModel = await loadTeamAdminViewModel({
|
||||||
|
id: team.id,
|
||||||
|
name: team.name,
|
||||||
|
tag: team.tag,
|
||||||
|
description: team.description,
|
||||||
|
ownerId: team.ownerId,
|
||||||
|
});
|
||||||
setJoinRequests(viewModel.requests);
|
setJoinRequests(viewModel.requests);
|
||||||
|
|
||||||
const driversById: Record<string, DriverDTO> = {};
|
const driversById: Record<string, DriverDTO> = {};
|
||||||
|
|||||||
@@ -29,9 +29,9 @@ interface TeamCardProps {
|
|||||||
totalRaces?: number;
|
totalRaces?: number;
|
||||||
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
performanceLevel?: 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||||
isRecruiting?: boolean;
|
isRecruiting?: boolean;
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
specialization?: 'endurance' | 'sprint' | 'mixed' | undefined;
|
||||||
region?: string;
|
region?: string;
|
||||||
languages?: string[];
|
languages?: string[] | undefined;
|
||||||
leagues?: string[];
|
leagues?: string[];
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import {
|
|||||||
getTeamRosterViewModel,
|
getTeamRosterViewModel,
|
||||||
type TeamRosterViewModel,
|
type TeamRosterViewModel,
|
||||||
} from '@/lib/presenters/TeamRosterPresenter';
|
} from '@/lib/presenters/TeamRosterPresenter';
|
||||||
|
import type { TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||||
type TeamRole = 'owner' | 'manager' | 'driver';
|
|
||||||
|
|
||||||
interface TeamMembershipSummary {
|
interface TeamMembershipSummary {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
@@ -39,7 +38,14 @@ export default function TeamRoster({
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const vm = await getTeamRosterViewModel(memberships);
|
const fullMemberships = memberships.map((m) => ({
|
||||||
|
teamId,
|
||||||
|
driverId: m.driverId,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
status: 'active' as const,
|
||||||
|
}));
|
||||||
|
const vm = await getTeamRosterViewModel(fullMemberships);
|
||||||
setViewModel(vm);
|
setViewModel(vm);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -64,6 +70,19 @@ export default function TeamRoster({
|
|||||||
return role.charAt(0).toUpperCase() + role.slice(1);
|
return role.charAt(0).toUpperCase() + role.slice(1);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function getRoleOrder(role: TeamRole): number {
|
||||||
|
switch (role) {
|
||||||
|
case 'owner':
|
||||||
|
return 0;
|
||||||
|
case 'manager':
|
||||||
|
return 1;
|
||||||
|
case 'driver':
|
||||||
|
return 2;
|
||||||
|
default:
|
||||||
|
return 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sortedMembers = viewModel
|
const sortedMembers = viewModel
|
||||||
? [...viewModel.members].sort((a, b) => {
|
? [...viewModel.members].sort((a, b) => {
|
||||||
switch (sortBy) {
|
switch (sortBy) {
|
||||||
@@ -73,8 +92,7 @@ export default function TeamRoster({
|
|||||||
return ratingB - ratingA;
|
return ratingB - ratingA;
|
||||||
}
|
}
|
||||||
case 'role': {
|
case 'role': {
|
||||||
const roleOrder: Record<TeamRole, number> = { owner: 0, manager: 1, driver: 2 };
|
return getRoleOrder(a.role) - getRoleOrder(b.role);
|
||||||
return roleOrder[a.role] - roleOrder[b.role];
|
|
||||||
}
|
}
|
||||||
case 'name': {
|
case 'name': {
|
||||||
return a.driver.name.localeCompare(b.driver.name);
|
return a.driver.name.localeCompare(b.driver.name);
|
||||||
@@ -110,7 +128,7 @@ export default function TeamRoster({
|
|||||||
<label className="text-sm text-gray-400">Sort by:</label>
|
<label className="text-sm text-gray-400">Sort by:</label>
|
||||||
<select
|
<select
|
||||||
value={sortBy}
|
value={sortBy}
|
||||||
onChange={(e) => setSortBy(e.target.value as any)}
|
onChange={(e) => setSortBy(e.target.value as typeof sortBy)}
|
||||||
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
className="px-3 py-1 bg-deep-graphite border border-charcoal-outline rounded text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-blue"
|
||||||
>
|
>
|
||||||
<option value="rating">Rating</option>
|
<option value="rating">Rating</option>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { InputHTMLAttributes, ReactNode } from 'react';
|
|||||||
|
|
||||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
error?: boolean;
|
error?: boolean;
|
||||||
errorMessage?: string;
|
errorMessage?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Input({
|
export default function Input({
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export default function Modal({
|
|||||||
if (focusable.length === 0) return;
|
if (focusable.length === 0) return;
|
||||||
|
|
||||||
const first = focusable[0];
|
const first = focusable[0];
|
||||||
const last = focusable[focusable.length - 1];
|
const last = focusable[focusable.length - 1] ?? first;
|
||||||
|
|
||||||
|
if (!first || !last) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!event.shiftKey && document.activeElement === last) {
|
if (!event.shiftKey && document.activeElement === last) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface RangeFieldProps {
|
|||||||
step?: number;
|
step?: number;
|
||||||
onChange: (value: number) => void;
|
onChange: (value: number) => void;
|
||||||
helperText?: string;
|
helperText?: string;
|
||||||
error?: string;
|
error?: string | undefined;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
unitLabel?: string;
|
unitLabel?: string;
|
||||||
rangeHint?: string;
|
rangeHint?: string;
|
||||||
|
|||||||
@@ -60,9 +60,13 @@ export class InMemoryAuthService implements AuthService {
|
|||||||
const provider = new IracingDemoIdentityProviderAdapter();
|
const provider = new IracingDemoIdentityProviderAdapter();
|
||||||
const useCase = new StartAuthUseCase(provider);
|
const useCase = new StartAuthUseCase(provider);
|
||||||
|
|
||||||
const command: StartAuthCommandDTO = {
|
const command: StartAuthCommandDTO = returnTo
|
||||||
|
? {
|
||||||
provider: 'IRACING_DEMO',
|
provider: 'IRACING_DEMO',
|
||||||
returnTo,
|
returnTo,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
provider: 'IRACING_DEMO',
|
||||||
};
|
};
|
||||||
|
|
||||||
return useCase.execute(command);
|
return useCase.execute(command);
|
||||||
@@ -77,11 +81,17 @@ export class InMemoryAuthService implements AuthService {
|
|||||||
const sessionPort = new CookieIdentitySessionAdapter();
|
const sessionPort = new CookieIdentitySessionAdapter();
|
||||||
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
|
const useCase = new HandleAuthCallbackUseCase(provider, sessionPort);
|
||||||
|
|
||||||
const command: AuthCallbackCommandDTO = {
|
const command: AuthCallbackCommandDTO = params.returnTo
|
||||||
|
? {
|
||||||
provider: 'IRACING_DEMO',
|
provider: 'IRACING_DEMO',
|
||||||
code: params.code,
|
code: params.code,
|
||||||
state: params.state,
|
state: params.state,
|
||||||
returnTo: params.returnTo,
|
returnTo: params.returnTo,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
provider: 'IRACING_DEMO',
|
||||||
|
code: params.code,
|
||||||
|
state: params.state,
|
||||||
};
|
};
|
||||||
|
|
||||||
return useCase.execute(command);
|
return useCase.execute(command);
|
||||||
|
|||||||
@@ -24,12 +24,23 @@ export function useEffectiveDriverId(): string {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Lazy-load to avoid importing DI facade at module evaluation time
|
// Lazy-load to avoid importing DI facade at module evaluation time
|
||||||
const { getDriverRepository } = require('./di-container') as typeof import('./di-container');
|
const { getDriverRepository } =
|
||||||
|
require('./di-container') as typeof import('./di-container');
|
||||||
const repo = getDriverRepository();
|
const repo = getDriverRepository();
|
||||||
// In-memory repository is synchronous for findAll in the demo implementation
|
|
||||||
const allDrivers = repo.findAllSync?.() as Array<{ id: string }> | undefined;
|
interface DriverRepositoryWithSyncFindAll {
|
||||||
if (allDrivers && allDrivers.length > 0) {
|
findAllSync?: () => Array<{ id: string }>;
|
||||||
return allDrivers[0].id;
|
}
|
||||||
|
|
||||||
|
// In alpha/demo mode the in-memory repository exposes a synchronous finder;
|
||||||
|
// access it via a safe dynamic lookup to keep typing compatible with the port.
|
||||||
|
const repoWithSync = repo as DriverRepositoryWithSyncFindAll;
|
||||||
|
const allDrivers = repoWithSync.findAllSync?.();
|
||||||
|
if (Array.isArray(allDrivers) && allDrivers.length > 0) {
|
||||||
|
const firstDriver = allDrivers[0];
|
||||||
|
if (firstDriver) {
|
||||||
|
return firstDriver.id;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore and fall back to legacy default below
|
// Ignore and fall back to legacy default below
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { Season } from '@gridpilot/racing/domain/entities/Season';
|
|||||||
import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor';
|
import { Sponsor } from '@gridpilot/racing/domain/entities/Sponsor';
|
||||||
import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship';
|
import { SeasonSponsorship } from '@gridpilot/racing/domain/entities/SeasonSponsorship';
|
||||||
import { Money } from '@gridpilot/racing/domain/value-objects/Money';
|
import { Money } from '@gridpilot/racing/domain/value-objects/Money';
|
||||||
import type { LeagueMembership, JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
import type { JoinRequest } from '@gridpilot/racing/domain/entities/LeagueMembership';
|
||||||
|
|
||||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||||
@@ -139,7 +139,6 @@ import { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/application/us
|
|||||||
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
|
import { CancelRaceUseCase } from '@gridpilot/racing/application/use-cases/CancelRaceUseCase';
|
||||||
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
|
import { ImportRaceResultsUseCase } from '@gridpilot/racing/application/use-cases/ImportRaceResultsUseCase';
|
||||||
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
import { DriversLeaderboardPresenter } from './presenters/DriversLeaderboardPresenter';
|
||||||
import { TeamsLeaderboardPresenter } from './presenters/TeamsLeaderboardPresenter';
|
|
||||||
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
|
import { RacesPagePresenter } from './presenters/RacesPagePresenter';
|
||||||
import { AllRacesPagePresenter } from './presenters/AllRacesPagePresenter';
|
import { AllRacesPagePresenter } from './presenters/AllRacesPagePresenter';
|
||||||
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
|
import { AllTeamsPresenter } from './presenters/AllTeamsPresenter';
|
||||||
@@ -195,7 +194,24 @@ export function configureDIContainer(): void {
|
|||||||
const primaryDriverId = seedData.drivers[0]!.id;
|
const primaryDriverId = seedData.drivers[0]!.id;
|
||||||
|
|
||||||
// Create driver statistics from seed data
|
// Create driver statistics from seed data
|
||||||
const driverStats = createDemoDriverStats(seedData.drivers);
|
type DemoDriverStatsEntry = {
|
||||||
|
rating?: number;
|
||||||
|
wins?: number;
|
||||||
|
podiums?: number;
|
||||||
|
dnfs?: number;
|
||||||
|
totalRaces?: number;
|
||||||
|
avgFinish?: number;
|
||||||
|
bestFinish?: number;
|
||||||
|
worstFinish?: number;
|
||||||
|
overallRank?: number;
|
||||||
|
consistency?: number;
|
||||||
|
percentile?: number;
|
||||||
|
driverId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DemoDriverStatsMap = Record<string, DemoDriverStatsEntry>;
|
||||||
|
|
||||||
|
const driverStats: DemoDriverStatsMap = createDemoDriverStats(seedData.drivers);
|
||||||
|
|
||||||
// Register repositories
|
// Register repositories
|
||||||
container.registerInstance<IDriverRepository>(
|
container.registerInstance<IDriverRepository>(
|
||||||
@@ -228,7 +244,11 @@ export function configureDIContainer(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Race registrations - seed from results for completed races, plus some upcoming races
|
// Race registrations - seed from results for completed races, plus some upcoming races
|
||||||
const seedRaceRegistrations: Array<{ raceId: string; driverId: string; registeredAt: Date }> = [];
|
const seedRaceRegistrations: Array<{
|
||||||
|
raceId: string;
|
||||||
|
driverId: string;
|
||||||
|
registeredAt: Date;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// For completed races, extract driver registrations from results
|
// For completed races, extract driver registrations from results
|
||||||
for (const result of seedData.results) {
|
for (const result of seedData.results) {
|
||||||
@@ -280,6 +300,8 @@ export function configureDIContainer(): void {
|
|||||||
const seededPenalties: Penalty[] = [];
|
const seededPenalties: Penalty[] = [];
|
||||||
const seededProtests: Protest[] = [];
|
const seededProtests: Protest[] = [];
|
||||||
|
|
||||||
|
type ProtestProps = Parameters<(typeof Protest)['create']>[0];
|
||||||
|
|
||||||
racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => {
|
racesForProtests.forEach(({ race, leagueIndex: leagueIdx }, raceIndex) => {
|
||||||
const raceResults = seedData.results.filter(r => r.raceId === race.id);
|
const raceResults = seedData.results.filter(r => r.raceId === race.id);
|
||||||
if (raceResults.length < 4) return;
|
if (raceResults.length < 4) return;
|
||||||
@@ -291,33 +313,51 @@ export function configureDIContainer(): void {
|
|||||||
|
|
||||||
if (!protestingResult || !accusedResult) continue;
|
if (!protestingResult || !accusedResult) continue;
|
||||||
|
|
||||||
const protestStatuses: Array<'pending' | 'under_review' | 'upheld' | 'dismissed'> = ['pending', 'under_review', 'upheld', 'dismissed'];
|
const protestStatuses = [
|
||||||
const status = protestStatuses[(raceIndex + i) % protestStatuses.length];
|
'pending',
|
||||||
|
'under_review',
|
||||||
|
'upheld',
|
||||||
|
'dismissed',
|
||||||
|
] as const;
|
||||||
|
const status =
|
||||||
|
protestStatuses[(raceIndex + i) % protestStatuses.length] ?? 'pending';
|
||||||
|
|
||||||
const protest = Protest.create({
|
const protestProps: ProtestProps = {
|
||||||
id: `protest-${race.id}-${i}`,
|
id: `protest-${race.id}-${i}`,
|
||||||
raceId: race.id,
|
raceId: race.id,
|
||||||
protestingDriverId: protestingResult.driverId,
|
protestingDriverId: protestingResult.driverId,
|
||||||
accusedDriverId: accusedResult.driverId,
|
accusedDriverId: accusedResult.driverId,
|
||||||
incident: {
|
incident: {
|
||||||
lap: 5 + i * 3,
|
lap: 5 + i * 3,
|
||||||
description: i === 0
|
description:
|
||||||
|
i === 0
|
||||||
? 'Unsafe rejoining to the track after going off, causing contact'
|
? 'Unsafe rejoining to the track after going off, causing contact'
|
||||||
: 'Aggressive defending, pushing competitor off track',
|
: 'Aggressive defending, pushing competitor off track',
|
||||||
},
|
},
|
||||||
comment: i === 0
|
comment:
|
||||||
|
i === 0
|
||||||
? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.'
|
? 'Driver rejoined directly into my racing line, causing contact and damaging my front wing.'
|
||||||
: 'Driver moved under braking multiple times, forcing me off the circuit.',
|
: 'Driver moved under braking multiple times, forcing me off the circuit.',
|
||||||
status,
|
status,
|
||||||
filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000),
|
filedAt: new Date(Date.now() - (raceIndex + 1) * 24 * 60 * 60 * 1000),
|
||||||
reviewedBy: status !== 'pending' ? primaryDriverId : undefined,
|
};
|
||||||
decisionNotes: status === 'upheld'
|
|
||||||
? 'After reviewing the evidence, the accused driver is found at fault. Penalty applied.'
|
if (status !== 'pending') {
|
||||||
: status === 'dismissed'
|
protestProps.reviewedBy = primaryDriverId;
|
||||||
? 'No clear fault found. Racing incident.'
|
protestProps.reviewedAt = new Date(
|
||||||
: undefined,
|
Date.now() - raceIndex * 24 * 60 * 60 * 1000,
|
||||||
reviewedAt: status !== 'pending' ? new Date(Date.now() - raceIndex * 24 * 60 * 60 * 1000) : undefined,
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
if (status === 'upheld') {
|
||||||
|
protestProps.decisionNotes =
|
||||||
|
'After reviewing the evidence, the accused driver is found at fault. Penalty applied.';
|
||||||
|
} else if (status === 'dismissed') {
|
||||||
|
protestProps.decisionNotes =
|
||||||
|
'No clear fault found. Racing incident.';
|
||||||
|
}
|
||||||
|
|
||||||
|
const protest = Protest.create(protestProps);
|
||||||
|
|
||||||
seededProtests.push(protest);
|
seededProtests.push(protest);
|
||||||
|
|
||||||
@@ -448,7 +488,15 @@ export function configureDIContainer(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// League memberships
|
// League memberships
|
||||||
const seededMemberships: LeagueMembership[] = seedData.memberships.map((m) => ({
|
type SeedMembership = {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
role: 'member' | 'owner' | 'admin' | 'steward';
|
||||||
|
status: 'active';
|
||||||
|
joinedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
const seededMemberships: SeedMembership[] = seedData.memberships.map((m) => ({
|
||||||
leagueId: m.leagueId,
|
leagueId: m.leagueId,
|
||||||
driverId: m.driverId,
|
driverId: m.driverId,
|
||||||
role: 'member',
|
role: 'member',
|
||||||
@@ -476,11 +524,11 @@ export function configureDIContainer(): void {
|
|||||||
|
|
||||||
// Ensure primary driver owns at least one league
|
// Ensure primary driver owns at least one league
|
||||||
const hasPrimaryOwnerMembership = seededMemberships.some(
|
const hasPrimaryOwnerMembership = seededMemberships.some(
|
||||||
(m: LeagueMembership) => m.driverId === primaryDriverId && m.role === 'owner',
|
(m) => m.driverId === primaryDriverId && m.role === 'owner',
|
||||||
);
|
);
|
||||||
if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) {
|
if (!hasPrimaryOwnerMembership && seedData.leagues.length > 0) {
|
||||||
const targetLeague =
|
const targetLeague =
|
||||||
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0];
|
seedData.leagues.find((l) => l.ownerId === primaryDriverId) ?? seedData.leagues[0]!;
|
||||||
|
|
||||||
const existingForPrimary = seededMemberships.find(
|
const existingForPrimary = seededMemberships.find(
|
||||||
(m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId,
|
(m) => m.leagueId === targetLeague.id && m.driverId === primaryDriverId,
|
||||||
@@ -574,23 +622,36 @@ export function configureDIContainer(): void {
|
|||||||
'Heard great things about this league. Can I join?',
|
'Heard great things about this league. Can I join?',
|
||||||
'Experienced driver looking for competitive racing.',
|
'Experienced driver looking for competitive racing.',
|
||||||
'My friend recommended this league. Hope to race with you!',
|
'My friend recommended this league. Hope to race with you!',
|
||||||
];
|
] as const;
|
||||||
|
const message =
|
||||||
|
messages[(index + leagueIndex) % messages.length] ?? messages[0];
|
||||||
seededJoinRequests.push({
|
seededJoinRequests.push({
|
||||||
id: `join-${league.id}-${driver.id}`,
|
id: `join-${league.id}-${driver.id}`,
|
||||||
leagueId: league.id,
|
leagueId: league.id,
|
||||||
driverId: driver.id,
|
driverId: driver.id,
|
||||||
requestedAt: new Date(Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000),
|
requestedAt: new Date(
|
||||||
message: messages[(index + leagueIndex) % messages.length],
|
Date.now() - (index + 1 + leagueIndex) * 24 * 60 * 60 * 1000,
|
||||||
|
),
|
||||||
|
message,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
type InMemoryLeagueMembershipSeed = ConstructorParameters<
|
||||||
|
typeof InMemoryLeagueMembershipRepository
|
||||||
|
>[0];
|
||||||
|
|
||||||
container.registerInstance<ILeagueMembershipRepository>(
|
container.registerInstance<ILeagueMembershipRepository>(
|
||||||
DI_TOKENS.LeagueMembershipRepository,
|
DI_TOKENS.LeagueMembershipRepository,
|
||||||
new InMemoryLeagueMembershipRepository(seededMemberships, seededJoinRequests)
|
new InMemoryLeagueMembershipRepository(
|
||||||
|
seededMemberships as InMemoryLeagueMembershipSeed,
|
||||||
|
seededJoinRequests,
|
||||||
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Team repositories
|
// Team repositories
|
||||||
|
type InMemoryTeamSeed = ConstructorParameters<typeof InMemoryTeamRepository>[0];
|
||||||
|
|
||||||
container.registerInstance<ITeamRepository>(
|
container.registerInstance<ITeamRepository>(
|
||||||
DI_TOKENS.TeamRepository,
|
DI_TOKENS.TeamRepository,
|
||||||
new InMemoryTeamRepository(
|
new InMemoryTeamRepository(
|
||||||
@@ -602,8 +663,8 @@ export function configureDIContainer(): void {
|
|||||||
ownerId: seedData.drivers[0]!.id,
|
ownerId: seedData.drivers[0]!.id,
|
||||||
leagues: [t.primaryLeagueId],
|
leagues: [t.primaryLeagueId],
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
}))
|
})) as InMemoryTeamSeed,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
container.registerInstance<ITeamMembershipRepository>(
|
container.registerInstance<ITeamMembershipRepository>(
|
||||||
@@ -644,16 +705,13 @@ export function configureDIContainer(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const sponsorRepo = new InMemorySponsorRepository();
|
const sponsorRepo = new InMemorySponsorRepository();
|
||||||
// Use synchronous seeding via internal method
|
sponsorRepo.seed(seededSponsors);
|
||||||
seededSponsors.forEach(sponsor => {
|
|
||||||
(sponsorRepo as any).sponsors.set(sponsor.id, sponsor);
|
|
||||||
});
|
|
||||||
container.registerInstance<ISponsorRepository>(
|
container.registerInstance<ISponsorRepository>(
|
||||||
DI_TOKENS.SponsorRepository,
|
DI_TOKENS.SponsorRepository,
|
||||||
sponsorRepo
|
sponsorRepo
|
||||||
);
|
);
|
||||||
|
|
||||||
const seededSponsorships = seedData.seasonSponsorships.map(ss =>
|
const seededSponsorships = seedData.seasonSponsorships.map((ss) =>
|
||||||
SeasonSponsorship.create({
|
SeasonSponsorship.create({
|
||||||
id: ss.id,
|
id: ss.id,
|
||||||
seasonId: ss.seasonId,
|
seasonId: ss.seasonId,
|
||||||
@@ -661,15 +719,12 @@ export function configureDIContainer(): void {
|
|||||||
tier: ss.tier,
|
tier: ss.tier,
|
||||||
pricing: Money.create(ss.pricingAmount, ss.pricingCurrency),
|
pricing: Money.create(ss.pricingAmount, ss.pricingCurrency),
|
||||||
status: ss.status,
|
status: ss.status,
|
||||||
description: ss.description,
|
description: ss.description ?? '',
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository();
|
const seasonSponsorshipRepo = new InMemorySeasonSponsorshipRepository();
|
||||||
// Use synchronous seeding via internal method
|
seasonSponsorshipRepo.seed(seededSponsorships);
|
||||||
seededSponsorships.forEach(sponsorship => {
|
|
||||||
(seasonSponsorshipRepo as any).sponsorships.set(sponsorship.id, sponsorship);
|
|
||||||
});
|
|
||||||
container.registerInstance<ISeasonSponsorshipRepository>(
|
container.registerInstance<ISeasonSponsorshipRepository>(
|
||||||
DI_TOKENS.SeasonSponsorshipRepository,
|
DI_TOKENS.SeasonSponsorshipRepository,
|
||||||
seasonSponsorshipRepo
|
seasonSponsorshipRepo
|
||||||
@@ -691,9 +746,9 @@ export function configureDIContainer(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Seed sponsorship requests from demo data
|
// Seed sponsorship requests from demo data
|
||||||
seedData.sponsorshipRequests?.forEach(request => {
|
if (seedData.sponsorshipRequests && seedData.sponsorshipRequests.length > 0) {
|
||||||
(sponsorshipRequestRepo as any).requests.set(request.id, request);
|
sponsorshipRequestRepo.seed(seedData.sponsorshipRequests);
|
||||||
});
|
}
|
||||||
|
|
||||||
// Social repositories
|
// Social repositories
|
||||||
container.registerInstance<IFeedRepository>(
|
container.registerInstance<IFeedRepository>(
|
||||||
@@ -732,7 +787,7 @@ export function configureDIContainer(): void {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Register driver stats for access by utility functions
|
// Register driver stats for access by utility functions
|
||||||
container.registerInstance(
|
container.registerInstance<DemoDriverStatsMap>(
|
||||||
DI_TOKENS.DriverStats,
|
DI_TOKENS.DriverStats,
|
||||||
driverStats
|
driverStats
|
||||||
);
|
);
|
||||||
@@ -741,7 +796,8 @@ export function configureDIContainer(): void {
|
|||||||
const driverRatingProvider: DriverRatingProvider = {
|
const driverRatingProvider: DriverRatingProvider = {
|
||||||
getRating: (driverId: string): number | null => {
|
getRating: (driverId: string): number | null => {
|
||||||
const stats = driverStats[driverId];
|
const stats = driverStats[driverId];
|
||||||
return stats?.rating ?? null;
|
const rating = stats?.rating;
|
||||||
|
return typeof rating === 'number' ? rating : null;
|
||||||
},
|
},
|
||||||
getRatings: (driverIds: string[]): Map<string, number> => {
|
getRatings: (driverIds: string[]): Map<string, number> => {
|
||||||
const result = new Map<string, number>();
|
const result = new Map<string, number>();
|
||||||
@@ -905,7 +961,7 @@ export function configureDIContainer(): void {
|
|||||||
const leagueStandingsPresenter = new LeagueStandingsPresenter();
|
const leagueStandingsPresenter = new LeagueStandingsPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetLeagueStandingsUseCase,
|
DI_TOKENS.GetLeagueStandingsUseCase,
|
||||||
new GetLeagueStandingsUseCase(standingRepository, leagueStandingsPresenter)
|
new GetLeagueStandingsUseCase(standingRepository),
|
||||||
);
|
);
|
||||||
|
|
||||||
const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter();
|
const leagueDriverSeasonStatsPresenter = new LeagueDriverSeasonStatsPresenter();
|
||||||
@@ -919,7 +975,7 @@ export function configureDIContainer(): void {
|
|||||||
{
|
{
|
||||||
getRating: (driverId: string) => {
|
getRating: (driverId: string) => {
|
||||||
const stats = driverStats[driverId];
|
const stats = driverStats[driverId];
|
||||||
if (!stats) {
|
if (!stats || typeof stats.rating !== 'number') {
|
||||||
return { rating: null, ratingChange: null };
|
return { rating: null, ratingChange: null };
|
||||||
}
|
}
|
||||||
const baseline = 1500;
|
const baseline = 1500;
|
||||||
@@ -930,8 +986,8 @@ export function configureDIContainer(): void {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
leagueDriverSeasonStatsPresenter
|
leagueDriverSeasonStatsPresenter,
|
||||||
)
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter();
|
const allLeaguesWithCapacityPresenter = new AllLeaguesWithCapacityPresenter();
|
||||||
@@ -961,7 +1017,7 @@ export function configureDIContainer(): void {
|
|||||||
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
|
const leagueScoringPresetsPresenter = new LeagueScoringPresetsPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.ListLeagueScoringPresetsUseCase,
|
DI_TOKENS.ListLeagueScoringPresetsUseCase,
|
||||||
new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider, leagueScoringPresetsPresenter)
|
new ListLeagueScoringPresetsUseCase(leagueScoringPresetProvider)
|
||||||
);
|
);
|
||||||
|
|
||||||
const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter();
|
const leagueScoringConfigPresenter = new LeagueScoringConfigPresenter();
|
||||||
@@ -977,7 +1033,6 @@ export function configureDIContainer(): void {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const leagueFullConfigPresenter = new LeagueFullConfigPresenter();
|
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetLeagueFullConfigUseCase,
|
DI_TOKENS.GetLeagueFullConfigUseCase,
|
||||||
new GetLeagueFullConfigUseCase(
|
new GetLeagueFullConfigUseCase(
|
||||||
@@ -985,14 +1040,13 @@ export function configureDIContainer(): void {
|
|||||||
seasonRepository,
|
seasonRepository,
|
||||||
leagueScoringConfigRepository,
|
leagueScoringConfigRepository,
|
||||||
gameRepository,
|
gameRepository,
|
||||||
leagueFullConfigPresenter
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter();
|
const leagueSchedulePreviewPresenter = new LeagueSchedulePreviewPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.PreviewLeagueScheduleUseCase,
|
DI_TOKENS.PreviewLeagueScheduleUseCase,
|
||||||
new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter)
|
new PreviewLeagueScheduleUseCase(undefined, leagueSchedulePreviewPresenter),
|
||||||
);
|
);
|
||||||
|
|
||||||
const raceWithSOFPresenter = new RaceWithSOFPresenter();
|
const raceWithSOFPresenter = new RaceWithSOFPresenter();
|
||||||
@@ -1031,6 +1085,8 @@ export function configureDIContainer(): void {
|
|||||||
new GetAllRacesPageDataUseCase(raceRepository, leagueRepository, allRacesPagePresenter)
|
new GetAllRacesPageDataUseCase(raceRepository, leagueRepository, allRacesPagePresenter)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const imageService = container.resolve<ImageServicePort>(DI_TOKENS.ImageService);
|
||||||
|
|
||||||
const raceDetailPresenter = new RaceDetailPresenter();
|
const raceDetailPresenter = new RaceDetailPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetRaceDetailUseCase,
|
DI_TOKENS.GetRaceDetailUseCase,
|
||||||
@@ -1075,38 +1131,38 @@ export function configureDIContainer(): void {
|
|||||||
// Create services for driver leaderboard query
|
// Create services for driver leaderboard query
|
||||||
const rankingService = {
|
const rankingService = {
|
||||||
getAllDriverRankings: () => {
|
getAllDriverRankings: () => {
|
||||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||||
return Object.entries(stats).map(([driverId, stat]) => ({
|
return Object.entries(stats)
|
||||||
|
.map(([driverId, stat]) => ({
|
||||||
driverId,
|
driverId,
|
||||||
rating: stat.rating,
|
rating: stat.rating ?? 0,
|
||||||
overallRank: stat.overallRank,
|
overallRank: stat.overallRank ?? null,
|
||||||
})).sort((a, b) => b.rating - a.rating);
|
}))
|
||||||
}
|
.sort((a, b) => (b.rating ?? 0) - (a.rating ?? 0));
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const driverStatsService = {
|
const driverStatsService = {
|
||||||
getDriverStats: (driverId: string) => {
|
getDriverStats: (driverId: string) => {
|
||||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||||
return stats[driverId] || null;
|
return stats[driverId] ?? null;
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const imageService = getDIContainer().resolve<ImageServicePort>(DI_TOKENS.ImageService);
|
|
||||||
|
|
||||||
const driversPresenter = new DriversLeaderboardPresenter();
|
const driversPresenter = new DriversLeaderboardPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetDriversLeaderboardUseCase,
|
DI_TOKENS.GetDriversLeaderboardUseCase,
|
||||||
new GetDriversLeaderboardUseCase(
|
new GetDriversLeaderboardUseCase(
|
||||||
driverRepository,
|
driverRepository,
|
||||||
rankingService as any,
|
rankingService,
|
||||||
driverStatsService as any,
|
driverStatsService,
|
||||||
imageService,
|
imageService,
|
||||||
driversPresenter
|
driversPresenter
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDriverStatsAdapter = (driverId: string) => {
|
const getDriverStatsAdapter = (driverId: string) => {
|
||||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||||
const stat = stats[driverId];
|
const stat = stats[driverId];
|
||||||
if (!stat) return null;
|
if (!stat) return null;
|
||||||
return {
|
return {
|
||||||
@@ -1116,7 +1172,6 @@ export function configureDIContainer(): void {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const teamsPresenter = new TeamsLeaderboardPresenter();
|
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetTeamsLeaderboardUseCase,
|
DI_TOKENS.GetTeamsLeaderboardUseCase,
|
||||||
new GetTeamsLeaderboardUseCase(
|
new GetTeamsLeaderboardUseCase(
|
||||||
@@ -1124,12 +1179,11 @@ export function configureDIContainer(): void {
|
|||||||
teamMembershipRepository,
|
teamMembershipRepository,
|
||||||
driverRepository,
|
driverRepository,
|
||||||
getDriverStatsAdapter,
|
getDriverStatsAdapter,
|
||||||
teamsPresenter
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
const getDriverStatsForDashboard = (driverId: string) => {
|
const getDriverStatsForDashboard = (driverId: string) => {
|
||||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||||
const stat = stats[driverId];
|
const stat = stats[driverId];
|
||||||
if (!stat) return null;
|
if (!stat) return null;
|
||||||
return {
|
return {
|
||||||
@@ -1143,7 +1197,7 @@ export function configureDIContainer(): void {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const getDriverStatsForProfile = (driverId: string) => {
|
const getDriverStatsForProfile = (driverId: string) => {
|
||||||
const stats = getDIContainer().resolve<Record<string, any>>(DI_TOKENS.DriverStats);
|
const stats = getDIContainer().resolve<DemoDriverStatsMap>(DI_TOKENS.DriverStats);
|
||||||
const stat = stats[driverId];
|
const stat = stats[driverId];
|
||||||
if (!stat) return null;
|
if (!stat) return null;
|
||||||
return {
|
return {
|
||||||
@@ -1204,7 +1258,7 @@ export function configureDIContainer(): void {
|
|||||||
const allTeamsPresenter = new AllTeamsPresenter();
|
const allTeamsPresenter = new AllTeamsPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetAllTeamsUseCase,
|
DI_TOKENS.GetAllTeamsUseCase,
|
||||||
new GetAllTeamsUseCase(teamRepository, teamMembershipRepository, allTeamsPresenter)
|
new GetAllTeamsUseCase(teamRepository, teamMembershipRepository),
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamDetailsPresenter = new TeamDetailsPresenter();
|
const teamDetailsPresenter = new TeamDetailsPresenter();
|
||||||
@@ -1216,13 +1270,18 @@ export function configureDIContainer(): void {
|
|||||||
const teamMembersPresenter = new TeamMembersPresenter();
|
const teamMembersPresenter = new TeamMembersPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetTeamMembersUseCase,
|
DI_TOKENS.GetTeamMembersUseCase,
|
||||||
new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter)
|
new GetTeamMembersUseCase(teamMembershipRepository, driverRepository, imageService, teamMembersPresenter),
|
||||||
);
|
);
|
||||||
|
|
||||||
const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter();
|
const teamJoinRequestsPresenter = new TeamJoinRequestsPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetTeamJoinRequestsUseCase,
|
DI_TOKENS.GetTeamJoinRequestsUseCase,
|
||||||
new GetTeamJoinRequestsUseCase(teamMembershipRepository, driverRepository, imageService, teamJoinRequestsPresenter)
|
new GetTeamJoinRequestsUseCase(
|
||||||
|
teamMembershipRepository,
|
||||||
|
driverRepository,
|
||||||
|
imageService,
|
||||||
|
teamJoinRequestsPresenter,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const driverTeamPresenter = new DriverTeamPresenter();
|
const driverTeamPresenter = new DriverTeamPresenter();
|
||||||
@@ -1235,13 +1294,13 @@ export function configureDIContainer(): void {
|
|||||||
const raceProtestsPresenter = new RaceProtestsPresenter();
|
const raceProtestsPresenter = new RaceProtestsPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetRaceProtestsUseCase,
|
DI_TOKENS.GetRaceProtestsUseCase,
|
||||||
new GetRaceProtestsUseCase(protestRepository, driverRepository, raceProtestsPresenter)
|
new GetRaceProtestsUseCase(protestRepository, driverRepository)
|
||||||
);
|
);
|
||||||
|
|
||||||
const racePenaltiesPresenter = new RacePenaltiesPresenter();
|
const racePenaltiesPresenter = new RacePenaltiesPresenter();
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetRacePenaltiesUseCase,
|
DI_TOKENS.GetRacePenaltiesUseCase,
|
||||||
new GetRacePenaltiesUseCase(penaltyRepository, driverRepository, racePenaltiesPresenter)
|
new GetRacePenaltiesUseCase(penaltyRepository, driverRepository)
|
||||||
);
|
);
|
||||||
|
|
||||||
// Register queries - Notifications
|
// Register queries - Notifications
|
||||||
@@ -1286,13 +1345,11 @@ export function configureDIContainer(): void {
|
|||||||
const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
|
const sponsorshipRequestRepository = container.resolve<ISponsorshipRequestRepository>(DI_TOKENS.SponsorshipRequestRepository);
|
||||||
const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
|
const sponsorshipPricingRepository = container.resolve<ISponsorshipPricingRepository>(DI_TOKENS.SponsorshipPricingRepository);
|
||||||
|
|
||||||
const pendingSponsorshipRequestsPresenter = new PendingSponsorshipRequestsPresenter();
|
|
||||||
container.registerInstance(
|
container.registerInstance(
|
||||||
DI_TOKENS.GetPendingSponsorshipRequestsUseCase,
|
DI_TOKENS.GetPendingSponsorshipRequestsUseCase,
|
||||||
new GetPendingSponsorshipRequestsUseCase(
|
new GetPendingSponsorshipRequestsUseCase(
|
||||||
sponsorshipRequestRepository,
|
sponsorshipRequestRepository,
|
||||||
sponsorRepository,
|
sponsorRepository,
|
||||||
pendingSponsorshipRequestsPresenter
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import { configureDIContainer, getDIContainer } from './di-config';
|
import { configureDIContainer, getDIContainer } from './di-config';
|
||||||
import { DI_TOKENS } from './di-tokens';
|
import { DI_TOKENS } from './di-tokens';
|
||||||
|
import { LeagueScoringPresetsPresenter } from './presenters/LeagueScoringPresetsPresenter';
|
||||||
|
|
||||||
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
import type { IDriverRepository } from '@gridpilot/racing/domain/repositories/IDriverRepository';
|
||||||
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
|
||||||
@@ -97,6 +98,7 @@ import type { TransferLeagueOwnershipUseCase } from '@gridpilot/racing/applicati
|
|||||||
import type { DriverRatingProvider } from '@gridpilot/racing/application';
|
import type { DriverRatingProvider } from '@gridpilot/racing/application';
|
||||||
import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application';
|
import type { PreviewLeagueScheduleUseCase } from '@gridpilot/racing/application';
|
||||||
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
import type { LeagueScoringPresetProvider } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||||
|
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
||||||
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
import type { GetDashboardOverviewUseCase } from '@gridpilot/racing/application/use-cases/GetDashboardOverviewUseCase';
|
||||||
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
|
import { createDemoDriverStats, getDemoLeagueRankings, type DriverStats } from '@gridpilot/testing-support';
|
||||||
|
|
||||||
@@ -613,6 +615,21 @@ export function getIsDriverRegisteredForRaceUseCase(): IsDriverRegisteredForRace
|
|||||||
return DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
|
return DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query facade for checking if a driver is registered for a race.
|
||||||
|
*/
|
||||||
|
export function getIsDriverRegisteredForRaceQuery(): {
|
||||||
|
execute(input: { raceId: string; driverId: string }): Promise<boolean>;
|
||||||
|
} {
|
||||||
|
const useCase = DIContainer.getInstance().isDriverRegisteredForRaceUseCase;
|
||||||
|
return {
|
||||||
|
async execute(input: { raceId: string; driverId: string }): Promise<boolean> {
|
||||||
|
const result = await useCase.execute(input);
|
||||||
|
return result as unknown as boolean;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase {
|
export function getGetRaceRegistrationsUseCase(): GetRaceRegistrationsUseCase {
|
||||||
return DIContainer.getInstance().getRaceRegistrationsUseCase;
|
return DIContainer.getInstance().getRaceRegistrationsUseCase;
|
||||||
}
|
}
|
||||||
@@ -649,6 +666,24 @@ export function getListLeagueScoringPresetsUseCase(): ListLeagueScoringPresetsUs
|
|||||||
return DIContainer.getInstance().listLeagueScoringPresetsUseCase;
|
return DIContainer.getInstance().listLeagueScoringPresetsUseCase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight query facade for listing league scoring presets.
|
||||||
|
* Returns an object with an execute() method for use in UI code.
|
||||||
|
*/
|
||||||
|
export function getListLeagueScoringPresetsQuery(): {
|
||||||
|
execute(): Promise<LeagueScoringPresetDTO[]>;
|
||||||
|
} {
|
||||||
|
const useCase = DIContainer.getInstance().listLeagueScoringPresetsUseCase;
|
||||||
|
return {
|
||||||
|
async execute(): Promise<LeagueScoringPresetDTO[]> {
|
||||||
|
const presenter = new LeagueScoringPresetsPresenter();
|
||||||
|
await useCase.execute(undefined as void, presenter);
|
||||||
|
const viewModel = presenter.getViewModel();
|
||||||
|
return viewModel.presets;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
export function getCreateLeagueWithSeasonAndScoringUseCase(): CreateLeagueWithSeasonAndScoringUseCase {
|
||||||
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
return DIContainer.getInstance().createLeagueWithSeasonAndScoringUseCase;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const leagueMemberships = new Map<string, LeagueMembership[]>();
|
|||||||
const memberships = await membershipRepo.getLeagueMembers(league.id);
|
const memberships = await membershipRepo.getLeagueMembers(league.id);
|
||||||
|
|
||||||
const mapped: LeagueMembership[] = memberships.map((membership) => ({
|
const mapped: LeagueMembership[] = memberships.map((membership) => ({
|
||||||
|
id: membership.id,
|
||||||
leagueId: membership.leagueId,
|
leagueId: membership.leagueId,
|
||||||
driverId: membership.driverId,
|
driverId: membership.driverId,
|
||||||
role: membership.role,
|
role: membership.role,
|
||||||
|
|||||||
@@ -53,13 +53,13 @@ export function validateLeagueWizardStep(
|
|||||||
|
|
||||||
// Use LeagueName value object for validation
|
// Use LeagueName value object for validation
|
||||||
const nameValidation = LeagueName.validate(form.basics.name);
|
const nameValidation = LeagueName.validate(form.basics.name);
|
||||||
if (!nameValidation.valid) {
|
if (!nameValidation.valid && nameValidation.error) {
|
||||||
basicsErrors.name = nameValidation.error;
|
basicsErrors.name = nameValidation.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use LeagueDescription value object for validation
|
// Use LeagueDescription value object for validation
|
||||||
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
|
const descValidation = LeagueDescription.validate(form.basics.description ?? '');
|
||||||
if (!descValidation.valid) {
|
if (!descValidation.valid && descValidation.error) {
|
||||||
basicsErrors.description = descValidation.error;
|
basicsErrors.description = descValidation.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,8 +92,10 @@ export function validateLeagueWizardStep(
|
|||||||
'Max drivers must be greater than 0 for solo leagues';
|
'Max drivers must be greater than 0 for solo leagues';
|
||||||
} else {
|
} else {
|
||||||
// Validate against game constraints
|
// Validate against game constraints
|
||||||
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
|
const driverValidation = gameConstraints.validateDriverCount(
|
||||||
if (!driverValidation.valid) {
|
form.structure.maxDrivers,
|
||||||
|
);
|
||||||
|
if (!driverValidation.valid && driverValidation.error) {
|
||||||
structureErrors.maxDrivers = driverValidation.error;
|
structureErrors.maxDrivers = driverValidation.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -103,8 +105,10 @@ export function validateLeagueWizardStep(
|
|||||||
'Max teams must be greater than 0 for team leagues';
|
'Max teams must be greater than 0 for team leagues';
|
||||||
} else {
|
} else {
|
||||||
// Validate against game constraints
|
// Validate against game constraints
|
||||||
const teamValidation = gameConstraints.validateTeamCount(form.structure.maxTeams);
|
const teamValidation = gameConstraints.validateTeamCount(
|
||||||
if (!teamValidation.valid) {
|
form.structure.maxTeams,
|
||||||
|
);
|
||||||
|
if (!teamValidation.valid && teamValidation.error) {
|
||||||
structureErrors.maxTeams = teamValidation.error;
|
structureErrors.maxTeams = teamValidation.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -114,8 +118,10 @@ export function validateLeagueWizardStep(
|
|||||||
}
|
}
|
||||||
// Validate total driver count
|
// Validate total driver count
|
||||||
if (form.structure.maxDrivers) {
|
if (form.structure.maxDrivers) {
|
||||||
const driverValidation = gameConstraints.validateDriverCount(form.structure.maxDrivers);
|
const driverValidation = gameConstraints.validateDriverCount(
|
||||||
if (!driverValidation.valid) {
|
form.structure.maxDrivers,
|
||||||
|
);
|
||||||
|
if (!driverValidation.valid && driverValidation.error) {
|
||||||
structureErrors.maxDrivers = driverValidation.error;
|
structureErrors.maxDrivers = driverValidation.error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -197,7 +203,7 @@ export function validateAllLeagueWizardSteps(
|
|||||||
|
|
||||||
export function hasWizardErrors(errors: WizardErrors): boolean {
|
export function hasWizardErrors(errors: WizardErrors): boolean {
|
||||||
return Object.keys(errors).some((key) => {
|
return Object.keys(errors).some((key) => {
|
||||||
const value = (errors as any)[key];
|
const value = errors[key as keyof WizardErrors];
|
||||||
if (!value) return false;
|
if (!value) return false;
|
||||||
if (typeof value === 'string') return true;
|
if (typeof value === 'string') return true;
|
||||||
return Object.keys(value).length > 0;
|
return Object.keys(value).length > 0;
|
||||||
@@ -213,27 +219,31 @@ export function buildCreateLeagueCommandFromConfig(
|
|||||||
ownerId: string,
|
ownerId: string,
|
||||||
): CreateLeagueWithSeasonAndScoringCommand {
|
): CreateLeagueWithSeasonAndScoringCommand {
|
||||||
const structure = form.structure;
|
const structure = form.structure;
|
||||||
let maxDrivers: number | undefined;
|
let maxDrivers: number;
|
||||||
let maxTeams: number | undefined;
|
let maxTeams: number;
|
||||||
|
|
||||||
if (structure.mode === 'solo') {
|
if (structure.mode === 'solo') {
|
||||||
maxDrivers =
|
maxDrivers =
|
||||||
typeof structure.maxDrivers === 'number' ? structure.maxDrivers : undefined;
|
typeof structure.maxDrivers === 'number' && structure.maxDrivers > 0
|
||||||
maxTeams = undefined;
|
? structure.maxDrivers
|
||||||
|
: 0;
|
||||||
|
maxTeams = 0;
|
||||||
} else {
|
} else {
|
||||||
const teams =
|
const teams =
|
||||||
typeof structure.maxTeams === 'number' ? structure.maxTeams : 0;
|
typeof structure.maxTeams === 'number' && structure.maxTeams > 0
|
||||||
|
? structure.maxTeams
|
||||||
|
: 0;
|
||||||
const perTeam =
|
const perTeam =
|
||||||
typeof structure.driversPerTeam === 'number'
|
typeof structure.driversPerTeam === 'number' && structure.driversPerTeam > 0
|
||||||
? structure.driversPerTeam
|
? structure.driversPerTeam
|
||||||
: 0;
|
: 0;
|
||||||
maxTeams = teams > 0 ? teams : undefined;
|
maxTeams = teams;
|
||||||
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : undefined;
|
maxDrivers = teams > 0 && perTeam > 0 ? teams * perTeam : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: form.basics.name.trim(),
|
name: form.basics.name.trim(),
|
||||||
description: form.basics.description?.trim() || undefined,
|
description: (form.basics.description ?? '').trim(),
|
||||||
visibility: form.basics.visibility,
|
visibility: form.basics.visibility,
|
||||||
ownerId,
|
ownerId,
|
||||||
gameId: form.basics.gameId,
|
gameId: form.basics.gameId,
|
||||||
@@ -243,7 +253,7 @@ export function buildCreateLeagueCommandFromConfig(
|
|||||||
enableTeamChampionship: form.championships.enableTeamChampionship,
|
enableTeamChampionship: form.championships.enableTeamChampionship,
|
||||||
enableNationsChampionship: form.championships.enableNationsChampionship,
|
enableNationsChampionship: form.championships.enableNationsChampionship,
|
||||||
enableTrophyChampionship: form.championships.enableTrophyChampionship,
|
enableTrophyChampionship: form.championships.enableTrophyChampionship,
|
||||||
scoringPresetId: form.scoring.patternId || undefined,
|
scoringPresetId: form.scoring.patternId ?? 'custom',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,8 +273,8 @@ export async function createLeagueFromConfig(
|
|||||||
if (!currentDriver) {
|
if (!currentDriver) {
|
||||||
const error = new Error(
|
const error = new Error(
|
||||||
'No driver profile found. Please create a driver profile first.',
|
'No driver profile found. Please create a driver profile first.',
|
||||||
);
|
) as Error & { code?: string };
|
||||||
(error as any).code = 'NO_DRIVER';
|
error.code = 'NO_DRIVER';
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,7 +293,9 @@ export function applyScoringPresetToConfig(
|
|||||||
): LeagueConfigFormModel {
|
): LeagueConfigFormModel {
|
||||||
const lowerPresetId = patternId.toLowerCase();
|
const lowerPresetId = patternId.toLowerCase();
|
||||||
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
|
const timings = form.timings ?? ({} as LeagueConfigFormModel['timings']);
|
||||||
let updatedTimings = { ...timings };
|
let updatedTimings: NonNullable<LeagueConfigFormModel['timings']> = {
|
||||||
|
...timings,
|
||||||
|
};
|
||||||
|
|
||||||
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
|
if (lowerPresetId.includes('sprint') || lowerPresetId.includes('double')) {
|
||||||
updatedTimings = {
|
updatedTimings = {
|
||||||
@@ -299,19 +311,19 @@ export function applyScoringPresetToConfig(
|
|||||||
...updatedTimings,
|
...updatedTimings,
|
||||||
practiceMinutes: 30,
|
practiceMinutes: 30,
|
||||||
qualifyingMinutes: 30,
|
qualifyingMinutes: 30,
|
||||||
sprintRaceMinutes: undefined,
|
|
||||||
mainRaceMinutes: 90,
|
mainRaceMinutes: 90,
|
||||||
sessionCount: 1,
|
sessionCount: 1,
|
||||||
};
|
};
|
||||||
|
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
||||||
} else {
|
} else {
|
||||||
updatedTimings = {
|
updatedTimings = {
|
||||||
...updatedTimings,
|
...updatedTimings,
|
||||||
practiceMinutes: 20,
|
practiceMinutes: 20,
|
||||||
qualifyingMinutes: 30,
|
qualifyingMinutes: 30,
|
||||||
sprintRaceMinutes: undefined,
|
|
||||||
mainRaceMinutes: 40,
|
mainRaceMinutes: 40,
|
||||||
sessionCount: 1,
|
sessionCount: 1,
|
||||||
};
|
};
|
||||||
|
delete (updatedTimings as { sprintRaceMinutes?: number }).sprintRaceMinutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -25,8 +25,8 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
|||||||
: 40;
|
: 40;
|
||||||
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
const timingSummary = `${qualifyingMinutes} min Quali • ${mainRaceMinutes} min Race`;
|
||||||
|
|
||||||
let scoringSummary: LeagueSummaryViewModel['scoring'] | undefined;
|
let scoringPatternSummary: string | null = null;
|
||||||
let scoringPatternSummary: string | undefined;
|
let scoringSummary: LeagueSummaryViewModel['scoring'];
|
||||||
|
|
||||||
if (season && scoringConfig && game) {
|
if (season && scoringConfig && game) {
|
||||||
const dropPolicySummary =
|
const dropPolicySummary =
|
||||||
@@ -47,9 +47,23 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
|||||||
dropPolicySummary,
|
dropPolicySummary,
|
||||||
scoringPatternSummary,
|
scoringPatternSummary,
|
||||||
};
|
};
|
||||||
|
} else {
|
||||||
|
const dropPolicySummary = 'All results count';
|
||||||
|
const scoringPresetName = 'Custom';
|
||||||
|
scoringPatternSummary = scoringPatternSummary ?? `${scoringPresetName} • ${dropPolicySummary}`;
|
||||||
|
|
||||||
|
scoringSummary = {
|
||||||
|
gameId: 'unknown',
|
||||||
|
gameName: 'Unknown',
|
||||||
|
primaryChampionshipType: 'driver',
|
||||||
|
scoringPresetId: 'custom',
|
||||||
|
scoringPresetName,
|
||||||
|
dropPolicySummary,
|
||||||
|
scoringPatternSummary,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
const base: LeagueSummaryViewModel = {
|
||||||
id: league.id,
|
id: league.id,
|
||||||
name: league.name,
|
name: league.name,
|
||||||
description: league.description,
|
description: league.description,
|
||||||
@@ -57,13 +71,16 @@ export class AllLeaguesWithCapacityAndScoringPresenter implements IAllLeaguesWit
|
|||||||
createdAt: league.createdAt,
|
createdAt: league.createdAt,
|
||||||
maxDrivers: safeMaxDrivers,
|
maxDrivers: safeMaxDrivers,
|
||||||
usedDriverSlots,
|
usedDriverSlots,
|
||||||
maxTeams: undefined,
|
// Team capacity is not yet modeled here; use zero for now to satisfy strict typing.
|
||||||
usedTeamSlots: undefined,
|
maxTeams: 0,
|
||||||
|
usedTeamSlots: 0,
|
||||||
structureSummary,
|
structureSummary,
|
||||||
scoringPatternSummary,
|
scoringPatternSummary: scoringPatternSummary ?? '',
|
||||||
timingSummary,
|
timingSummary,
|
||||||
scoring: scoringSummary,
|
scoring: scoringSummary,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return base;
|
||||||
});
|
});
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
|
|||||||
const configuredMax = league.settings.maxDrivers ?? usedSlots;
|
const configuredMax = league.settings.maxDrivers ?? usedSlots;
|
||||||
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
|
const safeMaxDrivers = Math.max(configuredMax, usedSlots);
|
||||||
|
|
||||||
return {
|
const base: LeagueWithCapacityViewModel = {
|
||||||
id: league.id,
|
id: league.id,
|
||||||
name: league.name,
|
name: league.name,
|
||||||
description: league.description,
|
description: league.description,
|
||||||
@@ -30,15 +30,33 @@ export class AllLeaguesWithCapacityPresenter implements IAllLeaguesWithCapacityP
|
|||||||
maxDrivers: safeMaxDrivers,
|
maxDrivers: safeMaxDrivers,
|
||||||
},
|
},
|
||||||
createdAt: league.createdAt.toISOString(),
|
createdAt: league.createdAt.toISOString(),
|
||||||
socialLinks: league.socialLinks
|
|
||||||
? {
|
|
||||||
discordUrl: league.socialLinks.discordUrl,
|
|
||||||
youtubeUrl: league.socialLinks.youtubeUrl,
|
|
||||||
websiteUrl: league.socialLinks.websiteUrl,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
usedSlots,
|
usedSlots,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (!league.socialLinks) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
const socialLinks: NonNullable<LeagueWithCapacityViewModel['socialLinks']> = {};
|
||||||
|
|
||||||
|
if (league.socialLinks.discordUrl) {
|
||||||
|
socialLinks.discordUrl = league.socialLinks.discordUrl;
|
||||||
|
}
|
||||||
|
if (league.socialLinks.youtubeUrl) {
|
||||||
|
socialLinks.youtubeUrl = league.socialLinks.youtubeUrl;
|
||||||
|
}
|
||||||
|
if (league.socialLinks.websiteUrl) {
|
||||||
|
socialLinks.websiteUrl = league.socialLinks.websiteUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(socialLinks).length === 0) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
socialLinks,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
import type { Team } from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
import type {
|
import type {
|
||||||
IAllTeamsPresenter,
|
IAllTeamsPresenter,
|
||||||
TeamListItemViewModel,
|
TeamListItemViewModel,
|
||||||
AllTeamsViewModel,
|
AllTeamsViewModel,
|
||||||
|
AllTeamsResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
|
} from '@gridpilot/racing/application/presenters/IAllTeamsPresenter';
|
||||||
|
|
||||||
export class AllTeamsPresenter implements IAllTeamsPresenter {
|
export class AllTeamsPresenter implements IAllTeamsPresenter {
|
||||||
private viewModel: AllTeamsViewModel | null = null;
|
private viewModel: AllTeamsViewModel | null = null;
|
||||||
|
|
||||||
present(teams: Array<Team & { memberCount?: number }>): AllTeamsViewModel {
|
reset(): void {
|
||||||
const teamItems: TeamListItemViewModel[] = teams.map((team) => ({
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(input: AllTeamsResultDTO): void {
|
||||||
|
const teamItems: TeamListItemViewModel[] = input.teams.map((team) => ({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
tag: team.tag,
|
tag: team.tag,
|
||||||
description: team.description,
|
description: team.description,
|
||||||
memberCount: team.memberCount ?? 0,
|
memberCount: team.memberCount ?? 0,
|
||||||
leagues: team.leagues,
|
leagues: team.leagues,
|
||||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
|
||||||
region: team.region,
|
|
||||||
languages: team.languages,
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
teams: teamItems,
|
teams: teamItems,
|
||||||
totalCount: teamItems.length,
|
totalCount: teamItems.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): AllTeamsViewModel {
|
getViewModel(): AllTeamsViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
import type {
|
import type {
|
||||||
IDriverTeamPresenter,
|
IDriverTeamPresenter,
|
||||||
DriverTeamViewModel,
|
DriverTeamViewModel,
|
||||||
|
DriverTeamResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
} from '@gridpilot/racing/application/presenters/IDriverTeamPresenter';
|
||||||
|
|
||||||
export class DriverTeamPresenter implements IDriverTeamPresenter {
|
export class DriverTeamPresenter implements IDriverTeamPresenter {
|
||||||
private viewModel: DriverTeamViewModel | null = null;
|
private viewModel: DriverTeamViewModel | null = null;
|
||||||
|
|
||||||
present(
|
reset(): void {
|
||||||
team: Team,
|
this.viewModel = null;
|
||||||
membership: TeamMembership,
|
}
|
||||||
driverId: string
|
|
||||||
): DriverTeamViewModel {
|
present(input: DriverTeamResultDTO): void {
|
||||||
|
const { team, membership, driverId } = input;
|
||||||
|
|
||||||
const isOwner = team.ownerId === driverId;
|
const isOwner = team.ownerId === driverId;
|
||||||
const canManage = membership.role === 'owner' || membership.role === 'manager';
|
const canManage = membership.role === 'owner' || membership.role === 'manager';
|
||||||
|
|
||||||
@@ -23,26 +25,18 @@ export class DriverTeamPresenter implements IDriverTeamPresenter {
|
|||||||
description: team.description,
|
description: team.description,
|
||||||
ownerId: team.ownerId,
|
ownerId: team.ownerId,
|
||||||
leagues: team.leagues,
|
leagues: team.leagues,
|
||||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
|
||||||
region: team.region,
|
|
||||||
languages: team.languages,
|
|
||||||
},
|
},
|
||||||
membership: {
|
membership: {
|
||||||
role: membership.role,
|
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||||
joinedAt: membership.joinedAt.toISOString(),
|
joinedAt: membership.joinedAt.toISOString(),
|
||||||
isActive: membership.isActive,
|
isActive: membership.status === 'active',
|
||||||
},
|
},
|
||||||
isOwner,
|
isOwner,
|
||||||
canManage,
|
canManage,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): DriverTeamViewModel {
|
getViewModel(): DriverTeamViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { IEntitySponsorshipPricingPresenter } from '@racing/application/presenters/IEntitySponsorshipPricingPresenter';
|
import type { IEntitySponsorshipPricingPresenter } from '@gridpilot/racing/application/presenters/IEntitySponsorshipPricingPresenter';
|
||||||
import type { GetEntitySponsorshipPricingResultDTO } from '@racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
import type { GetEntitySponsorshipPricingResultDTO } from '@gridpilot/racing/application/use-cases/GetEntitySponsorshipPricingUseCase';
|
||||||
|
|
||||||
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
|
export class EntitySponsorshipPricingPresenter implements IEntitySponsorshipPricingPresenter {
|
||||||
private data: GetEntitySponsorshipPricingResultDTO | null = null;
|
private data: GetEntitySponsorshipPricingResultDTO | null = null;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import type { Protest } from '@gridpilot/racing/domain/entities/Protest';
|
|||||||
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
import type { Race } from '@gridpilot/racing/domain/entities/Race';
|
||||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||||
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
import type { LeagueConfigFormModel } from '@gridpilot/racing/application';
|
||||||
|
import type { LeagueConfigFormViewModel } from '@gridpilot/racing/application/presenters/ILeagueFullConfigPresenter';
|
||||||
|
import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter';
|
||||||
import type { MembershipRole } from '@/lib/leagueMembership';
|
import type { MembershipRole } from '@/lib/leagueMembership';
|
||||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||||
import {
|
import {
|
||||||
@@ -38,6 +40,14 @@ export interface LeagueOwnerSummaryViewModel {
|
|||||||
rank: number | null;
|
rank: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LeagueSummaryViewModel {
|
||||||
|
id: string;
|
||||||
|
ownerId: string;
|
||||||
|
settings: {
|
||||||
|
pointsSystem: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface LeagueAdminProtestsViewModel {
|
export interface LeagueAdminProtestsViewModel {
|
||||||
protests: Protest[];
|
protests: Protest[];
|
||||||
racesById: ProtestRaceSummary;
|
racesById: ProtestRaceSummary;
|
||||||
@@ -79,14 +89,23 @@ export async function loadLeagueJoinRequests(leagueId: string): Promise<LeagueJo
|
|||||||
driversById[dto.id] = dto;
|
driversById[dto.id] = dto;
|
||||||
}
|
}
|
||||||
|
|
||||||
return requests.map((request) => ({
|
return requests.map((request) => {
|
||||||
|
const base: LeagueJoinRequestViewModel = {
|
||||||
id: request.id,
|
id: request.id,
|
||||||
leagueId: request.leagueId,
|
leagueId: request.leagueId,
|
||||||
driverId: request.driverId,
|
driverId: request.driverId,
|
||||||
requestedAt: request.requestedAt,
|
requestedAt: request.requestedAt,
|
||||||
message: request.message,
|
};
|
||||||
driver: driversById[request.driverId],
|
|
||||||
}));
|
const message = request.message;
|
||||||
|
const driver = driversById[request.driverId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(typeof message === 'string' && message.length > 0 ? { message } : {}),
|
||||||
|
...(driver ? { driver } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,6 +123,7 @@ export async function approveLeagueJoinRequest(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await membershipRepo.saveMembership({
|
await membershipRepo.saveMembership({
|
||||||
|
id: request.id,
|
||||||
leagueId: request.leagueId,
|
leagueId: request.leagueId,
|
||||||
driverId: request.driverId,
|
driverId: request.driverId,
|
||||||
role: 'member',
|
role: 'member',
|
||||||
@@ -203,12 +223,17 @@ export async function updateLeagueMemberRole(
|
|||||||
/**
|
/**
|
||||||
* Load owner summary (DTO + rating/rank) for a league.
|
* Load owner summary (DTO + rating/rank) for a league.
|
||||||
*/
|
*/
|
||||||
export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwnerSummaryViewModel | null> {
|
export async function loadLeagueOwnerSummary(params: {
|
||||||
|
ownerId: string;
|
||||||
|
}): Promise<LeagueOwnerSummaryViewModel | null> {
|
||||||
const driverRepo = getDriverRepository();
|
const driverRepo = getDriverRepository();
|
||||||
const entity = await driverRepo.findById(league.ownerId);
|
const entity = await driverRepo.findById(params.ownerId);
|
||||||
if (!entity) return null;
|
if (!entity) return null;
|
||||||
|
|
||||||
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
const ownerDriver = EntityMappers.toDriverDTO(entity);
|
||||||
|
if (!ownerDriver) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
const stats = getDriverStats(ownerDriver.id);
|
const stats = getDriverStats(ownerDriver.id);
|
||||||
const allRankings = getAllDriverRankings();
|
const allRankings = getAllDriverRankings();
|
||||||
|
|
||||||
@@ -243,10 +268,52 @@ export async function loadLeagueOwnerSummary(league: League): Promise<LeagueOwne
|
|||||||
/**
|
/**
|
||||||
* Load league full config form.
|
* Load league full config form.
|
||||||
*/
|
*/
|
||||||
export async function loadLeagueConfig(leagueId: string): Promise<LeagueAdminConfigViewModel> {
|
export async function loadLeagueConfig(
|
||||||
|
leagueId: string,
|
||||||
|
): Promise<LeagueAdminConfigViewModel> {
|
||||||
const useCase = getGetLeagueFullConfigUseCase();
|
const useCase = getGetLeagueFullConfigUseCase();
|
||||||
const form = await useCase.execute({ leagueId });
|
const presenter = new LeagueFullConfigPresenter();
|
||||||
return { form };
|
|
||||||
|
await useCase.execute({ leagueId }, presenter);
|
||||||
|
const fullConfig = presenter.getViewModel();
|
||||||
|
|
||||||
|
if (!fullConfig) {
|
||||||
|
return { form: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const formModel: LeagueConfigFormModel = {
|
||||||
|
leagueId: fullConfig.leagueId,
|
||||||
|
basics: {
|
||||||
|
...fullConfig.basics,
|
||||||
|
visibility: fullConfig.basics.visibility as LeagueConfigFormModel['basics']['visibility'],
|
||||||
|
},
|
||||||
|
structure: {
|
||||||
|
...fullConfig.structure,
|
||||||
|
mode: fullConfig.structure.mode as LeagueConfigFormModel['structure']['mode'],
|
||||||
|
},
|
||||||
|
championships: fullConfig.championships,
|
||||||
|
scoring: fullConfig.scoring,
|
||||||
|
dropPolicy: {
|
||||||
|
strategy: fullConfig.dropPolicy.strategy as LeagueConfigFormModel['dropPolicy']['strategy'],
|
||||||
|
...(fullConfig.dropPolicy.n !== undefined ? { n: fullConfig.dropPolicy.n } : {}),
|
||||||
|
},
|
||||||
|
timings: fullConfig.timings,
|
||||||
|
stewarding: {
|
||||||
|
decisionMode: fullConfig.stewarding.decisionMode as LeagueConfigFormModel['stewarding']['decisionMode'],
|
||||||
|
...(fullConfig.stewarding.requiredVotes !== undefined
|
||||||
|
? { requiredVotes: fullConfig.stewarding.requiredVotes }
|
||||||
|
: {}),
|
||||||
|
requireDefense: fullConfig.stewarding.requireDefense,
|
||||||
|
defenseTimeLimit: fullConfig.stewarding.defenseTimeLimit,
|
||||||
|
voteTimeLimit: fullConfig.stewarding.voteTimeLimit,
|
||||||
|
protestDeadlineHours: fullConfig.stewarding.protestDeadlineHours,
|
||||||
|
stewardingClosesHours: fullConfig.stewarding.stewardingClosesHours,
|
||||||
|
notifyAccusedOnProtest: fullConfig.stewarding.notifyAccusedOnProtest,
|
||||||
|
notifyOnVoteRequired: fullConfig.stewarding.notifyOnVoteRequired,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return { form: formModel };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export class LeagueDriverSeasonStatsPresenter implements ILeagueDriverSeasonStat
|
|||||||
driverId: standing.driverId,
|
driverId: standing.driverId,
|
||||||
position: standing.position,
|
position: standing.position,
|
||||||
driverName: '',
|
driverName: '',
|
||||||
teamId: undefined,
|
teamId: '',
|
||||||
teamName: undefined,
|
teamName: '',
|
||||||
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
|
totalPoints: standing.points + totalPenaltyPoints + bonusPoints,
|
||||||
basePoints: standing.points,
|
basePoints: standing.points,
|
||||||
penaltyPoints: Math.abs(totalPenaltyPoints),
|
penaltyPoints: Math.abs(totalPenaltyPoints),
|
||||||
|
|||||||
@@ -8,7 +8,11 @@ import type {
|
|||||||
export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
||||||
private viewModel: LeagueConfigFormViewModel | null = null;
|
private viewModel: LeagueConfigFormViewModel | null = null;
|
||||||
|
|
||||||
present(data: LeagueFullConfigData): LeagueConfigFormViewModel {
|
reset(): void {
|
||||||
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(data: LeagueFullConfigData): void {
|
||||||
const { league, activeSeason, scoringConfig, game } = data;
|
const { league, activeSeason, scoringConfig, game } = data;
|
||||||
|
|
||||||
const patternId = scoringConfig?.scoringPresetId;
|
const patternId = scoringConfig?.scoringPresetId;
|
||||||
@@ -32,12 +36,8 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
|||||||
const roundsPlanned = 8;
|
const roundsPlanned = 8;
|
||||||
|
|
||||||
let sessionCount = 2;
|
let sessionCount = 2;
|
||||||
if (
|
if (primaryChampionship && Array.isArray(primaryChampionship.sessionTypes)) {
|
||||||
primaryChampionship &&
|
sessionCount = primaryChampionship.sessionTypes.length;
|
||||||
Array.isArray((primaryChampionship as any).sessionTypes) &&
|
|
||||||
(primaryChampionship as any).sessionTypes.length > 0
|
|
||||||
) {
|
|
||||||
sessionCount = (primaryChampionship as any).sessionTypes.length;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const practiceMinutes = 20;
|
const practiceMinutes = 20;
|
||||||
@@ -54,8 +54,6 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
|||||||
structure: {
|
structure: {
|
||||||
mode: 'solo',
|
mode: 'solo',
|
||||||
maxDrivers: league.settings.maxDrivers ?? 32,
|
maxDrivers: league.settings.maxDrivers ?? 32,
|
||||||
maxTeams: undefined,
|
|
||||||
driversPerTeam: undefined,
|
|
||||||
multiClassEnabled: false,
|
multiClassEnabled: false,
|
||||||
},
|
},
|
||||||
championships: {
|
championships: {
|
||||||
@@ -65,17 +63,19 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
|||||||
enableTrophyChampionship: false,
|
enableTrophyChampionship: false,
|
||||||
},
|
},
|
||||||
scoring: {
|
scoring: {
|
||||||
patternId: patternId ?? undefined,
|
|
||||||
customScoringEnabled: !patternId,
|
customScoringEnabled: !patternId,
|
||||||
|
...(patternId ? { patternId } : {}),
|
||||||
},
|
},
|
||||||
dropPolicy: dropPolicyForm,
|
dropPolicy: dropPolicyForm,
|
||||||
timings: {
|
timings: {
|
||||||
practiceMinutes,
|
practiceMinutes,
|
||||||
qualifyingMinutes,
|
qualifyingMinutes,
|
||||||
sprintRaceMinutes,
|
|
||||||
mainRaceMinutes,
|
mainRaceMinutes,
|
||||||
sessionCount,
|
sessionCount,
|
||||||
roundsPlanned,
|
roundsPlanned,
|
||||||
|
...(typeof sprintRaceMinutes === 'number'
|
||||||
|
? { sprintRaceMinutes }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
stewarding: {
|
stewarding: {
|
||||||
decisionMode: 'admin_only',
|
decisionMode: 'admin_only',
|
||||||
@@ -88,11 +88,9 @@ export class LeagueFullConfigPresenter implements ILeagueFullConfigPresenter {
|
|||||||
notifyOnVoteRequired: true,
|
notifyOnVoteRequired: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): LeagueConfigFormViewModel {
|
getViewModel(): LeagueConfigFormViewModel | null {
|
||||||
if (!this.viewModel) {
|
if (!this.viewModel) {
|
||||||
throw new Error('Presenter has not been called yet');
|
throw new Error('Presenter has not been called yet');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ILeagueSchedulePreviewPresenter } from '@racing/application/presenters/ILeagueSchedulePreviewPresenter';
|
import type { ILeagueSchedulePreviewPresenter } from '@gridpilot/racing/application/presenters/ILeagueSchedulePreviewPresenter';
|
||||||
import type { LeagueSchedulePreviewDTO } from '@racing/application/dto/LeagueScheduleDTO';
|
import type { LeagueSchedulePreviewDTO } from '@gridpilot/racing/application/dto/LeagueScheduleDTO';
|
||||||
|
|
||||||
export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter {
|
export class LeagueSchedulePreviewPresenter implements ILeagueSchedulePreviewPresenter {
|
||||||
private data: LeagueSchedulePreviewDTO | null = null;
|
private data: LeagueSchedulePreviewDTO | null = null;
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
|
|||||||
seasonId: data.seasonId,
|
seasonId: data.seasonId,
|
||||||
gameId: data.gameId,
|
gameId: data.gameId,
|
||||||
gameName: data.gameName,
|
gameName: data.gameName,
|
||||||
scoringPresetId: data.scoringPresetId,
|
scoringPresetId: data.scoringPresetId ?? 'custom',
|
||||||
scoringPresetName: data.preset?.name,
|
scoringPresetName: data.preset?.name ?? 'Custom',
|
||||||
dropPolicySummary,
|
dropPolicySummary,
|
||||||
championships,
|
championships,
|
||||||
};
|
};
|
||||||
@@ -61,7 +61,7 @@ export class LeagueScoringConfigPresenter implements ILeagueScoringConfigPresent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildPointsPreview(
|
private buildPointsPreview(
|
||||||
tables: Record<string, any>,
|
tables: Record<string, { getPointsForPosition: (position: number) => number }>,
|
||||||
): Array<{ sessionType: string; position: number; points: number }> {
|
): Array<{ sessionType: string; position: number; points: number }> {
|
||||||
const preview: Array<{
|
const preview: Array<{
|
||||||
sessionType: string;
|
sessionType: string;
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import type { LeagueScoringPresetDTO } from '@gridpilot/racing/application/ports/LeagueScoringPresetProvider';
|
|
||||||
import type {
|
import type {
|
||||||
ILeagueScoringPresetsPresenter,
|
ILeagueScoringPresetsPresenter,
|
||||||
LeagueScoringPresetsViewModel,
|
LeagueScoringPresetsViewModel,
|
||||||
|
LeagueScoringPresetsResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter';
|
} from '@gridpilot/racing/application/presenters/ILeagueScoringPresetsPresenter';
|
||||||
|
|
||||||
export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter {
|
export class LeagueScoringPresetsPresenter implements ILeagueScoringPresetsPresenter {
|
||||||
private viewModel: LeagueScoringPresetsViewModel | null = null;
|
private viewModel: LeagueScoringPresetsViewModel | null = null;
|
||||||
|
|
||||||
present(presets: LeagueScoringPresetDTO[]): LeagueScoringPresetsViewModel {
|
reset(): void {
|
||||||
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(dto: LeagueScoringPresetsResultDTO): void {
|
||||||
|
const { presets } = dto;
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
presets,
|
presets,
|
||||||
totalCount: presets.length,
|
totalCount: presets.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): LeagueScoringPresetsViewModel {
|
getViewModel(): LeagueScoringPresetsViewModel {
|
||||||
|
|||||||
@@ -1,38 +1,44 @@
|
|||||||
import type { Standing } from '@gridpilot/racing/domain/entities/Standing';
|
|
||||||
import type {
|
import type {
|
||||||
ILeagueStandingsPresenter,
|
ILeagueStandingsPresenter,
|
||||||
StandingItemViewModel,
|
LeagueStandingsResultDTO,
|
||||||
LeagueStandingsViewModel,
|
LeagueStandingsViewModel,
|
||||||
|
StandingItemViewModel,
|
||||||
} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter';
|
} from '@gridpilot/racing/application/presenters/ILeagueStandingsPresenter';
|
||||||
|
|
||||||
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
export class LeagueStandingsPresenter implements ILeagueStandingsPresenter {
|
||||||
private viewModel: LeagueStandingsViewModel | null = null;
|
private viewModel: LeagueStandingsViewModel | null = null;
|
||||||
|
|
||||||
present(standings: Standing[]): LeagueStandingsViewModel {
|
reset(): void {
|
||||||
const standingItems: StandingItemViewModel[] = standings.map((standing) => ({
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(dto: LeagueStandingsResultDTO): void {
|
||||||
|
const standingItems: StandingItemViewModel[] = dto.standings.map((standing) => {
|
||||||
|
const raw = standing as unknown as {
|
||||||
|
seasonId?: string;
|
||||||
|
podiums?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
id: standing.id,
|
id: standing.id,
|
||||||
leagueId: standing.leagueId,
|
leagueId: standing.leagueId,
|
||||||
seasonId: standing.seasonId,
|
seasonId: raw.seasonId ?? '',
|
||||||
driverId: standing.driverId,
|
driverId: standing.driverId,
|
||||||
position: standing.position,
|
position: standing.position,
|
||||||
points: standing.points,
|
points: standing.points,
|
||||||
wins: standing.wins,
|
wins: standing.wins,
|
||||||
podiums: standing.podiums,
|
podiums: raw.podiums ?? 0,
|
||||||
racesCompleted: standing.racesCompleted,
|
racesCompleted: standing.racesCompleted,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
leagueId: standings[0]?.leagueId ?? '',
|
leagueId: dto.standings[0]?.leagueId ?? '',
|
||||||
standings: standingItems,
|
standings: standingItems,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): LeagueStandingsViewModel {
|
getViewModel(): LeagueStandingsViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,14 +1,21 @@
|
|||||||
import type { IPendingSponsorshipRequestsPresenter } from '@racing/application/presenters/IPendingSponsorshipRequestsPresenter';
|
import type {
|
||||||
import type { GetPendingSponsorshipRequestsResultDTO } from '@racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
IPendingSponsorshipRequestsPresenter,
|
||||||
|
PendingSponsorshipRequestsViewModel,
|
||||||
|
} from '@gridpilot/racing/application/presenters/IPendingSponsorshipRequestsPresenter';
|
||||||
|
import type { GetPendingSponsorshipRequestsResultDTO } from '@gridpilot/racing/application/use-cases/GetPendingSponsorshipRequestsUseCase';
|
||||||
|
|
||||||
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
|
export class PendingSponsorshipRequestsPresenter implements IPendingSponsorshipRequestsPresenter {
|
||||||
private data: GetPendingSponsorshipRequestsResultDTO | null = null;
|
private viewModel: PendingSponsorshipRequestsViewModel | null = null;
|
||||||
|
|
||||||
|
reset(): void {
|
||||||
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
present(data: GetPendingSponsorshipRequestsResultDTO): void {
|
present(data: GetPendingSponsorshipRequestsResultDTO): void {
|
||||||
this.data = data;
|
this.viewModel = data;
|
||||||
}
|
}
|
||||||
|
|
||||||
getData(): GetPendingSponsorshipRequestsResultDTO | null {
|
getViewModel(): PendingSponsorshipRequestsViewModel | null {
|
||||||
return this.data;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,60 +1,55 @@
|
|||||||
import type {
|
import type {
|
||||||
IRacePenaltiesPresenter,
|
IRacePenaltiesPresenter,
|
||||||
RacePenaltyViewModel,
|
RacePenaltyViewModel,
|
||||||
|
RacePenaltiesResultDTO,
|
||||||
RacePenaltiesViewModel,
|
RacePenaltiesViewModel,
|
||||||
} from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
} from '@gridpilot/racing/application/presenters/IRacePenaltiesPresenter';
|
||||||
import type { PenaltyType, PenaltyStatus } from '@gridpilot/racing/domain/entities/Penalty';
|
|
||||||
|
|
||||||
export class RacePenaltiesPresenter implements IRacePenaltiesPresenter {
|
export class RacePenaltiesPresenter implements IRacePenaltiesPresenter {
|
||||||
private viewModel: RacePenaltiesViewModel | null = null;
|
private viewModel: RacePenaltiesViewModel | null = null;
|
||||||
|
|
||||||
present(
|
reset(): void {
|
||||||
penalties: Array<{
|
this.viewModel = null;
|
||||||
id: string;
|
}
|
||||||
raceId: string;
|
|
||||||
driverId: string;
|
present(dto: RacePenaltiesResultDTO): void {
|
||||||
type: PenaltyType;
|
const { penalties, driverMap } = dto;
|
||||||
value?: number;
|
|
||||||
reason: string;
|
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map((penalty) => {
|
||||||
protestId?: string;
|
const value = typeof penalty.value === 'number' ? penalty.value : 0;
|
||||||
issuedBy: string;
|
const protestId = penalty.protestId;
|
||||||
status: PenaltyStatus;
|
const appliedAt = penalty.appliedAt ? penalty.appliedAt.toISOString() : undefined;
|
||||||
issuedAt: Date;
|
const notes = penalty.notes;
|
||||||
appliedAt?: Date;
|
|
||||||
notes?: string;
|
const base: RacePenaltyViewModel = {
|
||||||
getDescription(): string;
|
|
||||||
}>,
|
|
||||||
driverMap: Map<string, string>
|
|
||||||
): RacePenaltiesViewModel {
|
|
||||||
const penaltyViewModels: RacePenaltyViewModel[] = penalties.map(penalty => ({
|
|
||||||
id: penalty.id,
|
id: penalty.id,
|
||||||
raceId: penalty.raceId,
|
raceId: penalty.raceId,
|
||||||
driverId: penalty.driverId,
|
driverId: penalty.driverId,
|
||||||
driverName: driverMap.get(penalty.driverId) || 'Unknown',
|
driverName: driverMap.get(penalty.driverId) || 'Unknown',
|
||||||
type: penalty.type,
|
type: penalty.type,
|
||||||
value: penalty.value,
|
value,
|
||||||
reason: penalty.reason,
|
reason: penalty.reason,
|
||||||
protestId: penalty.protestId,
|
|
||||||
issuedBy: penalty.issuedBy,
|
issuedBy: penalty.issuedBy,
|
||||||
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
|
issuedByName: driverMap.get(penalty.issuedBy) || 'Unknown',
|
||||||
status: penalty.status,
|
status: penalty.status,
|
||||||
description: penalty.getDescription(),
|
description: penalty.getDescription(),
|
||||||
issuedAt: penalty.issuedAt.toISOString(),
|
issuedAt: penalty.issuedAt.toISOString(),
|
||||||
appliedAt: penalty.appliedAt?.toISOString(),
|
};
|
||||||
notes: penalty.notes,
|
|
||||||
}));
|
return {
|
||||||
|
...base,
|
||||||
|
...(protestId ? { protestId } : {}),
|
||||||
|
...(appliedAt ? { appliedAt } : {}),
|
||||||
|
...(typeof notes === 'string' && notes.length > 0 ? { notes } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
penalties: penaltyViewModels,
|
penalties: penaltyViewModels,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RacePenaltiesViewModel {
|
getViewModel(): RacePenaltiesViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,31 +1,22 @@
|
|||||||
import type {
|
import type {
|
||||||
IRaceProtestsPresenter,
|
IRaceProtestsPresenter,
|
||||||
RaceProtestViewModel,
|
RaceProtestViewModel,
|
||||||
|
RaceProtestsResultDTO,
|
||||||
RaceProtestsViewModel,
|
RaceProtestsViewModel,
|
||||||
} from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
} from '@gridpilot/racing/application/presenters/IRaceProtestsPresenter';
|
||||||
import type { ProtestStatus, ProtestIncident } from '@gridpilot/racing/domain/entities/Protest';
|
|
||||||
|
|
||||||
export class RaceProtestsPresenter implements IRaceProtestsPresenter {
|
export class RaceProtestsPresenter implements IRaceProtestsPresenter {
|
||||||
private viewModel: RaceProtestsViewModel | null = null;
|
private viewModel: RaceProtestsViewModel | null = null;
|
||||||
|
|
||||||
present(
|
reset(): void {
|
||||||
protests: Array<{
|
this.viewModel = null;
|
||||||
id: string;
|
}
|
||||||
raceId: string;
|
|
||||||
protestingDriverId: string;
|
present(dto: RaceProtestsResultDTO): void {
|
||||||
accusedDriverId: string;
|
const { protests, driverMap } = dto;
|
||||||
incident: ProtestIncident;
|
|
||||||
comment?: string;
|
const protestViewModels: RaceProtestViewModel[] = protests.map((protest) => {
|
||||||
proofVideoUrl?: string;
|
const base: RaceProtestViewModel = {
|
||||||
status: ProtestStatus;
|
|
||||||
reviewedBy?: string;
|
|
||||||
decisionNotes?: string;
|
|
||||||
filedAt: Date;
|
|
||||||
reviewedAt?: Date;
|
|
||||||
}>,
|
|
||||||
driverMap: Map<string, string>
|
|
||||||
): RaceProtestsViewModel {
|
|
||||||
const protestViewModels: RaceProtestViewModel[] = protests.map(protest => ({
|
|
||||||
id: protest.id,
|
id: protest.id,
|
||||||
raceId: protest.raceId,
|
raceId: protest.raceId,
|
||||||
protestingDriverId: protest.protestingDriverId,
|
protestingDriverId: protest.protestingDriverId,
|
||||||
@@ -33,27 +24,37 @@ export class RaceProtestsPresenter implements IRaceProtestsPresenter {
|
|||||||
accusedDriverId: protest.accusedDriverId,
|
accusedDriverId: protest.accusedDriverId,
|
||||||
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
|
accusedDriverName: driverMap.get(protest.accusedDriverId) || 'Unknown',
|
||||||
incident: protest.incident,
|
incident: protest.incident,
|
||||||
comment: protest.comment,
|
|
||||||
proofVideoUrl: protest.proofVideoUrl,
|
|
||||||
status: protest.status,
|
|
||||||
reviewedBy: protest.reviewedBy,
|
|
||||||
reviewedByName: protest.reviewedBy ? driverMap.get(protest.reviewedBy) : undefined,
|
|
||||||
decisionNotes: protest.decisionNotes,
|
|
||||||
filedAt: protest.filedAt.toISOString(),
|
filedAt: protest.filedAt.toISOString(),
|
||||||
reviewedAt: protest.reviewedAt?.toISOString(),
|
status: protest.status,
|
||||||
}));
|
};
|
||||||
|
|
||||||
|
const comment = protest.comment;
|
||||||
|
const proofVideoUrl = protest.proofVideoUrl;
|
||||||
|
const reviewedBy = protest.reviewedBy;
|
||||||
|
const reviewedByName =
|
||||||
|
protest.reviewedBy !== undefined
|
||||||
|
? driverMap.get(protest.reviewedBy) ?? 'Unknown'
|
||||||
|
: undefined;
|
||||||
|
const decisionNotes = protest.decisionNotes;
|
||||||
|
const reviewedAt = protest.reviewedAt?.toISOString();
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(comment !== undefined ? { comment } : {}),
|
||||||
|
...(proofVideoUrl !== undefined ? { proofVideoUrl } : {}),
|
||||||
|
...(reviewedBy !== undefined ? { reviewedBy } : {}),
|
||||||
|
...(reviewedByName !== undefined ? { reviewedByName } : {}),
|
||||||
|
...(decisionNotes !== undefined ? { decisionNotes } : {}),
|
||||||
|
...(reviewedAt !== undefined ? { reviewedAt } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
this.viewModel = {
|
this.viewModel = {
|
||||||
protests: protestViewModels,
|
protests: protestViewModels,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): RaceProtestsViewModel {
|
getViewModel(): RaceProtestsViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,26 +4,59 @@ import type {
|
|||||||
RaceListItemViewModel,
|
RaceListItemViewModel,
|
||||||
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
|
} from '@gridpilot/racing/application/presenters/IRacesPagePresenter';
|
||||||
|
|
||||||
|
interface RacesPageInput {
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
status: string;
|
||||||
|
leagueId: string;
|
||||||
|
leagueName: string;
|
||||||
|
strengthOfField: number | null;
|
||||||
|
isUpcoming: boolean;
|
||||||
|
isLive: boolean;
|
||||||
|
isPast: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class RacesPagePresenter implements IRacesPagePresenter {
|
export class RacesPagePresenter implements IRacesPagePresenter {
|
||||||
private viewModel: RacesPageViewModel | null = null;
|
private viewModel: RacesPageViewModel | null = null;
|
||||||
|
|
||||||
present(races: any[]): void {
|
present(races: RacesPageInput[]): void {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
const nextWeek = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
const raceViewModels: RaceListItemViewModel[] = races.map(race => ({
|
const raceViewModels: RaceListItemViewModel[] = races.map((race) => {
|
||||||
|
const scheduledAt =
|
||||||
|
typeof race.scheduledAt === 'string'
|
||||||
|
? race.scheduledAt
|
||||||
|
: race.scheduledAt.toISOString();
|
||||||
|
|
||||||
|
const allowedStatuses: RaceListItemViewModel['status'][] = [
|
||||||
|
'scheduled',
|
||||||
|
'running',
|
||||||
|
'completed',
|
||||||
|
'cancelled',
|
||||||
|
];
|
||||||
|
|
||||||
|
const status: RaceListItemViewModel['status'] =
|
||||||
|
allowedStatuses.includes(race.status as RaceListItemViewModel['status'])
|
||||||
|
? (race.status as RaceListItemViewModel['status'])
|
||||||
|
: 'scheduled';
|
||||||
|
|
||||||
|
return {
|
||||||
id: race.id,
|
id: race.id,
|
||||||
track: race.track,
|
track: race.track,
|
||||||
car: race.car,
|
car: race.car,
|
||||||
scheduledAt: race.scheduledAt,
|
scheduledAt,
|
||||||
status: race.status,
|
status,
|
||||||
leagueId: race.leagueId,
|
leagueId: race.leagueId,
|
||||||
leagueName: race.leagueName,
|
leagueName: race.leagueName,
|
||||||
strengthOfField: race.strengthOfField,
|
strengthOfField: race.strengthOfField,
|
||||||
isUpcoming: race.isUpcoming,
|
isUpcoming: race.isUpcoming,
|
||||||
isLive: race.isLive,
|
isLive: race.isLive,
|
||||||
isPast: race.isPast,
|
isPast: race.isPast,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const stats = {
|
const stats = {
|
||||||
total: raceViewModels.length,
|
total: raceViewModels.length,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ISponsorDashboardPresenter } from '@racing/application/presenters/ISponsorDashboardPresenter';
|
import type { ISponsorDashboardPresenter } from '@gridpilot/racing/application/presenters/ISponsorDashboardPresenter';
|
||||||
import type { SponsorDashboardDTO } from '@racing/application/use-cases/GetSponsorDashboardUseCase';
|
import type { SponsorDashboardDTO } from '@gridpilot/racing/application/use-cases/GetSponsorDashboardUseCase';
|
||||||
|
|
||||||
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
export class SponsorDashboardPresenter implements ISponsorDashboardPresenter {
|
||||||
private data: SponsorDashboardDTO | null = null;
|
private data: SponsorDashboardDTO | null = null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { ISponsorSponsorshipsPresenter } from '@racing/application/presenters/ISponsorSponsorshipsPresenter';
|
import type { ISponsorSponsorshipsPresenter } from '@gridpilot/racing/application/presenters/ISponsorSponsorshipsPresenter';
|
||||||
import type { SponsorSponsorshipsDTO } from '@racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
import type { SponsorSponsorshipsDTO } from '@gridpilot/racing/application/use-cases/GetSponsorSponsorshipsUseCase';
|
||||||
|
|
||||||
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
export class SponsorSponsorshipsPresenter implements ISponsorSponsorshipsPresenter {
|
||||||
private data: SponsorSponsorshipsDTO | null = null;
|
private data: SponsorSponsorshipsDTO | null = null;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { Team, TeamJoinRequest } from '@gridpilot/racing';
|
|
||||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||||
import {
|
import {
|
||||||
@@ -34,7 +33,9 @@ export interface TeamAdminViewModel {
|
|||||||
/**
|
/**
|
||||||
* Load join requests plus driver DTOs for a team.
|
* Load join requests plus driver DTOs for a team.
|
||||||
*/
|
*/
|
||||||
export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewModel> {
|
export async function loadTeamAdminViewModel(
|
||||||
|
team: TeamAdminTeamSummaryViewModel,
|
||||||
|
): Promise<TeamAdminViewModel> {
|
||||||
const requests = await loadTeamJoinRequests(team.id);
|
const requests = await loadTeamJoinRequests(team.id);
|
||||||
return {
|
return {
|
||||||
team: {
|
team: {
|
||||||
@@ -48,10 +49,18 @@ export async function loadTeamAdminViewModel(team: Team): Promise<TeamAdminViewM
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoinRequestViewModel[]> {
|
export async function loadTeamJoinRequests(
|
||||||
|
teamId: string,
|
||||||
|
): Promise<TeamAdminJoinRequestViewModel[]> {
|
||||||
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
const getRequestsUseCase = getGetTeamJoinRequestsUseCase();
|
||||||
await getRequestsUseCase.execute({ teamId });
|
const presenter = new (await import('./TeamJoinRequestsPresenter')).TeamJoinRequestsPresenter();
|
||||||
const presenterVm = getRequestsUseCase.presenter.getViewModel();
|
|
||||||
|
await getRequestsUseCase.execute({ teamId }, presenter);
|
||||||
|
|
||||||
|
const presenterVm = presenter.getViewModel();
|
||||||
|
if (!presenterVm) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
const driverRepo = getDriverRepository();
|
const driverRepo = getDriverRepository();
|
||||||
const allDrivers = await driverRepo.findAll();
|
const allDrivers = await driverRepo.findAll();
|
||||||
@@ -64,14 +73,29 @@ export async function loadTeamJoinRequests(teamId: string): Promise<TeamAdminJoi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return presenterVm.requests.map((req) => ({
|
return presenterVm.requests.map((req: {
|
||||||
|
requestId: string;
|
||||||
|
teamId: string;
|
||||||
|
driverId: string;
|
||||||
|
requestedAt: string;
|
||||||
|
message?: string;
|
||||||
|
}): TeamAdminJoinRequestViewModel => {
|
||||||
|
const base: TeamAdminJoinRequestViewModel = {
|
||||||
id: req.requestId,
|
id: req.requestId,
|
||||||
teamId: req.teamId,
|
teamId: req.teamId,
|
||||||
driverId: req.driverId,
|
driverId: req.driverId,
|
||||||
requestedAt: new Date(req.requestedAt),
|
requestedAt: new Date(req.requestedAt),
|
||||||
message: req.message,
|
};
|
||||||
driver: driversById[req.driverId],
|
|
||||||
}));
|
const message = req.message;
|
||||||
|
const driver = driversById[req.driverId];
|
||||||
|
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
...(message !== undefined ? { message } : {}),
|
||||||
|
...(driver !== undefined ? { driver } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { Team, TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
import type { Team } from '@gridpilot/racing/domain/entities/Team';
|
||||||
|
import type { TeamMembership } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||||
import type {
|
import type {
|
||||||
ITeamDetailsPresenter,
|
ITeamDetailsPresenter,
|
||||||
TeamDetailsViewModel,
|
TeamDetailsViewModel,
|
||||||
@@ -14,7 +15,7 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
|||||||
): TeamDetailsViewModel {
|
): TeamDetailsViewModel {
|
||||||
const canManage = membership?.role === 'owner' || membership?.role === 'manager';
|
const canManage = membership?.role === 'owner' || membership?.role === 'manager';
|
||||||
|
|
||||||
this.viewModel = {
|
const viewModel: TeamDetailsViewModel = {
|
||||||
team: {
|
team: {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
@@ -22,21 +23,20 @@ export class TeamDetailsPresenter implements ITeamDetailsPresenter {
|
|||||||
description: team.description,
|
description: team.description,
|
||||||
ownerId: team.ownerId,
|
ownerId: team.ownerId,
|
||||||
leagues: team.leagues,
|
leagues: team.leagues,
|
||||||
specialization: team.specialization as 'endurance' | 'sprint' | 'mixed' | undefined,
|
|
||||||
region: team.region,
|
|
||||||
languages: team.languages,
|
|
||||||
},
|
},
|
||||||
membership: membership
|
membership: membership
|
||||||
? {
|
? {
|
||||||
role: membership.role,
|
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||||
joinedAt: membership.joinedAt.toISOString(),
|
joinedAt: membership.joinedAt.toISOString(),
|
||||||
isActive: membership.isActive,
|
isActive: membership.status === 'active',
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
canManage,
|
canManage,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
this.viewModel = viewModel;
|
||||||
|
|
||||||
|
return viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): TeamDetailsViewModel {
|
getViewModel(): TeamDetailsViewModel {
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
import type { TeamJoinRequest } from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
import type {
|
import type {
|
||||||
ITeamJoinRequestsPresenter,
|
ITeamJoinRequestsPresenter,
|
||||||
TeamJoinRequestViewModel,
|
TeamJoinRequestViewModel,
|
||||||
TeamJoinRequestsViewModel,
|
TeamJoinRequestsViewModel,
|
||||||
|
TeamJoinRequestsResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter';
|
} from '@gridpilot/racing/application/presenters/ITeamJoinRequestsPresenter';
|
||||||
|
|
||||||
export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
||||||
private viewModel: TeamJoinRequestsViewModel | null = null;
|
private viewModel: TeamJoinRequestsViewModel | null = null;
|
||||||
|
|
||||||
present(
|
reset(): void {
|
||||||
requests: TeamJoinRequest[],
|
this.viewModel = null;
|
||||||
driverNames: Record<string, string>,
|
}
|
||||||
avatarUrls: Record<string, string>
|
|
||||||
): TeamJoinRequestsViewModel {
|
present(input: TeamJoinRequestsResultDTO): void {
|
||||||
const requestItems: TeamJoinRequestViewModel[] = requests.map((request) => ({
|
const requestItems: TeamJoinRequestViewModel[] = input.requests.map((request) => ({
|
||||||
requestId: request.id,
|
requestId: request.id,
|
||||||
driverId: request.driverId,
|
driverId: request.driverId,
|
||||||
driverName: driverNames[request.driverId] ?? 'Unknown Driver',
|
driverName: input.driverNames[request.driverId] ?? 'Unknown Driver',
|
||||||
teamId: request.teamId,
|
teamId: request.teamId,
|
||||||
status: request.status,
|
status: 'pending',
|
||||||
requestedAt: request.requestedAt.toISOString(),
|
requestedAt: request.requestedAt.toISOString(),
|
||||||
avatarUrl: avatarUrls[request.driverId] ?? '',
|
avatarUrl: input.avatarUrls[request.driverId] ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const pendingCount = requestItems.filter((r) => r.status === 'pending').length;
|
const pendingCount = requestItems.filter((r) => r.status === 'pending').length;
|
||||||
@@ -30,14 +30,9 @@ export class TeamJoinRequestsPresenter implements ITeamJoinRequestsPresenter {
|
|||||||
pendingCount,
|
pendingCount,
|
||||||
totalCount: requestItems.length,
|
totalCount: requestItems.length,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): TeamJoinRequestsViewModel {
|
getViewModel(): TeamJoinRequestsViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,25 @@
|
|||||||
import type { TeamMembership } from '@gridpilot/racing/domain/entities/Team';
|
|
||||||
import type {
|
import type {
|
||||||
ITeamMembersPresenter,
|
ITeamMembersPresenter,
|
||||||
TeamMemberViewModel,
|
TeamMemberViewModel,
|
||||||
TeamMembersViewModel,
|
TeamMembersViewModel,
|
||||||
|
TeamMembersResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
|
} from '@gridpilot/racing/application/presenters/ITeamMembersPresenter';
|
||||||
|
|
||||||
export class TeamMembersPresenter implements ITeamMembersPresenter {
|
export class TeamMembersPresenter implements ITeamMembersPresenter {
|
||||||
private viewModel: TeamMembersViewModel | null = null;
|
private viewModel: TeamMembersViewModel | null = null;
|
||||||
|
|
||||||
present(
|
reset(): void {
|
||||||
memberships: TeamMembership[],
|
this.viewModel = null;
|
||||||
driverNames: Record<string, string>,
|
}
|
||||||
avatarUrls: Record<string, string>
|
|
||||||
): TeamMembersViewModel {
|
present(input: TeamMembersResultDTO): void {
|
||||||
const members: TeamMemberViewModel[] = memberships.map((membership) => ({
|
const members: TeamMemberViewModel[] = input.memberships.map((membership) => ({
|
||||||
driverId: membership.driverId,
|
driverId: membership.driverId,
|
||||||
driverName: driverNames[membership.driverId] ?? 'Unknown Driver',
|
driverName: input.driverNames[membership.driverId] ?? 'Unknown Driver',
|
||||||
role: membership.role,
|
role: membership.role === 'driver' ? 'member' : membership.role,
|
||||||
joinedAt: membership.joinedAt.toISOString(),
|
joinedAt: membership.joinedAt.toISOString(),
|
||||||
isActive: membership.isActive,
|
isActive: membership.status === 'active',
|
||||||
avatarUrl: avatarUrls[membership.driverId] ?? '',
|
avatarUrl: input.avatarUrls[membership.driverId] ?? '',
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
const ownerCount = members.filter((m) => m.role === 'owner').length;
|
||||||
@@ -33,14 +33,9 @@ export class TeamMembersPresenter implements ITeamMembersPresenter {
|
|||||||
managerCount,
|
managerCount,
|
||||||
memberCount,
|
memberCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.viewModel;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): TeamMembersViewModel {
|
getViewModel(): TeamMembersViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('Presenter has not been called yet');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TeamMembership, TeamRole } from '@gridpilot/racing';
|
import type { TeamMembership, TeamRole } from '@gridpilot/racing/domain/types/TeamMembership';
|
||||||
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@gridpilot/racing/application/dto/DriverDTO';
|
||||||
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
import { EntityMappers } from '@gridpilot/racing/application/mappers/EntityMappers';
|
||||||
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
import { getDriverRepository, getDriverStats } from '@/lib/di-container';
|
||||||
|
|||||||
@@ -3,12 +3,36 @@ import type {
|
|||||||
TeamsLeaderboardViewModel,
|
TeamsLeaderboardViewModel,
|
||||||
TeamLeaderboardItemViewModel,
|
TeamLeaderboardItemViewModel,
|
||||||
SkillLevel,
|
SkillLevel,
|
||||||
|
TeamsLeaderboardResultDTO,
|
||||||
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
} from '@gridpilot/racing/application/presenters/ITeamsLeaderboardPresenter';
|
||||||
|
|
||||||
|
interface TeamLeaderboardInput {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
memberCount: number;
|
||||||
|
rating: number | null;
|
||||||
|
totalWins: number;
|
||||||
|
totalRaces: number;
|
||||||
|
performanceLevel: SkillLevel;
|
||||||
|
isRecruiting: boolean;
|
||||||
|
createdAt: Date;
|
||||||
|
description?: string | null;
|
||||||
|
specialization?: string | null;
|
||||||
|
region?: string | null;
|
||||||
|
languages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
||||||
private viewModel: TeamsLeaderboardViewModel | null = null;
|
private viewModel: TeamsLeaderboardViewModel | null = null;
|
||||||
|
|
||||||
present(teams: any[], recruitingCount: number): void {
|
reset(): void {
|
||||||
|
this.viewModel = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
present(input: TeamsLeaderboardResultDTO): void {
|
||||||
|
const teams = (input.teams ?? []) as TeamLeaderboardInput[];
|
||||||
|
const recruitingCount = input.recruitingCount ?? 0;
|
||||||
|
|
||||||
const transformedTeams = teams.map((team) => this.transformTeam(team));
|
const transformedTeams = teams.map((team) => this.transformTeam(team));
|
||||||
|
|
||||||
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
|
const groupsBySkillLevel = transformedTeams.reduce<Record<SkillLevel, TeamLeaderboardItemViewModel[]>>(
|
||||||
@@ -41,14 +65,22 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
getViewModel(): TeamsLeaderboardViewModel {
|
getViewModel(): TeamsLeaderboardViewModel | null {
|
||||||
if (!this.viewModel) {
|
|
||||||
throw new Error('ViewModel not yet generated. Call present() first.');
|
|
||||||
}
|
|
||||||
return this.viewModel;
|
return this.viewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
private transformTeam(team: any): TeamLeaderboardItemViewModel {
|
private transformTeam(team: TeamLeaderboardInput): TeamLeaderboardItemViewModel {
|
||||||
|
let specialization: TeamLeaderboardItemViewModel['specialization'];
|
||||||
|
if (
|
||||||
|
team.specialization === 'endurance' ||
|
||||||
|
team.specialization === 'sprint' ||
|
||||||
|
team.specialization === 'mixed'
|
||||||
|
) {
|
||||||
|
specialization = team.specialization;
|
||||||
|
} else {
|
||||||
|
specialization = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: team.id,
|
id: team.id,
|
||||||
name: team.name,
|
name: team.name,
|
||||||
@@ -56,13 +88,13 @@ export class TeamsLeaderboardPresenter implements ITeamsLeaderboardPresenter {
|
|||||||
rating: team.rating,
|
rating: team.rating,
|
||||||
totalWins: team.totalWins,
|
totalWins: team.totalWins,
|
||||||
totalRaces: team.totalRaces,
|
totalRaces: team.totalRaces,
|
||||||
performanceLevel: team.performanceLevel as SkillLevel,
|
performanceLevel: team.performanceLevel,
|
||||||
isRecruiting: team.isRecruiting,
|
isRecruiting: team.isRecruiting,
|
||||||
createdAt: team.createdAt,
|
createdAt: team.createdAt,
|
||||||
description: team.description,
|
description: team.description ?? '',
|
||||||
specialization: team.specialization,
|
specialization: specialization ?? 'mixed',
|
||||||
region: team.region,
|
region: team.region ?? '',
|
||||||
languages: team.languages,
|
languages: team.languages ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user