website cleanup
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
|||||||
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query } from '@nestjs/common';
|
||||||
import { AuthService } from './AuthService';
|
import { AuthService } from './AuthService';
|
||||||
import { LoginParams, SignupParams, AuthSessionDTO } from './dtos/AuthDto';
|
import { LoginParamsDTO, SignupParamsDTO, AuthSessionDTO } from './dtos/AuthDto';
|
||||||
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
@@ -8,12 +8,12 @@ export class AuthController {
|
|||||||
constructor(private readonly authService: AuthService) {}
|
constructor(private readonly authService: AuthService) {}
|
||||||
|
|
||||||
@Post('signup')
|
@Post('signup')
|
||||||
async signup(@Body() params: SignupParams): Promise<AuthSessionDTO> {
|
async signup(@Body() params: SignupParamsDTO): Promise<AuthSessionDTO> {
|
||||||
return this.authService.signupWithEmail(params);
|
return this.authService.signupWithEmail(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() params: LoginParams): Promise<AuthSessionDTO> {
|
async login(@Body() params: LoginParamsDTO): Promise<AuthSessionDTO> {
|
||||||
return this.authService.loginWithEmail(params);
|
return this.authService.loginWithEmail(params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
SIGNUP_USE_CASE_TOKEN,
|
SIGNUP_USE_CASE_TOKEN,
|
||||||
} from './AuthProviders';
|
} from './AuthProviders';
|
||||||
import type { AuthSessionDTO } from './dtos/AuthDto';
|
import type { AuthSessionDTO } from './dtos/AuthDto';
|
||||||
import { LoginParams, SignupParams } from './dtos/AuthDto';
|
import { LoginParamsDTO, SignupParamsDTO } from './dtos/AuthDto';
|
||||||
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
import { AuthSessionPresenter } from './presenters/AuthSessionPresenter';
|
||||||
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
import type { CommandResultDTO } from './presenters/CommandResultPresenter';
|
||||||
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
import { CommandResultPresenter } from './presenters/CommandResultPresenter';
|
||||||
@@ -67,7 +67,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async signupWithEmail(params: SignupParams): Promise<AuthSessionDTO> {
|
async signupWithEmail(params: SignupParamsDTO): Promise<AuthSessionDTO> {
|
||||||
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
|
this.logger.debug(`[AuthService] Attempting signup for email: ${params.email}`);
|
||||||
|
|
||||||
this.authSessionPresenter.reset();
|
this.authSessionPresenter.reset();
|
||||||
@@ -98,7 +98,7 @@ export class AuthService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async loginWithEmail(params: LoginParams): Promise<AuthSessionDTO> {
|
async loginWithEmail(params: LoginParamsDTO): Promise<AuthSessionDTO> {
|
||||||
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
|
this.logger.debug(`[AuthService] Attempting login for email: ${params.email}`);
|
||||||
|
|
||||||
this.authSessionPresenter.reset();
|
this.authSessionPresenter.reset();
|
||||||
@@ -142,4 +142,51 @@ export class AuthService {
|
|||||||
|
|
||||||
return this.commandResultPresenter.responseModel;
|
return this.commandResultPresenter.responseModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start iRacing OAuth flow.
|
||||||
|
*
|
||||||
|
* NOTE: This is a placeholder implementation for the current alpha build.
|
||||||
|
* A production implementation should delegate to a dedicated iRacing OAuth port
|
||||||
|
* and persist/validate state server-side.
|
||||||
|
*/
|
||||||
|
async startIracingAuth(returnTo?: string): Promise<string> {
|
||||||
|
this.logger.debug('[AuthService] Starting iRacing auth flow', { returnTo });
|
||||||
|
|
||||||
|
const state = Math.random().toString(36).slice(2);
|
||||||
|
const base = 'https://example.com/iracing/auth';
|
||||||
|
const query = new URLSearchParams();
|
||||||
|
query.set('state', state);
|
||||||
|
if (returnTo) {
|
||||||
|
query.set('returnTo', returnTo);
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${base}?${query.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle iRacing OAuth callback.
|
||||||
|
*
|
||||||
|
* NOTE: Placeholder implementation that creates a demo session.
|
||||||
|
*/
|
||||||
|
async iracingCallback(code: string, state: string, returnTo?: string): Promise<AuthSessionDTO> {
|
||||||
|
this.logger.debug('[AuthService] iRacing callback received', { hasCode: !!code, state, returnTo });
|
||||||
|
|
||||||
|
const userId = `iracing-${state || code}`.slice(0, 64);
|
||||||
|
|
||||||
|
const session = await this.identitySessionPort.createSession({
|
||||||
|
id: userId,
|
||||||
|
displayName: 'iRacing User',
|
||||||
|
email: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
token: session.token,
|
||||||
|
user: {
|
||||||
|
userId,
|
||||||
|
email: '',
|
||||||
|
displayName: 'iRacing User',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export class AuthSessionDTO {
|
|||||||
user!: AuthenticatedUserDTO;
|
user!: AuthenticatedUserDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class SignupParams {
|
export class SignupParamsDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email!: string;
|
email!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -31,21 +31,21 @@ export class SignupParams {
|
|||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginParams {
|
export class LoginParamsDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
email!: string;
|
email!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
password!: string;
|
password!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IracingAuthRedirectResult {
|
export class IracingAuthRedirectResultDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
redirectUrl!: string;
|
redirectUrl!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
state!: string;
|
state!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LoginWithIracingCallbackParams {
|
export class LoginWithIracingCallbackParamsDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
code!: string;
|
code!: string;
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
import { IsOptional, IsString, ValidateNested } from 'class-validator';
|
import { IsOptional, IsString, ValidateNested } from 'class-validator';
|
||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
class WizardErrorsBasicsDTO {
|
export class WizardErrorsBasicsDTO {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -19,7 +19,7 @@ class WizardErrorsBasicsDTO {
|
|||||||
visibility?: string;
|
visibility?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WizardErrorsStructureDTO {
|
export class WizardErrorsStructureDTO {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -36,7 +36,7 @@ class WizardErrorsStructureDTO {
|
|||||||
driversPerTeam?: string;
|
driversPerTeam?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WizardErrorsTimingsDTO {
|
export class WizardErrorsTimingsDTO {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -53,7 +53,7 @@ class WizardErrorsTimingsDTO {
|
|||||||
roundsPlanned?: string;
|
roundsPlanned?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
class WizardErrorsScoringDTO {
|
export class WizardErrorsScoringDTO {
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -89,4 +89,4 @@ export class WizardErrorsDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
submit?: string;
|
submit?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,12 @@ export class LeagueStandingsPresenter implements Presenter<GetLeagueStandingsRes
|
|||||||
joinedAt: standing.driver.joinedAt.toString(),
|
joinedAt: standing.driver.joinedAt.toString(),
|
||||||
},
|
},
|
||||||
points: standing.points,
|
points: standing.points,
|
||||||
rank: standing.rank,
|
position: standing.rank,
|
||||||
|
// TODO: GetLeagueStandings currently does not provide these metrics.
|
||||||
|
// Keep them stable in the API contract for now.
|
||||||
|
wins: 0,
|
||||||
|
podiums: 0,
|
||||||
|
races: 0,
|
||||||
}));
|
}));
|
||||||
this.result = { standings };
|
this.result = { standings };
|
||||||
}
|
}
|
||||||
@@ -30,4 +35,4 @@ export class LeagueStandingsPresenter implements Presenter<GetLeagueStandingsRes
|
|||||||
if (!this.result) throw new Error('Presenter not presented');
|
if (!this.result) throw new Error('Presenter not presented');
|
||||||
return this.result;
|
return this.result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString, IsNotEmpty, IsOptional, IsUrl } from 'class-validator';
|
import { IsNotEmpty, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import { ProtestIncidentDTO } from './ProtestIncidentDTO';
|
||||||
|
|
||||||
export class FileProtestCommandDTO {
|
export class FileProtestCommandDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -18,11 +20,9 @@ export class FileProtestCommandDTO {
|
|||||||
accusedDriverId!: string;
|
accusedDriverId!: string;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
incident!: {
|
@ValidateNested()
|
||||||
lap: number;
|
@Type(() => ProtestIncidentDTO)
|
||||||
description: string;
|
incident!: ProtestIncidentDTO;
|
||||||
timeInRace?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
@ApiProperty({ required: false })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -34,4 +34,4 @@ export class FileProtestCommandDTO {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsUrl()
|
@IsUrl()
|
||||||
proofVideoUrl?: string;
|
proofVideoUrl?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
21
apps/api/src/domain/race/dtos/ProtestIncidentDTO.ts
Normal file
21
apps/api/src/domain/race/dtos/ProtestIncidentDTO.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { IsInt, IsNotEmpty, IsOptional, IsString, Min } from 'class-validator';
|
||||||
|
|
||||||
|
export class ProtestIncidentDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
lap!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, description: 'Seconds from race start' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
timeInRace?: number;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -18,8 +18,8 @@ import { InvoiceDTO } from './dtos/InvoiceDTO';
|
|||||||
import { BillingStatsDTO } from './dtos/BillingStatsDTO';
|
import { BillingStatsDTO } from './dtos/BillingStatsDTO';
|
||||||
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
|
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
|
||||||
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
|
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
|
||||||
import { DriverDTO } from './dtos/DriverDTO';
|
import { SponsorDriverDTO } from './dtos/SponsorDriverDTO';
|
||||||
import { RaceDTO } from './dtos/RaceDTO';
|
import { SponsorRaceDTO } from './dtos/RaceDTO';
|
||||||
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
|
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
|
||||||
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
|
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
|
||||||
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
|
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
|
||||||
@@ -194,8 +194,8 @@ export class SponsorController {
|
|||||||
@Param('leagueId') leagueId: string,
|
@Param('leagueId') leagueId: string,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
league: LeagueDetailDTO;
|
league: LeagueDetailDTO;
|
||||||
drivers: DriverDTO[];
|
drivers: SponsorDriverDTO[];
|
||||||
races: RaceDTO[];
|
races: SponsorRaceDTO[];
|
||||||
} | null> {
|
} | null> {
|
||||||
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
|
const presenter = await this.sponsorService.getLeagueDetail(leagueId);
|
||||||
return presenter.viewModel;
|
return presenter.viewModel;
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import { GetEntitySponsorshipPricingResultDTO } from './dtos/GetEntitySponsorshi
|
|||||||
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
|
import { GetSponsorsOutputDTO } from './dtos/GetSponsorsOutputDTO';
|
||||||
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
|
import { AvailableLeagueDTO } from './dtos/AvailableLeagueDTO';
|
||||||
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
|
import { LeagueDetailDTO } from './dtos/LeagueDetailDTO';
|
||||||
import { DriverDTO } from './dtos/DriverDTO';
|
import { SponsorDriverDTO } from './dtos/SponsorDriverDTO';
|
||||||
import { RaceDTO } from './dtos/RaceDTO';
|
import { SponsorRaceDTO } from './dtos/RaceDTO';
|
||||||
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
|
import { SponsorProfileDTO } from './dtos/SponsorProfileDTO';
|
||||||
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
|
import { NotificationSettingsDTO } from './dtos/NotificationSettingsDTO';
|
||||||
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
|
import { PrivacySettingsDTO } from './dtos/PrivacySettingsDTO';
|
||||||
@@ -399,7 +399,7 @@ export class SponsorService {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const drivers: DriverDTO[] = [
|
const drivers: SponsorDriverDTO[] = [
|
||||||
{
|
{
|
||||||
id: 'd1',
|
id: 'd1',
|
||||||
name: 'Max Verstappen',
|
name: 'Max Verstappen',
|
||||||
@@ -420,7 +420,7 @@ export class SponsorService {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const races: RaceDTO[] = [
|
const races: SponsorRaceDTO[] = [
|
||||||
{
|
{
|
||||||
id: 'r1',
|
id: 'r1',
|
||||||
name: 'Spa-Francorchamps',
|
name: 'Spa-Francorchamps',
|
||||||
@@ -508,4 +508,4 @@ export class SponsorService {
|
|||||||
presenter.present({ success: true });
|
presenter.present({ success: true });
|
||||||
return presenter;
|
return presenter;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString, IsEnum, IsNumber, IsDateString } from 'class-validator';
|
import { IsString, IsEnum, IsNumber, IsDateString } from 'class-validator';
|
||||||
|
|
||||||
export class RaceDTO {
|
export class SponsorRaceDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string = '';
|
id: string = '';
|
||||||
@@ -21,4 +21,4 @@ export class RaceDTO {
|
|||||||
@ApiProperty({ enum: ['upcoming', 'completed'] })
|
@ApiProperty({ enum: ['upcoming', 'completed'] })
|
||||||
@IsEnum(['upcoming', 'completed'])
|
@IsEnum(['upcoming', 'completed'])
|
||||||
status: 'upcoming' | 'completed' = 'upcoming';
|
status: 'upcoming' | 'completed' = 'upcoming';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString, IsNumber } from 'class-validator';
|
import { IsString, IsNumber } from 'class-validator';
|
||||||
|
|
||||||
export class DriverDTO {
|
export class SponsorDriverDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
id: string = '';
|
id: string = '';
|
||||||
@@ -29,4 +29,5 @@ export class DriverDTO {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsString()
|
@IsString()
|
||||||
team: string = '';
|
team: string = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { LeagueDetailDTO } from '../dtos/LeagueDetailDTO';
|
import { LeagueDetailDTO } from '../dtos/LeagueDetailDTO';
|
||||||
import { DriverDTO } from '../dtos/DriverDTO';
|
import { SponsorDriverDTO } from '../dtos/SponsorDriverDTO';
|
||||||
import { RaceDTO } from '../dtos/RaceDTO';
|
import { SponsorRaceDTO } from '../dtos/RaceDTO';
|
||||||
|
|
||||||
export interface LeagueDetailViewModel {
|
export interface LeagueDetailViewModel {
|
||||||
league: LeagueDetailDTO;
|
league: LeagueDetailDTO;
|
||||||
drivers: DriverDTO[];
|
drivers: SponsorDriverDTO[];
|
||||||
races: RaceDTO[];
|
races: SponsorRaceDTO[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class LeagueDetailPresenter {
|
export class LeagueDetailPresenter {
|
||||||
|
|||||||
@@ -1,33 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
class TeamListItemDTO {
|
import { TeamListItemDTO } from './TeamListItemDTO';
|
||||||
@ApiProperty()
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
tag!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
description!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
memberCount!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
|
||||||
leagues!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
region?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String], required: false })
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetAllTeamsOutputDTO {
|
export class GetAllTeamsOutputDTO {
|
||||||
@ApiProperty({ type: [TeamListItemDTO] })
|
@ApiProperty({ type: [TeamListItemDTO] })
|
||||||
@@ -35,4 +8,4 @@ export class GetAllTeamsOutputDTO {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
totalCount!: number;
|
totalCount!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,58 +1,18 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
class TeamDTO {
|
import { TeamDTO } from './TeamDTO';
|
||||||
@ApiProperty()
|
import { TeamMembershipDTO } from './TeamMembershipDTO';
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
tag!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
description!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
ownerId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
|
||||||
leagues!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
createdAt?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
region?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String], required: false })
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class MembershipDTO {
|
|
||||||
@ApiProperty()
|
|
||||||
role!: 'owner' | 'manager' | 'member';
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
joinedAt!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
isActive!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetDriverTeamOutputDTO {
|
export class GetDriverTeamOutputDTO {
|
||||||
@ApiProperty({ type: TeamDTO })
|
@ApiProperty({ type: TeamDTO })
|
||||||
team!: TeamDTO;
|
team!: TeamDTO;
|
||||||
|
|
||||||
@ApiProperty({ type: MembershipDTO })
|
@ApiProperty({ type: TeamMembershipDTO })
|
||||||
membership!: MembershipDTO;
|
membership!: TeamMembershipDTO;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
isOwner!: boolean;
|
isOwner!: boolean;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
canManage!: boolean;
|
canManage!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,15 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
class TeamDTO {
|
import { TeamDTO } from './TeamDTO';
|
||||||
@ApiProperty()
|
import { TeamMembershipDTO } from './TeamMembershipDTO';
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
tag!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
description!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
ownerId!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
|
||||||
leagues!: string[];
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
createdAt?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
region?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String], required: false })
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
class MembershipDTO {
|
|
||||||
@ApiProperty()
|
|
||||||
role!: 'owner' | 'manager' | 'member';
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
joinedAt!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
isActive!: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetTeamDetailsOutputDTO {
|
export class GetTeamDetailsOutputDTO {
|
||||||
@ApiProperty({ type: TeamDTO })
|
@ApiProperty({ type: TeamDTO })
|
||||||
team!: TeamDTO;
|
team!: TeamDTO;
|
||||||
|
|
||||||
@ApiProperty({ type: MembershipDTO, nullable: true })
|
@ApiProperty({ type: TeamMembershipDTO, nullable: true })
|
||||||
membership!: MembershipDTO | null;
|
membership!: TeamMembershipDTO | null;
|
||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
canManage!: boolean;
|
canManage!: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,27 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
class TeamJoinRequestDTO {
|
import { TeamJoinRequestDTO } from './TeamJoinRequestDTO';
|
||||||
@ApiProperty()
|
|
||||||
requestId!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
driverId!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
driverName!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
teamId!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
status!: 'pending' | 'approved' | 'rejected';
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
requestedAt!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
avatarUrl!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetTeamJoinRequestsOutputDTO {
|
export class GetTeamJoinRequestsOutputDTO {
|
||||||
@ApiProperty({ type: [TeamJoinRequestDTO] })
|
@ApiProperty({ type: [TeamJoinRequestDTO] })
|
||||||
@@ -32,4 +11,4 @@ export class GetTeamJoinRequestsOutputDTO {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
totalCount!: number;
|
totalCount!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
class TeamMemberDTO {
|
import { TeamMemberDTO } from './TeamMemberDTO';
|
||||||
@ApiProperty()
|
|
||||||
driverId!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
driverName!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
role!: 'owner' | 'manager' | 'member';
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
joinedAt!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
isActive!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
avatarUrl!: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetTeamMembersOutputDTO {
|
export class GetTeamMembersOutputDTO {
|
||||||
@ApiProperty({ type: [TeamMemberDTO] })
|
@ApiProperty({ type: [TeamMemberDTO] })
|
||||||
@@ -35,4 +17,4 @@ export class GetTeamMembersOutputDTO {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
memberCount!: number;
|
memberCount!: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
import { TeamLeaderboardItemDTO, type SkillLevel } from './TeamLeaderboardItemDTO';
|
||||||
|
|
||||||
class TeamLeaderboardItemDTO {
|
|
||||||
@ApiProperty()
|
|
||||||
id!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
name!: string;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
memberCount!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ nullable: true })
|
|
||||||
rating!: number | null;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
totalWins!: number;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
totalRaces!: number;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: ['beginner', 'intermediate', 'advanced', 'pro'] })
|
|
||||||
performanceLevel!: SkillLevel;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
isRecruiting!: boolean;
|
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
createdAt!: string;
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
description?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ enum: ['endurance', 'sprint', 'mixed'], required: false })
|
|
||||||
specialization?: 'endurance' | 'sprint' | 'mixed';
|
|
||||||
|
|
||||||
@ApiProperty({ required: false })
|
|
||||||
region?: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: [String], required: false })
|
|
||||||
languages?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class GetTeamsLeaderboardOutputDTO {
|
export class GetTeamsLeaderboardOutputDTO {
|
||||||
@ApiProperty({ type: [TeamLeaderboardItemDTO] })
|
@ApiProperty({ type: [TeamLeaderboardItemDTO] })
|
||||||
@@ -55,4 +14,4 @@ export class GetTeamsLeaderboardOutputDTO {
|
|||||||
|
|
||||||
@ApiProperty({ type: [TeamLeaderboardItemDTO] })
|
@ApiProperty({ type: [TeamLeaderboardItemDTO] })
|
||||||
topTeams!: TeamLeaderboardItemDTO[];
|
topTeams!: TeamLeaderboardItemDTO[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { IsString, IsNotEmpty, IsBoolean, IsOptional } from 'class-validator';
|
import { IsString, IsNotEmpty, IsBoolean, IsOptional, IsArray } from 'class-validator';
|
||||||
|
|
||||||
export class TeamListItemViewModel {
|
export class TeamListItemViewModel {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -296,3 +296,38 @@ export class RejectTeamJoinRequestOutput {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
success!: boolean;
|
success!: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---
|
||||||
|
// DTOs used by the public API surface (consumed by the website via generated types)
|
||||||
|
// ---
|
||||||
|
|
||||||
|
export class TeamDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
tag!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsString()
|
||||||
|
ownerId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
@IsArray()
|
||||||
|
leagues!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
createdAt?: string;
|
||||||
|
}
|
||||||
|
|||||||
25
apps/api/src/domain/team/dtos/TeamJoinRequestDTO.ts
Normal file
25
apps/api/src/domain/team/dtos/TeamJoinRequestDTO.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class TeamJoinRequestDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
requestId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
driverId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
teamId!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['pending', 'approved', 'rejected'] })
|
||||||
|
status!: 'pending' | 'approved' | 'rejected';
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
requestedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
avatarUrl!: string;
|
||||||
|
}
|
||||||
|
|
||||||
45
apps/api/src/domain/team/dtos/TeamLeaderboardItemDTO.ts
Normal file
45
apps/api/src/domain/team/dtos/TeamLeaderboardItemDTO.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'pro';
|
||||||
|
|
||||||
|
export class TeamLeaderboardItemDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
memberCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ nullable: true })
|
||||||
|
rating!: number | null;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalWins!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
totalRaces!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['beginner', 'intermediate', 'advanced', 'pro'] })
|
||||||
|
performanceLevel!: SkillLevel;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isRecruiting!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
createdAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['endurance', 'sprint', 'mixed'], required: false })
|
||||||
|
specialization?: 'endurance' | 'sprint' | 'mixed';
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], required: false })
|
||||||
|
languages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
31
apps/api/src/domain/team/dtos/TeamListItemDTO.ts
Normal file
31
apps/api/src/domain/team/dtos/TeamListItemDTO.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class TeamListItemDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
id!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
tag!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
description!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
memberCount!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String] })
|
||||||
|
leagues!: string[];
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, enum: ['endurance', 'sprint', 'mixed'] })
|
||||||
|
specialization?: 'endurance' | 'sprint' | 'mixed';
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
region?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: [String], required: false })
|
||||||
|
languages?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
22
apps/api/src/domain/team/dtos/TeamMemberDTO.ts
Normal file
22
apps/api/src/domain/team/dtos/TeamMemberDTO.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class TeamMemberDTO {
|
||||||
|
@ApiProperty()
|
||||||
|
driverId!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
driverName!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
|
||||||
|
role!: 'owner' | 'manager' | 'member';
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
joinedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isActive!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
avatarUrl!: string;
|
||||||
|
}
|
||||||
|
|
||||||
13
apps/api/src/domain/team/dtos/TeamMembershipDTO.ts
Normal file
13
apps/api/src/domain/team/dtos/TeamMembershipDTO.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
export class TeamMembershipDTO {
|
||||||
|
@ApiProperty({ enum: ['owner', 'manager', 'member'] })
|
||||||
|
role!: 'owner' | 'manager' | 'member';
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
joinedAt!: string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
isActive!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -8,9 +8,11 @@ import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
|||||||
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
|
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||||
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
|
||||||
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||||
|
import type { LeagueStandingDTO } from '@/lib/types/generated/LeagueStandingDTO';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
@@ -21,7 +23,8 @@ export default function LeagueStandingsPage() {
|
|||||||
const { leagueService } = useServices();
|
const { leagueService } = useServices();
|
||||||
|
|
||||||
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
|
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
|
||||||
const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
|
const [drivers, setDrivers] = useState<DriverDTO[]>([]);
|
||||||
|
const [driverVms, setDriverVms] = useState<DriverViewModel[]>([]);
|
||||||
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -31,7 +34,8 @@ export default function LeagueStandingsPage() {
|
|||||||
try {
|
try {
|
||||||
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
|
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
|
||||||
setStandings(vm.standings);
|
setStandings(vm.standings);
|
||||||
setDrivers(vm.drivers.map(d => new DriverViewModel(d)));
|
setDrivers(vm.drivers as unknown as DriverDTO[]);
|
||||||
|
setDriverVms(vm.drivers.map((d) => new DriverViewModel(d)));
|
||||||
setMemberships(vm.memberships);
|
setMemberships(vm.memberships);
|
||||||
|
|
||||||
// Check if current user is admin
|
// Check if current user is admin
|
||||||
@@ -89,12 +93,33 @@ export default function LeagueStandingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Championship Stats */}
|
{/* Championship Stats */}
|
||||||
<LeagueChampionshipStats standings={standings} drivers={drivers} />
|
<LeagueChampionshipStats standings={standings} drivers={driverVms} />
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
<h2 className="text-xl font-semibold text-white mb-4">Championship Standings</h2>
|
||||||
<StandingsTable
|
<StandingsTable
|
||||||
standings={standings}
|
standings={standings.map((s) => ({
|
||||||
|
leagueId,
|
||||||
|
driverId: s.driverId,
|
||||||
|
position: s.position,
|
||||||
|
totalPoints: s.points,
|
||||||
|
racesFinished: s.races,
|
||||||
|
racesStarted: s.races,
|
||||||
|
avgFinish: null,
|
||||||
|
penaltyPoints: 0,
|
||||||
|
bonusPoints: 0,
|
||||||
|
}) satisfies {
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
totalPoints: number;
|
||||||
|
racesFinished: number;
|
||||||
|
racesStarted: number;
|
||||||
|
avgFinish: number | null;
|
||||||
|
penaltyPoints: number;
|
||||||
|
bonusPoints: number;
|
||||||
|
teamName?: string;
|
||||||
|
})}
|
||||||
drivers={drivers}
|
drivers={drivers}
|
||||||
leagueId={leagueId}
|
leagueId={leagueId}
|
||||||
memberships={memberships}
|
memberships={memberships}
|
||||||
@@ -106,4 +131,4 @@ export default function LeagueStandingsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,27 +14,13 @@ import SimPlatformMockup from '@/components/mockups/SimPlatformMockup';
|
|||||||
import MockupStack from '@/components/ui/MockupStack';
|
import MockupStack from '@/components/ui/MockupStack';
|
||||||
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 { LandingService } from '@/lib/services/landing/LandingService';
|
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
||||||
import { SessionService } from '@/lib/services/auth/SessionService';
|
|
||||||
import { AuthApiClient } from '@/lib/api/auth/AuthApiClient';
|
|
||||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
|
||||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
|
||||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
|
||||||
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
|
||||||
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
|
||||||
|
|
||||||
export default async function HomePage() {
|
export default async function HomePage() {
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001';
|
||||||
const errorReporter = new ConsoleErrorReporter();
|
const serviceFactory = new ServiceFactory(baseUrl);
|
||||||
const logger = new ConsoleLogger();
|
const sessionService = serviceFactory.createSessionService();
|
||||||
|
const landingService = serviceFactory.createLandingService();
|
||||||
const authApiClient = new AuthApiClient(baseUrl, errorReporter, logger);
|
|
||||||
const racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
|
||||||
const leaguesApiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
|
||||||
const teamsApiClient = new TeamsApiClient(baseUrl, errorReporter, logger);
|
|
||||||
|
|
||||||
const sessionService = new SessionService(authApiClient);
|
|
||||||
const landingService = new LandingService(racesApiClient, leaguesApiClient, teamsApiClient);
|
|
||||||
|
|
||||||
const session = await sessionService.getSession();
|
const session = await sessionService.getSession();
|
||||||
if (session) {
|
if (session) {
|
||||||
@@ -368,4 +354,4 @@ export default async function HomePage() {
|
|||||||
<Footer />
|
<Footer />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
|||||||
import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
|
import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
|
||||||
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { useEffect, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
export default function RaceResultsPage() {
|
export default function RaceResultsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -93,19 +93,6 @@ export default function RaceResultsPage() {
|
|||||||
Back to Races
|
Back to Races
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{showQuickPenaltyModal && (
|
|
||||||
<QuickPenaltyModal
|
|
||||||
{...({
|
|
||||||
raceId,
|
|
||||||
drivers: raceData?.drivers as any,
|
|
||||||
onClose: handleCloseQuickPenaltyModal,
|
|
||||||
preSelectedDriver: preSelectedDriver as any,
|
|
||||||
adminId: currentDriverId,
|
|
||||||
races: undefined,
|
|
||||||
} as any)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -190,4 +177,4 @@ export default function RaceResultsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { siteConfig } from '@/lib/siteConfig';
|
import { siteConfig } from '@/lib/siteConfig';
|
||||||
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
|
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
|
||||||
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
|
||||||
import {
|
import {
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
@@ -40,6 +39,8 @@ export default function SponsorLeagueDetailPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
|
||||||
|
const { sponsorService } = useServices();
|
||||||
|
|
||||||
const showSponsorAction = searchParams.get('action') === 'sponsor';
|
const showSponsorAction = searchParams.get('action') === 'sponsor';
|
||||||
const [activeTab, setActiveTab] = useState<TabType>(showSponsorAction ? 'sponsor' : 'overview');
|
const [activeTab, setActiveTab] = useState<TabType>(showSponsorAction ? 'sponsor' : 'overview');
|
||||||
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
|
const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main');
|
||||||
@@ -52,7 +53,6 @@ export default function SponsorLeagueDetailPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLeagueDetail = async () => {
|
const loadLeagueDetail = async () => {
|
||||||
try {
|
try {
|
||||||
const sponsorService = ServiceFactory.getSponsorService();
|
|
||||||
const leagueData = await sponsorService.getLeagueDetail(leagueId);
|
const leagueData = await sponsorService.getLeagueDetail(leagueId);
|
||||||
setData(new LeagueDetailViewModel(leagueData));
|
setData(new LeagueDetailViewModel(leagueData));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -66,7 +66,7 @@ export default function SponsorLeagueDetailPage() {
|
|||||||
if (leagueId) {
|
if (leagueId) {
|
||||||
loadLeagueDetail();
|
loadLeagueDetail();
|
||||||
}
|
}
|
||||||
}, [leagueId]);
|
}, [leagueId, sponsorService]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -385,7 +385,7 @@ export default function SponsorLeagueDetailPage() {
|
|||||||
}`} />
|
}`} />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-white">{race.name}</div>
|
<div className="font-medium text-white">{race.name}</div>
|
||||||
<div className="text-sm text-gray-500">{race.date}</div>
|
<div className="text-sm text-gray-500">{race.formattedDate}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -560,4 +560,4 @@ export default function SponsorLeagueDetailPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import { siteConfig } from '@/lib/siteConfig';
|
import { siteConfig } from '@/lib/siteConfig';
|
||||||
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
|
import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel';
|
||||||
import { SponsorService } from '@/lib/services/sponsors/SponsorService';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { ServiceFactory } from '@/lib/services/ServiceFactory';
|
|
||||||
import {
|
import {
|
||||||
Trophy,
|
Trophy,
|
||||||
Users,
|
Users,
|
||||||
@@ -194,6 +193,7 @@ function LeagueCard({ league, index }: { league: any; index: number }) {
|
|||||||
|
|
||||||
export default function SponsorLeaguesPage() {
|
export default function SponsorLeaguesPage() {
|
||||||
const shouldReduceMotion = useReducedMotion();
|
const shouldReduceMotion = useReducedMotion();
|
||||||
|
const { sponsorService } = useServices();
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
||||||
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
||||||
@@ -205,7 +205,6 @@ export default function SponsorLeaguesPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadLeagues = async () => {
|
const loadLeagues = async () => {
|
||||||
try {
|
try {
|
||||||
const sponsorService = ServiceFactory.getSponsorService();
|
|
||||||
const leaguesData = await sponsorService.getAvailableLeagues();
|
const leaguesData = await sponsorService.getAvailableLeagues();
|
||||||
setData(new AvailableLeaguesViewModel(leaguesData));
|
setData(new AvailableLeaguesViewModel(leaguesData));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -217,7 +216,7 @@ export default function SponsorLeaguesPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
loadLeagues();
|
loadLeagues();
|
||||||
}, []);
|
}, [sponsorService]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -458,4 +457,4 @@ export default function SponsorLeaguesPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,7 @@ import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
|||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
|
|
||||||
type TeamRole = 'owner' | 'manager' | 'driver';
|
type TeamRole = 'owner' | 'admin' | 'member';
|
||||||
|
|
||||||
interface TeamMembership {
|
|
||||||
driverId: string;
|
|
||||||
role: TeamRole;
|
|
||||||
joinedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||||
|
|
||||||
@@ -57,7 +51,7 @@ export default function TeamDetailPage() {
|
|||||||
const teamMembers = await teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
|
const teamMembers = await teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
|
||||||
|
|
||||||
const adminStatus = teamDetails.isOwner ||
|
const adminStatus = teamDetails.isOwner ||
|
||||||
teamMembers.some(m => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
teamMembers.some((m) => m.driverId === currentDriverId && (m.role === 'admin' || m.role === 'owner'));
|
||||||
|
|
||||||
setTeam(teamDetails);
|
setTeam(teamDetails);
|
||||||
setMemberships(teamMembers);
|
setMemberships(teamMembers);
|
||||||
@@ -82,8 +76,8 @@ export default function TeamDetailPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const performer = await teamService.getMembership(teamId, currentDriverId);
|
const performer = await teamService.getMembership(teamId, currentDriverId);
|
||||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
|
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||||
throw new Error('Only owners or managers can remove members');
|
throw new Error('Only owners or admins can remove members');
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await teamService.getMembership(teamId, driverId);
|
const membership = await teamService.getMembership(teamId, driverId);
|
||||||
@@ -104,8 +98,8 @@ export default function TeamDetailPage() {
|
|||||||
const handleChangeRole = async (driverId: string, newRole: TeamRole) => {
|
const handleChangeRole = async (driverId: string, newRole: TeamRole) => {
|
||||||
try {
|
try {
|
||||||
const performer = await teamService.getMembership(teamId, currentDriverId);
|
const performer = await teamService.getMembership(teamId, currentDriverId);
|
||||||
if (!performer || (performer.role !== 'owner' && performer.role !== 'manager')) {
|
if (!performer || (performer.role !== 'owner' && performer.role !== 'admin')) {
|
||||||
throw new Error('Only owners or managers can update roles');
|
throw new Error('Only owners or admins can update roles');
|
||||||
}
|
}
|
||||||
|
|
||||||
const membership = await teamService.getMembership(teamId, driverId);
|
const membership = await teamService.getMembership(teamId, driverId);
|
||||||
|
|||||||
@@ -37,8 +37,8 @@ type SortBy = 'rating' | 'wins' | 'winRate' | 'races';
|
|||||||
type TeamDisplayData = TeamSummaryViewModel;
|
type TeamDisplayData = TeamSummaryViewModel;
|
||||||
|
|
||||||
const getSafeRating = (team: TeamDisplayData): number => {
|
const getSafeRating = (team: TeamDisplayData): number => {
|
||||||
const value = typeof team.rating === 'number' ? team.rating : 0;
|
void team;
|
||||||
return Number.isFinite(value) ? value : 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getSafeTotalWins = (team: TeamDisplayData): number => {
|
const getSafeTotalWins = (team: TeamDisplayData): number => {
|
||||||
@@ -497,4 +497,4 @@ export default function TeamLeaderboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewMode
|
|||||||
|
|
||||||
type TeamDisplayData = TeamSummaryViewModel;
|
type TeamDisplayData = TeamSummaryViewModel;
|
||||||
|
|
||||||
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// SKILL LEVEL CONFIG
|
// SKILL LEVEL CONFIG
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -126,9 +128,9 @@ export default function TeamsPage() {
|
|||||||
// Select top teams by rating for the preview section
|
// Select top teams by rating for the preview section
|
||||||
const topTeams = useMemo(() => {
|
const topTeams = useMemo(() => {
|
||||||
const sortedByRating = [...teams].sort((a, b) => {
|
const sortedByRating = [...teams].sort((a, b) => {
|
||||||
const aRating = typeof a.rating === 'number' && Number.isFinite(a.rating) ? a.rating : 0;
|
// Rating is not currently part of TeamSummaryViewModel in this build.
|
||||||
const bRating = typeof b.rating === 'number' && Number.isFinite(b.rating) ? b.rating : 0;
|
// Keep deterministic ordering by name until a rating field is exposed.
|
||||||
return bRating - aRating;
|
return a.name.localeCompare(b.name);
|
||||||
});
|
});
|
||||||
return sortedByRating.slice(0, 5);
|
return sortedByRating.slice(0, 5);
|
||||||
}, [teams]);
|
}, [teams]);
|
||||||
@@ -172,7 +174,7 @@ export default function TeamsPage() {
|
|||||||
intermediate: [],
|
intermediate: [],
|
||||||
advanced: [],
|
advanced: [],
|
||||||
pro: [],
|
pro: [],
|
||||||
} as Record<string, typeof teams>,
|
} as Record<string, TeamSummaryViewModel[]>,
|
||||||
);
|
);
|
||||||
}, [groupsBySkillLevel, filteredTeams]);
|
}, [groupsBySkillLevel, filteredTeams]);
|
||||||
|
|
||||||
@@ -373,7 +375,7 @@ export default function TeamsPage() {
|
|||||||
<div key={level.id} id={`level-${level.id}`} className="scroll-mt-8">
|
<div key={level.id} id={`level-${level.id}`} className="scroll-mt-8">
|
||||||
<SkillLevelSection
|
<SkillLevelSection
|
||||||
level={level}
|
level={level}
|
||||||
teams={teamsByLevel[level.id]}
|
teams={teamsByLevel[level.id] ?? []}
|
||||||
onTeamClick={handleTeamClick}
|
onTeamClick={handleTeamClick}
|
||||||
defaultExpanded={index === 0}
|
defaultExpanded={index === 0}
|
||||||
/>
|
/>
|
||||||
@@ -383,4 +385,4 @@ export default function TeamsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Race } from '@core/racing/domain/entities/Race'; // TODO forbidden core import
|
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
|
|
||||||
|
type CompanionRace = {
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
sessionType: string;
|
||||||
|
};
|
||||||
|
|
||||||
interface CompanionInstructionsProps {
|
interface CompanionInstructionsProps {
|
||||||
race: Race;
|
race: CompanionRace;
|
||||||
leagueName?: string;
|
leagueName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -24,10 +30,12 @@ export default function CompanionInstructions({ race, leagueName }: CompanionIns
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||||
|
|
||||||
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
|
const raceDetails = `GridPilot Race: ${leagueName || 'League'}
|
||||||
Track: ${race.track}
|
Track: ${race.track}
|
||||||
Car: ${race.car}
|
Car: ${race.car}
|
||||||
Date/Time: ${formatDateTime(race.scheduledAt)}
|
Date/Time: ${formatDateTime(scheduledAt)}
|
||||||
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
|
Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slice(1)}`;
|
||||||
|
|
||||||
const handleCopyDetails = async () => {
|
const handleCopyDetails = async () => {
|
||||||
@@ -125,4 +133,4 @@ Session Type: ${race.sessionType.charAt(0).toUpperCase() + race.sessionType.slic
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
|
import { useNotifications } from '@/components/notifications/NotificationProvider';
|
||||||
|
import type { NotificationVariant } from '@/components/notifications/notificationTypes';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -104,6 +106,7 @@ type LoginMode = 'none' | 'driver' | 'sponsor';
|
|||||||
|
|
||||||
export default function DevToolbar() {
|
export default function DevToolbar() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { addNotification } = useNotifications();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
const [isMinimized, setIsMinimized] = useState(false);
|
const [isMinimized, setIsMinimized] = useState(false);
|
||||||
const [selectedType, setSelectedType] = useState<DemoNotificationType>('protest_filed');
|
const [selectedType, setSelectedType] = useState<DemoNotificationType>('protest_filed');
|
||||||
@@ -180,6 +183,64 @@ export default function DevToolbar() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleSendNotification = async () => {
|
||||||
|
setSending(true);
|
||||||
|
try {
|
||||||
|
const actionUrlByType: Record<DemoNotificationType, string> = {
|
||||||
|
protest_filed: '/races',
|
||||||
|
defense_requested: '/races',
|
||||||
|
vote_required: '/leagues',
|
||||||
|
race_performance_summary: '/races',
|
||||||
|
race_final_results: '/races',
|
||||||
|
};
|
||||||
|
|
||||||
|
const titleByType: Record<DemoNotificationType, string> = {
|
||||||
|
protest_filed: 'Protest Filed Against You',
|
||||||
|
defense_requested: 'Defense Requested',
|
||||||
|
vote_required: 'Vote Required',
|
||||||
|
race_performance_summary: 'Race Performance Summary',
|
||||||
|
race_final_results: 'Race Final Results',
|
||||||
|
};
|
||||||
|
|
||||||
|
const messageByType: Record<DemoNotificationType, string> = {
|
||||||
|
protest_filed: 'A protest has been filed against you. Please review the incident details.',
|
||||||
|
defense_requested: 'A steward requests your defense. Please respond within the deadline.',
|
||||||
|
vote_required: 'A protest vote is pending. Please review and vote.',
|
||||||
|
race_performance_summary: 'Your race is complete. View your provisional results.',
|
||||||
|
race_final_results: 'Stewarding is closed. Your final results are available.',
|
||||||
|
};
|
||||||
|
|
||||||
|
const notificationTypeByDemoType: Record<DemoNotificationType, string> = {
|
||||||
|
protest_filed: 'protest_filed',
|
||||||
|
defense_requested: 'protest_defense_requested',
|
||||||
|
vote_required: 'protest_vote_required',
|
||||||
|
race_performance_summary: 'race_performance_summary',
|
||||||
|
race_final_results: 'race_final_results',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variant: NotificationVariant = selectedUrgency === 'modal' ? 'modal' : 'toast';
|
||||||
|
|
||||||
|
addNotification({
|
||||||
|
type: notificationTypeByDemoType[selectedType],
|
||||||
|
title: titleByType[selectedType],
|
||||||
|
message: messageByType[selectedType],
|
||||||
|
variant,
|
||||||
|
actionUrl: actionUrlByType[selectedType],
|
||||||
|
data: {
|
||||||
|
driverId: currentDriverId,
|
||||||
|
demo: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setLastSent(`${selectedType}-${selectedUrgency}`);
|
||||||
|
setTimeout(() => setLastSent(null), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to send demo notification:', error);
|
||||||
|
} finally {
|
||||||
|
setSending(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// const handleSendNotification = async () => {
|
// const handleSendNotification = async () => {
|
||||||
// setSending(true);
|
// setSending(true);
|
||||||
// try {
|
// try {
|
||||||
@@ -559,4 +620,4 @@ export default function DevToolbar() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useState, FormEvent } from 'react';
|
|||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Input from '../ui/Input';
|
import Input from '../ui/Input';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import { Driver } from '@core/racing';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
interface FormErrors {
|
interface FormErrors {
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -16,12 +16,12 @@ interface FormErrors {
|
|||||||
|
|
||||||
export default function CreateDriverForm() {
|
export default function CreateDriverForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { driverService } = useServices();
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [errors, setErrors] = useState<FormErrors>({});
|
const [errors, setErrors] = useState<FormErrors>({});
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
iracingId: '',
|
|
||||||
country: '',
|
country: '',
|
||||||
bio: ''
|
bio: ''
|
||||||
});
|
});
|
||||||
@@ -33,16 +33,6 @@ export default function CreateDriverForm() {
|
|||||||
newErrors.name = 'Name is required';
|
newErrors.name = 'Name is required';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formData.iracingId.trim()) {
|
|
||||||
newErrors.iracingId = 'iRacing ID is required';
|
|
||||||
} else {
|
|
||||||
const driverRepo = getDriverRepository();
|
|
||||||
const exists = await driverRepo.existsByIRacingId(formData.iracingId);
|
|
||||||
if (exists) {
|
|
||||||
newErrors.iracingId = 'This iRacing ID is already registered';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!formData.country.trim()) {
|
if (!formData.country.trim()) {
|
||||||
newErrors.country = 'Country is required';
|
newErrors.country = 'Country is required';
|
||||||
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
|
} else if (!/^[A-Z]{2,3}$/i.test(formData.country)) {
|
||||||
@@ -68,18 +58,21 @@ export default function CreateDriverForm() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const driverRepo = getDriverRepository();
|
|
||||||
const bio = formData.bio.trim();
|
const bio = formData.bio.trim();
|
||||||
|
|
||||||
const driver = Driver.create({
|
const displayName = formData.name.trim();
|
||||||
id: crypto.randomUUID(),
|
const parts = displayName.split(' ').filter(Boolean);
|
||||||
iracingId: formData.iracingId.trim(),
|
const firstName = parts[0] ?? displayName;
|
||||||
name: formData.name.trim(),
|
const lastName = parts.slice(1).join(' ') || 'Driver';
|
||||||
|
|
||||||
|
await driverService.completeDriverOnboarding({
|
||||||
|
firstName,
|
||||||
|
lastName,
|
||||||
|
displayName,
|
||||||
country: formData.country.trim().toUpperCase(),
|
country: formData.country.trim().toUpperCase(),
|
||||||
...(bio ? { bio } : {}),
|
...(bio ? { bio } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
await driverRepo.create(driver);
|
|
||||||
router.push('/profile');
|
router.push('/profile');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -111,16 +104,16 @@ export default function CreateDriverForm() {
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
|
<label htmlFor="iracingId" className="block text-sm font-medium text-gray-300 mb-2">
|
||||||
iRacing ID *
|
Display Name *
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
id="iracingId"
|
id="name"
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.iracingId}
|
value={formData.name}
|
||||||
onChange={(e) => setFormData({ ...formData, iracingId: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
error={!!errors.iracingId}
|
error={!!errors.name}
|
||||||
errorMessage={errors.iracingId}
|
errorMessage={errors.name}
|
||||||
placeholder="123456"
|
placeholder="Alex Vermeer"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,4 +175,4 @@ export default function CreateDriverForm() {
|
|||||||
</form>
|
</form>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import RaceResultCard from '../races/RaceResultCard';
|
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
|
||||||
|
|
||||||
interface RaceHistoryProps {
|
interface RaceHistoryProps {
|
||||||
driverId: string;
|
driverId: string;
|
||||||
@@ -13,35 +11,14 @@ interface RaceHistoryProps {
|
|||||||
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
||||||
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
|
const [filter, setFilter] = useState<'all' | 'wins' | 'podiums'>('all');
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [races, setRaces] = useState<Race[]>([]);
|
|
||||||
const [results, setResults] = useState<Result[]>([]);
|
|
||||||
const [leagues, setLeagues] = useState<Map<string, League>>(new Map());
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const resultsPerPage = 10;
|
const resultsPerPage = 10;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadRaceHistory() {
|
async function loadRaceHistory() {
|
||||||
try {
|
try {
|
||||||
const resultRepo = getResultRepository();
|
// Driver race history is not exposed via API yet.
|
||||||
const raceRepo = getRaceRepository();
|
// Keep as placeholder until an endpoint exists.
|
||||||
const leagueRepo = getLeagueRepository();
|
|
||||||
|
|
||||||
const driverResults = await resultRepo.findByDriverId(driverId);
|
|
||||||
const allRaces = await raceRepo.findAll();
|
|
||||||
const allLeagues = await leagueRepo.findAll();
|
|
||||||
|
|
||||||
// Filter races to only those where driver has results
|
|
||||||
const raceIds = new Set(driverResults.map(r => r.raceId));
|
|
||||||
const driverRaces = allRaces
|
|
||||||
.filter(race => raceIds.has(race.id) && race.status === 'completed')
|
|
||||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime());
|
|
||||||
|
|
||||||
const leagueMap = new Map<string, League>();
|
|
||||||
allLeagues.forEach(league => leagueMap.set(league.id, league));
|
|
||||||
|
|
||||||
setRaces(driverRaces);
|
|
||||||
setResults(driverResults);
|
|
||||||
setLeagues(leagueMap);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load race history:', err);
|
console.error('Failed to load race history:', err);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -52,22 +29,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
|||||||
loadRaceHistory();
|
loadRaceHistory();
|
||||||
}, [driverId]);
|
}, [driverId]);
|
||||||
|
|
||||||
const raceHistory = races.map(race => {
|
const filteredResults: Array<unknown> = [];
|
||||||
const result = results.find(r => r.raceId === race.id);
|
|
||||||
const league = leagues.get(race.leagueId);
|
|
||||||
return {
|
|
||||||
race,
|
|
||||||
result,
|
|
||||||
league,
|
|
||||||
};
|
|
||||||
}).filter(item => item.result);
|
|
||||||
|
|
||||||
const filteredResults = raceHistory.filter(item => {
|
|
||||||
if (!item.result) return false;
|
|
||||||
if (filter === 'wins') return item.result.position === 1;
|
|
||||||
if (filter === 'podiums') return item.result.position <= 3;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
const totalPages = Math.ceil(filteredResults.length / resultsPerPage);
|
||||||
const paginatedResults = filteredResults.slice(
|
const paginatedResults = filteredResults.slice(
|
||||||
@@ -94,7 +56,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (raceHistory.length === 0) {
|
if (filteredResults.length === 0) {
|
||||||
return (
|
return (
|
||||||
<Card className="text-center py-12">
|
<Card className="text-center py-12">
|
||||||
<p className="text-gray-400 mb-2">No race history yet</p>
|
<p className="text-gray-400 mb-2">No race history yet</p>
|
||||||
@@ -131,19 +93,7 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{paginatedResults.map(({ race, result, league }) => {
|
{/* No results until API provides driver results */}
|
||||||
if (!result || !league) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<RaceResultCard
|
|
||||||
key={race.id}
|
|
||||||
race={race}
|
|
||||||
result={result}
|
|
||||||
league={league}
|
|
||||||
showLeague={true}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
@@ -172,4 +122,4 @@ export default function ProfileRaceHistory({ driverId }: RaceHistoryProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
import Card from '../ui/Card';
|
import Card from '../ui/Card';
|
||||||
import RankBadge from './RankBadge';
|
import RankBadge from './RankBadge';
|
||||||
import { useState, useEffect } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
|
import { useDriverProfile } from '@/hooks/useDriverService';
|
||||||
|
|
||||||
interface ProfileStatsProps {
|
interface ProfileStatsProps {
|
||||||
driverId?: string;
|
driverId?: string;
|
||||||
@@ -17,43 +17,36 @@ interface ProfileStatsProps {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
type DriverProfileOverviewViewModel = ProfileOverviewOutputPort | null;
|
|
||||||
|
|
||||||
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
||||||
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
|
const { data: profileData } = useDriverProfile(driverId ?? '');
|
||||||
|
|
||||||
useEffect(() => {
|
const driverStats = profileData?.stats ?? null;
|
||||||
if (driverId) {
|
const totalDrivers = profileData?.currentDriver?.totalDrivers ?? 0;
|
||||||
const load = async () => {
|
|
||||||
const profileUseCase = getGetProfileOverviewUseCase();
|
// League rank widget needs a dedicated API contract; keep it disabled until provided.
|
||||||
const vm = await profileUseCase.execute({ driverId });
|
// (Leaving UI block out avoids `never` typing issues.)
|
||||||
setProfileData(vm);
|
|
||||||
};
|
const defaultStats = useMemo(() => {
|
||||||
void load();
|
if (stats) {
|
||||||
|
return stats;
|
||||||
}
|
}
|
||||||
}, [driverId]);
|
|
||||||
|
|
||||||
const driverStats = profileData?.stats || null;
|
if (!driverStats) {
|
||||||
const totalDrivers = profileData?.driver?.totalDrivers ?? 0;
|
return null;
|
||||||
const primaryLeagueId = driverId ? getPrimaryLeagueIdForDriver(driverId) : null;
|
}
|
||||||
const leagueRank =
|
|
||||||
driverId && primaryLeagueId ? getLeagueRankings(driverId, primaryLeagueId) : null;
|
|
||||||
|
|
||||||
const defaultStats =
|
return {
|
||||||
stats ||
|
totalRaces: driverStats.totalRaces,
|
||||||
(driverStats
|
wins: driverStats.wins,
|
||||||
? {
|
podiums: driverStats.podiums,
|
||||||
totalRaces: driverStats.totalRaces,
|
dnfs: driverStats.dnfs,
|
||||||
wins: driverStats.wins,
|
avgFinish: driverStats.avgFinish ?? 0,
|
||||||
podiums: driverStats.podiums,
|
completionRate:
|
||||||
dnfs: driverStats.dnfs,
|
driverStats.totalRaces > 0
|
||||||
avgFinish: driverStats.avgFinish ?? 0,
|
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
||||||
completionRate:
|
: 0,
|
||||||
driverStats.totalRaces > 0
|
};
|
||||||
? ((driverStats.totalRaces - driverStats.dnfs) / driverStats.totalRaces) * 100
|
}, [stats, driverStats]);
|
||||||
: 0,
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const winRate =
|
const winRate =
|
||||||
defaultStats && defaultStats.totalRaces > 0
|
defaultStats && defaultStats.totalRaces > 0
|
||||||
@@ -134,27 +127,7 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{leagueRank && leagueRank.totalDrivers > 0 && (
|
{/* Primary-league ranking removed until we have a dedicated API + view model for league ranks. */}
|
||||||
<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 gap-3">
|
|
||||||
<RankBadge rank={leagueRank.rank} size="md" />
|
|
||||||
<div>
|
|
||||||
<div className="text-white font-medium">Primary League</div>
|
|
||||||
<div className="text-sm text-gray-400">
|
|
||||||
{leagueRank.rank} of {leagueRank.totalDrivers} drivers
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className={`text-sm font-medium ${getPercentileColor(leagueRank.percentile)}`}>
|
|
||||||
{getPercentileLabel(leagueRank.percentile)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500">League Percentile</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
@@ -264,4 +237,4 @@ function PerformanceRow({ label, races, wins, podiums, avgFinish }: {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useEffect, useState } 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 '@core/social/application/dto/FeedItemDTO';
|
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||||
|
|
||||||
function timeAgo(timestamp: Date | string): string {
|
function timeAgo(timestamp: Date | string): string {
|
||||||
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
const date = typeof timestamp === 'string' ? new Date(timestamp) : timestamp;
|
||||||
@@ -16,32 +16,14 @@ function timeAgo(timestamp: Date | string): string {
|
|||||||
return `${diffDays} d ago`;
|
return `${diffDays} d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function resolveActor(item: FeedItemDTO) {
|
async function resolveActor(_item: DashboardFeedItemSummaryViewModel) {
|
||||||
const driverRepo = getDriverRepository();
|
// Actor resolution is not wired through the API in this build.
|
||||||
const imageService = getImageService();
|
// Keep rendering deterministic and decoupled (no core repos).
|
||||||
|
|
||||||
const actorId = item.actorFriendId ?? item.actorDriverId;
|
|
||||||
if (!actorId) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const driver = await driverRepo.findById(actorId);
|
|
||||||
if (driver) {
|
|
||||||
return {
|
|
||||||
name: driver.name,
|
|
||||||
avatarUrl: imageService.getDriverAvatar(driver.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// ignore and fall through to generic rendering
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FeedItemCardProps {
|
interface FeedItemCardProps {
|
||||||
item: FeedItemDTO;
|
item: DashboardFeedItemSummaryViewModel;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedItemCard({ item }: FeedItemCardProps) {
|
export default function FeedItemCard({ item }: FeedItemCardProps) {
|
||||||
@@ -110,4 +92,4 @@ export default function FeedItemCard({ item }: FeedItemCardProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,28 @@
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import type { FeedItemDTO } from '@core/social/application/dto/FeedItemDTO';
|
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||||
import type { Race } from '@core/racing/domain/entities/Race';
|
|
||||||
import type { RaceWithResultsDTO } from '@core/testing-support';
|
|
||||||
import FeedList from '@/components/feed/FeedList';
|
import FeedList from '@/components/feed/FeedList';
|
||||||
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar';
|
||||||
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
import LatestResultsSidebar from '@/components/races/LatestResultsSidebar';
|
||||||
|
|
||||||
|
type FeedUpcomingRace = {
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedLatestResult = {
|
||||||
|
raceId: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
winnerName: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
interface FeedLayoutProps {
|
interface FeedLayoutProps {
|
||||||
feedItems: FeedItemDTO[];
|
feedItems: DashboardFeedItemSummaryViewModel[];
|
||||||
upcomingRaces: Race[];
|
upcomingRaces: FeedUpcomingRace[];
|
||||||
latestResults: RaceWithResultsDTO[];
|
latestResults: FeedLatestResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedLayout({
|
export default function FeedLayout({
|
||||||
@@ -40,4 +53,4 @@ export default function FeedLayout({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import FeedEmptyState from '@/components/feed/FeedEmptyState';
|
import FeedEmptyState from '@/components/feed/FeedEmptyState';
|
||||||
import FeedItemCard from '@/components/feed/FeedItemCard';
|
import FeedItemCard from '@/components/feed/FeedItemCard';
|
||||||
import type { FeedItemDTO } from '@core/social/application/dto/FeedItemDTO';
|
import type { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
|
||||||
|
|
||||||
interface FeedListProps {
|
interface FeedListProps {
|
||||||
items: FeedItemDTO[];
|
items: DashboardFeedItemSummaryViewModel[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FeedList({ items }: FeedListProps) {
|
export default function FeedList({ items }: FeedListProps) {
|
||||||
@@ -18,4 +18,4 @@ export default function FeedList({ items }: FeedListProps) {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,19 @@
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import type { LeagueScoringChampionshipDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
|
import type { LeagueScoringChampionshipDTO } from '@/lib/types/generated/LeagueScoringChampionshipDTO';
|
||||||
|
|
||||||
|
type PointsPreviewRow = {
|
||||||
|
sessionType: string;
|
||||||
|
position: number;
|
||||||
|
points: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface ChampionshipCardProps {
|
interface ChampionshipCardProps {
|
||||||
championship: LeagueScoringChampionshipDTO;
|
championship: LeagueScoringChampionshipDTO;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
||||||
|
const pointsPreview = (championship.pointsPreview as unknown as PointsPreviewRow[]) ?? [];
|
||||||
|
const dropPolicyDescription = (championship as unknown as { dropPolicyDescription?: string }).dropPolicyDescription ?? '';
|
||||||
const getTypeLabel = (type: string): string => {
|
const getTypeLabel = (type: string): string => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'driver':
|
case 'driver':
|
||||||
@@ -66,12 +74,12 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Points Preview */}
|
{/* Points Preview */}
|
||||||
{championship.pointsPreview.length > 0 && (
|
{pointsPreview.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
|
<h4 className="text-xs font-medium text-gray-500 uppercase tracking-wider mb-2">Points Distribution</h4>
|
||||||
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
|
<div className="bg-deep-graphite rounded-lg border border-charcoal-outline p-4">
|
||||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-6 gap-3">
|
||||||
{championship.pointsPreview.slice(0, 6).map((preview, idx) => (
|
{pointsPreview.slice(0, 6).map((preview, idx) => (
|
||||||
<div key={idx} className="text-center">
|
<div key={idx} className="text-center">
|
||||||
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
|
<div className="text-xs text-gray-500 mb-1">P{preview.position}</div>
|
||||||
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
|
<div className="text-lg font-bold text-white tabular-nums">{preview.points}</div>
|
||||||
@@ -87,9 +95,9 @@ export function ChampionshipCard({ championship }: ChampionshipCardProps) {
|
|||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center gap-2 mb-2">
|
||||||
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
|
<span className="text-xs font-medium text-gray-500 uppercase tracking-wider">Drop Policy</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-gray-300">{championship.dropPolicyDescription}</p>
|
<p className="text-sm text-gray-300">{dropPolicyDescription}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { getMembership } from '@/lib/leagueMembership';
|
import { getMembership } from '@/lib/leagueMembership';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
|
|
||||||
interface JoinLeagueButtonProps {
|
interface JoinLeagueButtonProps {
|
||||||
@@ -18,6 +19,7 @@ export default function JoinLeagueButton({
|
|||||||
}: JoinLeagueButtonProps) {
|
}: JoinLeagueButtonProps) {
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const membership = getMembership(leagueId, currentDriverId);
|
const membership = getMembership(leagueId, currentDriverId);
|
||||||
|
const { leagueMembershipService } = useServices();
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -28,21 +30,13 @@ export default function JoinLeagueButton({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const membershipRepo = getLeagueMembershipRepository();
|
|
||||||
|
|
||||||
if (isInviteOnly) {
|
if (isInviteOnly) {
|
||||||
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
|
||||||
if (existing) {
|
|
||||||
throw new Error('Already a member or have a pending request');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Requesting to join invite-only leagues is not available in this alpha build.',
|
'Requesting to join invite-only leagues is not available in this alpha build.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const useCase = getJoinLeagueUseCase();
|
await leagueMembershipService.joinLeague(leagueId, currentDriverId);
|
||||||
await useCase.execute({ leagueId, driverId: currentDriverId });
|
|
||||||
|
|
||||||
onMembershipChange?.();
|
onMembershipChange?.();
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
@@ -57,15 +51,11 @@ export default function JoinLeagueButton({
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const membershipRepo = getLeagueMembershipRepository();
|
if (membership?.role === 'owner') {
|
||||||
const existing = await membershipRepo.getMembership(leagueId, currentDriverId);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error('Not a member of this league');
|
|
||||||
}
|
|
||||||
if (existing.role === 'owner') {
|
|
||||||
throw new Error('League owner cannot leave the league');
|
throw new Error('League owner cannot leave the league');
|
||||||
}
|
}
|
||||||
await membershipRepo.removeMembership(leagueId, currentDriverId);
|
|
||||||
|
await leagueMembershipService.leaveLeague(leagueId, currentDriverId);
|
||||||
|
|
||||||
onMembershipChange?.();
|
onMembershipChange?.();
|
||||||
setShowConfirmDialog(false);
|
setShowConfirmDialog(false);
|
||||||
@@ -171,4 +161,4 @@ export default function JoinLeagueButton({
|
|||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
|
import { Calendar, Award, UserPlus, UserMinus, Shield, Flag, AlertTriangle } from 'lucide-react';
|
||||||
import { Race, Penalty } from '@core/racing';
|
|
||||||
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { Driver } from '@core/racing';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel';
|
||||||
|
|
||||||
export type LeagueActivity =
|
export type LeagueActivity =
|
||||||
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
||||||
@@ -33,65 +32,42 @@ function timeAgo(timestamp: Date): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
|
export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedProps) {
|
||||||
|
const { raceService, driverService } = useServices();
|
||||||
const [activities, setActivities] = useState<LeagueActivity[]>([]);
|
const [activities, setActivities] = useState<LeagueActivity[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadActivities() {
|
async function loadActivities() {
|
||||||
try {
|
try {
|
||||||
const raceRepo = getRaceRepository();
|
const raceList = await raceService.findByLeagueId(leagueId);
|
||||||
const penaltyRepo = getPenaltyRepository();
|
|
||||||
const driverRepo = getDriverRepository();
|
|
||||||
|
|
||||||
const races = await raceRepo.findByLeagueId(leagueId);
|
const completedRaces = raceList
|
||||||
const drivers = await driverRepo.findAll();
|
.filter((r) => r.status === 'completed')
|
||||||
const driversMap = new Map(drivers.map(d => [d.id, d]));
|
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const upcomingRaces = raceList
|
||||||
|
.filter((r) => r.status === 'scheduled')
|
||||||
|
.sort((a, b) => new Date(b.scheduledAt).getTime() - new Date(a.scheduledAt).getTime())
|
||||||
|
.slice(0, 3);
|
||||||
|
|
||||||
const activityList: LeagueActivity[] = [];
|
const activityList: LeagueActivity[] = [];
|
||||||
|
|
||||||
// Add completed races
|
|
||||||
const completedRaces = races.filter(r => r.status === 'completed')
|
|
||||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
|
||||||
.slice(0, 5);
|
|
||||||
|
|
||||||
for (const race of completedRaces) {
|
for (const race of completedRaces) {
|
||||||
activityList.push({
|
activityList.push({
|
||||||
type: 'race_completed',
|
type: 'race_completed',
|
||||||
raceId: race.id,
|
raceId: race.id,
|
||||||
raceName: `${race.track} - ${race.car}`,
|
raceName: `${race.track} - ${race.car}`,
|
||||||
timestamp: race.scheduledAt,
|
timestamp: new Date(race.scheduledAt),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add penalties from this race
|
|
||||||
const racePenalties = await penaltyRepo.findByRaceId(race.id);
|
|
||||||
const appliedPenalties = racePenalties.filter(p => p.status === 'applied' && p.type === 'points_deduction');
|
|
||||||
|
|
||||||
for (const penalty of appliedPenalties) {
|
|
||||||
const driver = driversMap.get(penalty.driverId);
|
|
||||||
if (driver && penalty.value) {
|
|
||||||
activityList.push({
|
|
||||||
type: 'penalty_applied',
|
|
||||||
penaltyId: penalty.id,
|
|
||||||
driverName: driver.name,
|
|
||||||
reason: penalty.reason,
|
|
||||||
points: penalty.value,
|
|
||||||
timestamp: penalty.appliedAt || penalty.issuedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add scheduled races
|
|
||||||
const upcomingRaces = races.filter(r => r.status === 'scheduled')
|
|
||||||
.sort((a, b) => b.scheduledAt.getTime() - a.scheduledAt.getTime())
|
|
||||||
.slice(0, 3);
|
|
||||||
|
|
||||||
for (const race of upcomingRaces) {
|
for (const race of upcomingRaces) {
|
||||||
activityList.push({
|
activityList.push({
|
||||||
type: 'race_scheduled',
|
type: 'race_scheduled',
|
||||||
raceId: race.id,
|
raceId: race.id,
|
||||||
raceName: `${race.track} - ${race.car}`,
|
raceName: `${race.track} - ${race.car}`,
|
||||||
timestamp: new Date(race.scheduledAt.getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
|
timestamp: new Date(new Date(race.scheduledAt).getTime() - 7 * 24 * 60 * 60 * 1000), // Simulate schedule announcement
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,7 +83,7 @@ export default function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActiv
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadActivities();
|
loadActivities();
|
||||||
}, [leagueId, limit]);
|
}, [leagueId, limit, raceService, driverService]);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -217,4 +193,4 @@ function ActivityItem({ activity }: { activity: LeagueActivity }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
|
||||||
import { DriverViewModel } from '@/lib/view-models';
|
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
|
||||||
|
|
||||||
interface LeagueChampionshipStatsProps {
|
interface LeagueChampionshipStatsProps {
|
||||||
standings: StandingEntryViewModel[];
|
standings: StandingEntryViewModel[];
|
||||||
@@ -56,4 +56,4 @@ export default function LeagueChampionshipStats({ standings, drivers }: LeagueCh
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
import MembershipStatus from '@/components/leagues/MembershipStatus';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
|
|
||||||
@@ -27,8 +28,8 @@ export default function LeagueHeader({
|
|||||||
ownerId,
|
ownerId,
|
||||||
mainSponsor,
|
mainSponsor,
|
||||||
}: LeagueHeaderProps) {
|
}: LeagueHeaderProps) {
|
||||||
const imageService = getImageService();
|
const { mediaService } = useServices();
|
||||||
const logoUrl = imageService.getLeagueLogo(leagueId);
|
const logoUrl = mediaService.getLeagueLogo(leagueId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
@@ -76,4 +77,4 @@ export default function LeagueHeader({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import type { LeagueScheduleRaceItemViewModel } from '@/lib/presenters/LeagueSchedulePresenter';
|
import { useLeagueSchedule } from '@/hooks/useLeagueService';
|
||||||
|
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useCallback, useEffect, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
|
|
||||||
interface LeagueScheduleProps {
|
interface LeagueScheduleProps {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
@@ -11,69 +12,44 @@ interface LeagueScheduleProps {
|
|||||||
|
|
||||||
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [races, setRaces] = useState<LeagueScheduleRaceItemViewModel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||||
const [registrationStates, setRegistrationStates] = useState<Record<string, boolean>>({});
|
|
||||||
const [processingRace, setProcessingRace] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
const loadRacesCallback = useCallback(async () => {
|
const { data: schedule, isLoading } = useLeagueSchedule(leagueId);
|
||||||
setLoading(true);
|
const registerMutation = useRegisterForRace();
|
||||||
try {
|
const withdrawMutation = useWithdrawFromRace();
|
||||||
const viewModel = await loadLeagueSchedule(leagueId, currentDriverId);
|
|
||||||
setRaces(viewModel.races);
|
|
||||||
|
|
||||||
const states: Record<string, boolean> = {};
|
const races = useMemo(() => {
|
||||||
for (const race of viewModel.races) {
|
// Current contract uses `unknown[]` for races; treat as any until a proper schedule DTO/view-model is introduced.
|
||||||
states[race.id] = race.isRegistered;
|
return (schedule?.races ?? []) as Array<any>;
|
||||||
}
|
}, [schedule]);
|
||||||
setRegistrationStates(states);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load races:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [leagueId, currentDriverId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRegister = async (race: any, e: React.MouseEvent) => {
|
||||||
void loadRacesCallback();
|
|
||||||
}, [loadRacesCallback]);
|
|
||||||
|
|
||||||
const handleRegister = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const confirmed = window.confirm(`Register for ${race.track}?`);
|
const confirmed = window.confirm(`Register for ${race.track}?`);
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setProcessingRace(race.id);
|
|
||||||
try {
|
try {
|
||||||
await registerForRace(race.id, leagueId, currentDriverId);
|
await registerMutation.mutateAsync({ raceId: race.id, leagueId, driverId: currentDriverId });
|
||||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: true }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Failed to register');
|
alert(err instanceof Error ? err.message : 'Failed to register');
|
||||||
} finally {
|
|
||||||
setProcessingRace(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWithdraw = async (race: LeagueScheduleRaceItemViewModel, e: React.MouseEvent) => {
|
const handleWithdraw = async (race: any, e: React.MouseEvent) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
||||||
const confirmed = window.confirm('Withdraw from this race?');
|
const confirmed = window.confirm('Withdraw from this race?');
|
||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
setProcessingRace(race.id);
|
|
||||||
try {
|
try {
|
||||||
await withdrawFromRace(race.id, currentDriverId);
|
await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
|
||||||
setRegistrationStates((prev) => ({ ...prev, [race.id]: false }));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
alert(err instanceof Error ? err.message : 'Failed to withdraw');
|
||||||
} finally {
|
|
||||||
setProcessingRace(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +71,7 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
|
|
||||||
const displayRaces = getDisplayRaces();
|
const displayRaces = getDisplayRaces();
|
||||||
|
|
||||||
if (loading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="text-center py-8 text-gray-400">
|
<div className="text-center py-8 text-gray-400">
|
||||||
Loading schedule...
|
Loading schedule...
|
||||||
@@ -157,6 +133,9 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
{displayRaces.map((race) => {
|
{displayRaces.map((race) => {
|
||||||
const isPast = race.isPast;
|
const isPast = race.isPast;
|
||||||
const isUpcoming = race.isUpcoming;
|
const isUpcoming = race.isUpcoming;
|
||||||
|
const isRegistered = Boolean(race.isRegistered);
|
||||||
|
const isProcessing =
|
||||||
|
registerMutation.isPending || withdrawMutation.isPending;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -172,12 +151,12 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
||||||
<h3 className="text-white font-medium">{race.track}</h3>
|
<h3 className="text-white font-medium">{race.track}</h3>
|
||||||
{isUpcoming && !registrationStates[race.id] && (
|
{isUpcoming && !isRegistered && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
|
||||||
Upcoming
|
Upcoming
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{isUpcoming && registrationStates[race.id] && (
|
{isUpcoming && isRegistered && (
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
|
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
|
||||||
✓ Registered
|
✓ Registered
|
||||||
</span>
|
</span>
|
||||||
@@ -217,21 +196,21 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
{/* Registration Actions */}
|
{/* Registration Actions */}
|
||||||
{isUpcoming && (
|
{isUpcoming && (
|
||||||
<div onClick={(e) => e.stopPropagation()}>
|
<div onClick={(e) => e.stopPropagation()}>
|
||||||
{!registrationStates[race.id] ? (
|
{!isRegistered ? (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleRegister(race, e)}
|
onClick={(e) => handleRegister(race, e)}
|
||||||
disabled={processingRace === race.id}
|
disabled={isProcessing}
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{processingRace === race.id ? 'Registering...' : 'Register'}
|
{registerMutation.isPending ? 'Registering...' : 'Register'}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => handleWithdraw(race, e)}
|
onClick={(e) => handleWithdraw(race, e)}
|
||||||
disabled={processingRace === race.id}
|
disabled={isProcessing}
|
||||||
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{processingRace === race.id ? 'Withdrawing...' : 'Withdraw'}
|
{withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -245,4 +224,4 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import React, { useState, useRef, useEffect } from 'react';
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
|
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { LeagueScoringPresetDTO } from '@/hooks/useLeagueScoringPresets';
|
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
|
||||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -1157,4 +1157,4 @@ export function ChampionshipsSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,22 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import type { LeagueScoringConfigDTO } from '@core/racing/application/dto/LeagueScoringConfigDTO';
|
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||||
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
|
import { Trophy, Clock, Target, Zap, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
type LeagueScoringConfigUi = LeagueScoringConfigDTO & {
|
||||||
|
scoringPresetName?: string;
|
||||||
|
dropPolicySummary?: string;
|
||||||
|
championships?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'driver' | 'team' | 'nations' | 'trophy' | string;
|
||||||
|
sessionTypes: string[];
|
||||||
|
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
|
||||||
|
bonusSummary: string[];
|
||||||
|
dropPolicyDescription?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
interface LeagueScoringTabProps {
|
interface LeagueScoringTabProps {
|
||||||
scoringConfig: LeagueScoringConfigDTO | null;
|
scoringConfig: LeagueScoringConfigDTO | null;
|
||||||
practiceMinutes?: number;
|
practiceMinutes?: number;
|
||||||
@@ -32,9 +46,12 @@ export default function LeagueScoringTab({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ui = scoringConfig as unknown as LeagueScoringConfigUi;
|
||||||
|
const championships = ui.championships ?? [];
|
||||||
|
|
||||||
const primaryChampionship =
|
const primaryChampionship =
|
||||||
scoringConfig.championships.find((c) => c.type === 'driver') ??
|
championships.find((c) => c.type === 'driver') ??
|
||||||
scoringConfig.championships[0];
|
championships[0];
|
||||||
|
|
||||||
const resolvedPractice = practiceMinutes ?? 20;
|
const resolvedPractice = practiceMinutes ?? 20;
|
||||||
const resolvedQualifying = qualifyingMinutes ?? 30;
|
const resolvedQualifying = qualifyingMinutes ?? 30;
|
||||||
@@ -54,10 +71,10 @@ export default function LeagueScoringTab({
|
|||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{scoringConfig.gameName}{' '}
|
{scoringConfig.gameName}{' '}
|
||||||
{scoringConfig.scoringPresetName
|
{ui.scoringPresetName
|
||||||
? `• ${scoringConfig.scoringPresetName}`
|
? `• ${ui.scoringPresetName}`
|
||||||
: '• Custom scoring'}{' '}
|
: '• Custom scoring'}{' '}
|
||||||
• {scoringConfig.dropPolicySummary}
|
{ui.dropPolicySummary ? `• ${ui.dropPolicySummary}` : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -71,7 +88,7 @@ export default function LeagueScoringTab({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
{primaryChampionship.sessionTypes.map((session) => (
|
{primaryChampionship.sessionTypes.map((session: string) => (
|
||||||
<span
|
<span
|
||||||
key={session}
|
key={session}
|
||||||
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
|
className="px-3 py-1 rounded-full bg-primary-blue/10 text-primary-blue border border-primary-blue/20 font-medium"
|
||||||
@@ -106,7 +123,7 @@ export default function LeagueScoringTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{scoringConfig.championships.map((championship) => (
|
{championships.map((championship) => (
|
||||||
<div
|
<div
|
||||||
key={championship.id}
|
key={championship.id}
|
||||||
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
|
className="border border-charcoal-outline rounded-lg bg-iron-gray/40 p-4 space-y-4"
|
||||||
@@ -128,7 +145,7 @@ export default function LeagueScoringTab({
|
|||||||
</div>
|
</div>
|
||||||
{championship.sessionTypes.length > 0 && (
|
{championship.sessionTypes.length > 0 && (
|
||||||
<div className="flex flex-wrap gap-1 justify-end">
|
<div className="flex flex-wrap gap-1 justify-end">
|
||||||
{championship.sessionTypes.map((session) => (
|
{championship.sessionTypes.map((session: string) => (
|
||||||
<span
|
<span
|
||||||
key={session}
|
key={session}
|
||||||
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
|
className="px-2 py-0.5 rounded-full bg-charcoal-outline/60 text-xs text-gray-200"
|
||||||
@@ -161,7 +178,7 @@ export default function LeagueScoringTab({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{championship.pointsPreview.map((row, index) => (
|
{championship.pointsPreview.map((row, index: number) => (
|
||||||
<tr
|
<tr
|
||||||
key={`${row.sessionType}-${row.position}-${index}`}
|
key={`${row.sessionType}-${row.position}-${index}`}
|
||||||
className="border-b border-charcoal-outline/30"
|
className="border-b border-charcoal-outline/30"
|
||||||
@@ -192,7 +209,7 @@ export default function LeagueScoringTab({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
|
<ul className="list-disc list-inside text-xs text-gray-300 space-y-1">
|
||||||
{championship.bonusSummary.map((item, index) => (
|
{championship.bonusSummary.map((item: string, index: number) => (
|
||||||
<li key={index}>{item}</li>
|
<li key={index}>{item}</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -207,11 +224,11 @@ export default function LeagueScoringTab({
|
|||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-300">
|
<p className="text-xs text-gray-300">
|
||||||
{championship.dropPolicyDescription}
|
{championship.dropPolicyDescription ?? ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function LeagueSponsorshipsSection({
|
|||||||
readOnly = false
|
readOnly = false
|
||||||
}: LeagueSponsorshipsSectionProps) {
|
}: LeagueSponsorshipsSectionProps) {
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const { sponsorshipService } = useServices();
|
const { sponsorshipService, leagueService } = useServices();
|
||||||
const [slots, setSlots] = useState<SponsorshipSlot[]>([
|
const [slots, setSlots] = useState<SponsorshipSlot[]>([
|
||||||
{ tier: 'main', price: 500, isOccupied: false },
|
{ tier: 'main', price: 500, isOccupied: false },
|
||||||
{ tier: 'secondary', price: 200, isOccupied: false },
|
{ tier: 'secondary', price: 200, isOccupied: false },
|
||||||
@@ -49,18 +49,15 @@ export function LeagueSponsorshipsSection({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const seasonRepo = getSeasonRepository();
|
const seasons = await leagueService.getLeagueSeasons(leagueId);
|
||||||
const seasons = await seasonRepo.findByLeagueId(leagueId);
|
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||||
const activeSeason = seasons.find(s => s.status === 'active') ?? seasons[0];
|
if (activeSeason) setSeasonId(activeSeason.seasonId);
|
||||||
if (activeSeason) {
|
|
||||||
setSeasonId(activeSeason.id);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load season:', err);
|
console.error('Failed to load season:', err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
loadSeasonId();
|
loadSeasonId();
|
||||||
}, [leagueId, propSeasonId]);
|
}, [leagueId, propSeasonId, leagueService]);
|
||||||
|
|
||||||
// Load pending sponsorship requests
|
// Load pending sponsorship requests
|
||||||
const loadPendingRequests = useCallback(async () => {
|
const loadPendingRequests = useCallback(async () => {
|
||||||
@@ -68,25 +65,36 @@ export function LeagueSponsorshipsSection({
|
|||||||
|
|
||||||
setRequestsLoading(true);
|
setRequestsLoading(true);
|
||||||
try {
|
try {
|
||||||
const useCase = getGetPendingSponsorshipRequestsUseCase();
|
const requests = await sponsorshipService.getPendingSponsorshipRequests({
|
||||||
const presenter = new PendingSponsorshipRequestsPresenter();
|
entityType: 'season',
|
||||||
|
entityId: seasonId,
|
||||||
|
});
|
||||||
|
|
||||||
await useCase.execute(
|
// Convert service view-models to component DTO type (UI-only)
|
||||||
{
|
setPendingRequests(
|
||||||
entityType: 'season',
|
requests.map(
|
||||||
entityId: seasonId,
|
(r): PendingRequestDTO => ({
|
||||||
},
|
id: r.id,
|
||||||
presenter,
|
sponsorId: r.sponsorId,
|
||||||
|
sponsorName: r.sponsorName,
|
||||||
|
sponsorLogo: r.sponsorLogo,
|
||||||
|
tier: r.tier,
|
||||||
|
offeredAmount: r.offeredAmount,
|
||||||
|
currency: r.currency,
|
||||||
|
formattedAmount: r.formattedAmount,
|
||||||
|
message: r.message,
|
||||||
|
createdAt: r.createdAt,
|
||||||
|
platformFee: r.platformFee,
|
||||||
|
netAmount: r.netAmount,
|
||||||
|
}),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
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 {
|
||||||
setRequestsLoading(false);
|
setRequestsLoading(false);
|
||||||
}
|
}
|
||||||
}, [seasonId]);
|
}, [seasonId, sponsorshipService]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadPendingRequests();
|
loadPendingRequests();
|
||||||
@@ -94,11 +102,7 @@ export function LeagueSponsorshipsSection({
|
|||||||
|
|
||||||
const handleAcceptRequest = async (requestId: string) => {
|
const handleAcceptRequest = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
const useCase = getAcceptSponsorshipRequestUseCase();
|
await sponsorshipService.acceptSponsorshipRequest(requestId, currentDriverId);
|
||||||
await useCase.execute({
|
|
||||||
requestId,
|
|
||||||
respondedBy: currentDriverId,
|
|
||||||
});
|
|
||||||
await loadPendingRequests();
|
await loadPendingRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to accept request:', err);
|
console.error('Failed to accept request:', err);
|
||||||
@@ -108,12 +112,7 @@ export function LeagueSponsorshipsSection({
|
|||||||
|
|
||||||
const handleRejectRequest = async (requestId: string, reason?: string) => {
|
const handleRejectRequest = async (requestId: string, reason?: string) => {
|
||||||
try {
|
try {
|
||||||
const useCase = getRejectSponsorshipRequestUseCase();
|
await sponsorshipService.rejectSponsorshipRequest(requestId, currentDriverId, reason);
|
||||||
await useCase.execute({
|
|
||||||
requestId,
|
|
||||||
respondedBy: currentDriverId,
|
|
||||||
...(reason ? { reason } : {}),
|
|
||||||
});
|
|
||||||
await loadPendingRequests();
|
await loadPendingRequests();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to reject request:', err);
|
console.error('Failed to reject request:', err);
|
||||||
@@ -324,4 +323,4 @@ export function LeagueSponsorshipsSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
|
import { Scale, Users, Clock, Bell, Shield, Vote, UserCheck, AlertTriangle } from 'lucide-react';
|
||||||
import type { LeagueConfigFormModel, LeagueStewardingFormDTO } from '@core/racing/application';
|
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||||
import type { StewardingDecisionMode } from '@core/racing/domain/entities/League';
|
|
||||||
|
|
||||||
interface LeagueStewardingSectionProps {
|
interface LeagueStewardingSectionProps {
|
||||||
form: LeagueConfigFormModel;
|
form: LeagueConfigFormModel;
|
||||||
@@ -12,7 +11,7 @@ interface LeagueStewardingSectionProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DecisionModeOption = {
|
type DecisionModeOption = {
|
||||||
value: StewardingDecisionMode;
|
value: NonNullable<LeagueConfigFormModel['stewarding']>['decisionMode'];
|
||||||
label: string;
|
label: string;
|
||||||
description: string;
|
description: string;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
@@ -21,40 +20,19 @@ type DecisionModeOption = {
|
|||||||
|
|
||||||
const decisionModeOptions: DecisionModeOption[] = [
|
const decisionModeOptions: DecisionModeOption[] = [
|
||||||
{
|
{
|
||||||
value: 'admin_only',
|
value: 'single_steward',
|
||||||
label: 'Admin Decision',
|
label: 'Single Steward',
|
||||||
description: 'League admins make all penalty decisions',
|
description: 'A single steward/admin makes all penalty decisions',
|
||||||
icon: <Shield className="w-5 h-5" />,
|
icon: <Shield className="w-5 h-5" />,
|
||||||
requiresVotes: false,
|
requiresVotes: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'steward_vote',
|
value: 'committee_vote',
|
||||||
label: 'Steward Vote',
|
label: 'Committee Vote',
|
||||||
description: 'Designated stewards vote to uphold protests',
|
description: 'A group votes to uphold/dismiss protests',
|
||||||
icon: <Scale className="w-5 h-5" />,
|
icon: <Scale className="w-5 h-5" />,
|
||||||
requiresVotes: true,
|
requiresVotes: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
value: 'member_vote',
|
|
||||||
label: 'Member Vote',
|
|
||||||
description: 'All league members vote on protests',
|
|
||||||
icon: <Users className="w-5 h-5" />,
|
|
||||||
requiresVotes: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'steward_veto',
|
|
||||||
label: 'Steward Veto',
|
|
||||||
description: 'Protests upheld unless stewards vote against',
|
|
||||||
icon: <Vote className="w-5 h-5" />,
|
|
||||||
requiresVotes: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: 'member_veto',
|
|
||||||
label: 'Member Veto',
|
|
||||||
description: 'Protests upheld unless members vote against',
|
|
||||||
icon: <UserCheck className="w-5 h-5" />,
|
|
||||||
requiresVotes: true,
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export function LeagueStewardingSection({
|
export function LeagueStewardingSection({
|
||||||
@@ -64,7 +42,7 @@ export function LeagueStewardingSection({
|
|||||||
}: LeagueStewardingSectionProps) {
|
}: LeagueStewardingSectionProps) {
|
||||||
// Provide default stewarding settings if not present
|
// Provide default stewarding settings if not present
|
||||||
const stewarding = form.stewarding ?? {
|
const stewarding = form.stewarding ?? {
|
||||||
decisionMode: 'admin_only' as const,
|
decisionMode: 'single_steward' as const,
|
||||||
requiredVotes: 2,
|
requiredVotes: 2,
|
||||||
requireDefense: false,
|
requireDefense: false,
|
||||||
defenseTimeLimit: 48,
|
defenseTimeLimit: 48,
|
||||||
@@ -75,7 +53,7 @@ export function LeagueStewardingSection({
|
|||||||
notifyOnVoteRequired: true,
|
notifyOnVoteRequired: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateStewarding = (updates: Partial<LeagueStewardingFormDTO>) => {
|
const updateStewarding = (updates: Partial<NonNullable<LeagueConfigFormModel['stewarding']>>) => {
|
||||||
onChange({
|
onChange({
|
||||||
...form,
|
...form,
|
||||||
stewarding: {
|
stewarding: {
|
||||||
@@ -147,7 +125,7 @@ export function LeagueStewardingSection({
|
|||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
<label className="block text-xs font-medium text-gray-400 mb-1.5">
|
||||||
Required votes to {stewarding.decisionMode.includes('veto') ? 'block' : 'uphold'}
|
Required votes to uphold
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={stewarding.requiredVotes ?? 2}
|
value={stewarding.requiredVotes ?? 2}
|
||||||
@@ -375,7 +353,7 @@ export function LeagueStewardingSection({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Warning about strict settings */}
|
{/* Warning about strict settings */}
|
||||||
{stewarding.requireDefense && stewarding.decisionMode !== 'admin_only' && (
|
{stewarding.requireDefense && stewarding.decisionMode !== 'single_steward' && (
|
||||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
<div className="flex items-start gap-3 p-4 rounded-xl bg-warning-amber/10 border border-warning-amber/20">
|
||||||
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||||
<div>
|
<div>
|
||||||
@@ -389,4 +367,4 @@ export function LeagueStewardingSection({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { useState, useRef, useEffect } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { Star } from 'lucide-react';
|
import { Star } from 'lucide-react';
|
||||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||||
import type { LeagueDriverSeasonStatsDTO } from '@core/racing/application/dto/LeagueDriverSeasonStatsDTO';
|
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
|
||||||
import type { LeagueMembership, MembershipRole } from '@/lib/leagueMembership';
|
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
|
||||||
import { getLeagueRoleDisplay } from '@/lib/leagueRoles';
|
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
||||||
import CountryFlag from '@/components/ui/CountryFlag';
|
import CountryFlag from '@/components/ui/CountryFlag';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
// Position background colors
|
// Position background colors
|
||||||
const getPositionBgColor = (position: number): string => {
|
const getPositionBgColor = (position: number): string => {
|
||||||
@@ -21,14 +22,25 @@ const getPositionBgColor = (position: number): string => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
interface StandingsTableProps {
|
interface StandingsTableProps {
|
||||||
standings: LeagueDriverSeasonStatsDTO[];
|
standings: Array<{
|
||||||
|
leagueId: string;
|
||||||
|
driverId: string;
|
||||||
|
position: number;
|
||||||
|
totalPoints: number;
|
||||||
|
racesFinished: number;
|
||||||
|
racesStarted: number;
|
||||||
|
avgFinish: number | null;
|
||||||
|
penaltyPoints: number;
|
||||||
|
bonusPoints: number;
|
||||||
|
teamName?: string;
|
||||||
|
}>;
|
||||||
drivers: DriverDTO[];
|
drivers: DriverDTO[];
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
memberships?: LeagueMembership[];
|
memberships?: LeagueMembership[];
|
||||||
currentDriverId?: string;
|
currentDriverId?: string;
|
||||||
isAdmin?: boolean;
|
isAdmin?: boolean;
|
||||||
onRemoveMember?: (driverId: string) => void;
|
onRemoveMember?: (driverId: string) => void;
|
||||||
onUpdateRole?: (driverId: string, role: MembershipRole) => void;
|
onUpdateRole?: (driverId: string, role: MembershipRoleDTO['value']) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function StandingsTable({
|
export default function StandingsTable({
|
||||||
@@ -41,6 +53,7 @@ export default function StandingsTable({
|
|||||||
onRemoveMember,
|
onRemoveMember,
|
||||||
onUpdateRole
|
onUpdateRole
|
||||||
}: StandingsTableProps) {
|
}: StandingsTableProps) {
|
||||||
|
const { mediaService } = useServices();
|
||||||
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
const [hoveredRow, setHoveredRow] = useState<string | null>(null);
|
||||||
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
|
const [activeMenu, setActiveMenu] = useState<{ driverId: string; type: 'member' | 'points' } | null>(null);
|
||||||
const menuRef = useRef<HTMLDivElement>(null);
|
const menuRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -78,12 +91,14 @@ export default function StandingsTable({
|
|||||||
return driverId === currentDriverId;
|
return driverId === currentDriverId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type MembershipRole = MembershipRoleDTO['value'];
|
||||||
|
|
||||||
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
|
const handleRoleChange = (driverId: string, newRole: MembershipRole) => {
|
||||||
if (!onUpdateRole) return;
|
if (!onUpdateRole) return;
|
||||||
const membership = getMembership(driverId);
|
const membership = getMembership(driverId);
|
||||||
if (!membership) return;
|
if (!membership) return;
|
||||||
|
|
||||||
const confirmationMessages: Record<MembershipRole, string> = {
|
const confirmationMessages: Record<string, string> = {
|
||||||
owner: 'Cannot promote to owner',
|
owner: 'Cannot promote to owner',
|
||||||
admin: 'Promote this member to Admin? They will have full management permissions.',
|
admin: 'Promote this member to Admin? They will have full management permissions.',
|
||||||
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
|
steward: 'Assign Steward role? They will be able to manage protests and penalties.',
|
||||||
@@ -96,7 +111,7 @@ export default function StandingsTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
|
if (newRole !== membership.role && confirm(confirmationMessages[newRole])) {
|
||||||
onUpdateRole(driverId, newRole);
|
onUpdateRole(driverId, newRole as MembershipRoleDTO['value']);
|
||||||
setActiveMenu(null);
|
setActiveMenu(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -266,9 +281,10 @@ export default function StandingsTable({
|
|||||||
{standings.map((row) => {
|
{standings.map((row) => {
|
||||||
const driver = getDriver(row.driverId);
|
const driver = getDriver(row.driverId);
|
||||||
const membership = getMembership(row.driverId);
|
const membership = getMembership(row.driverId);
|
||||||
const roleDisplay = membership ? getLeagueRoleDisplay(membership.role) : null;
|
const roleDisplay = membership ? LeagueRoleDisplay.getLeagueRoleDisplay(membership.role) : null;
|
||||||
const canModify = canModifyMember(row.driverId);
|
const canModify = canModifyMember(row.driverId);
|
||||||
const driverStatsData = getDriverStats(row.driverId);
|
// TODO: Hook up real driver stats once API provides it
|
||||||
|
const driverStatsData: null = null;
|
||||||
const isRowHovered = hoveredRow === row.driverId;
|
const isRowHovered = hoveredRow === row.driverId;
|
||||||
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
|
const isMemberMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'member';
|
||||||
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
|
const isPointsMenuOpen = activeMenu?.driverId === row.driverId && activeMenu?.type === 'points';
|
||||||
@@ -307,7 +323,7 @@ export default function StandingsTable({
|
|||||||
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
|
<div className="w-10 h-10 rounded-full bg-primary-blue/20 overflow-hidden flex items-center justify-center shrink-0">
|
||||||
{driver && (
|
{driver && (
|
||||||
<Image
|
<Image
|
||||||
src={getImageService().getDriverAvatar(driver.id)}
|
src={mediaService.getDriverAvatar(driver.id)}
|
||||||
alt={driver.name}
|
alt={driver.name}
|
||||||
width={40}
|
width={40}
|
||||||
height={40}
|
height={40}
|
||||||
@@ -344,12 +360,7 @@ export default function StandingsTable({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs flex items-center gap-1">
|
<div className="text-xs flex items-center gap-1">
|
||||||
{driverStatsData && (
|
{/* Rating intentionally omitted until API provides driver stats */}
|
||||||
<span className="inline-flex items-center gap-1 text-amber-300">
|
|
||||||
<Star className="h-3 w-3" />
|
|
||||||
<span className="tabular-nums font-medium">{driverStatsData.rating}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -435,4 +446,4 @@ export default function StandingsTable({
|
|||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { Notification, NotificationAction } from '@core/notifications/application';
|
import type { Notification, NotificationAction } from './notificationTypes';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -107,7 +107,7 @@ export default function ModalNotification({
|
|||||||
}, [notification, onDismiss]);
|
}, [notification, onDismiss]);
|
||||||
|
|
||||||
const handleAction = (action: NotificationAction) => {
|
const handleAction = (action: NotificationAction) => {
|
||||||
onAction(notification, action.actionId);
|
onAction(notification, action.id);
|
||||||
if (action.href) {
|
if (action.href) {
|
||||||
router.push(action.href);
|
router.push(action.href);
|
||||||
}
|
}
|
||||||
@@ -128,15 +128,45 @@ export default function ModalNotification({
|
|||||||
glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]',
|
glow: 'shadow-[0_0_60px_rgba(245,158,11,0.3)]',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = notification.data ?? {};
|
||||||
|
|
||||||
|
const getNumber = (value: unknown): number | null => {
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (Number.isFinite(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getString = (value: unknown): string | null => {
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isValidDate = (value: unknown): value is Date => value instanceof Date && !Number.isNaN(value.getTime());
|
||||||
|
|
||||||
// Check if there's a deadline
|
// Check if there's a deadline
|
||||||
const deadline = notification.data?.deadline;
|
const deadlineValue = data.deadline;
|
||||||
const hasDeadline = deadline instanceof Date;
|
const deadline: Date | null =
|
||||||
|
isValidDate(deadlineValue)
|
||||||
|
? deadlineValue
|
||||||
|
: typeof deadlineValue === 'string' || typeof deadlineValue === 'number'
|
||||||
|
? new Date(deadlineValue)
|
||||||
|
: null;
|
||||||
|
const hasDeadline = !!deadline && !Number.isNaN(deadline.getTime());
|
||||||
|
|
||||||
// Special celebratory styling for race notifications
|
// Special celebratory styling for race notifications
|
||||||
const isRaceNotification = notification.type.startsWith('race_');
|
const isRaceNotification = notification.type.startsWith('race_');
|
||||||
const isPerformanceSummary = notification.type === 'race_performance_summary';
|
const isPerformanceSummary = notification.type === 'race_performance_summary';
|
||||||
const isFinalResults = notification.type === 'race_final_results';
|
const isFinalResults = notification.type === 'race_final_results';
|
||||||
|
|
||||||
|
const provisionalRatingChange = getNumber(data.provisionalRatingChange) ?? 0;
|
||||||
|
const finalRatingChange = getNumber(data.finalRatingChange) ?? 0;
|
||||||
|
const ratingChange = provisionalRatingChange || finalRatingChange;
|
||||||
|
const protestId = getString(data.protestId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`
|
className={`
|
||||||
@@ -199,7 +229,7 @@ export default function ModalNotification({
|
|||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
|
<div className={`px-6 py-5 ${isRaceNotification ? 'bg-gradient-to-b from-transparent to-yellow-500/5' : ''}`}>
|
||||||
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
|
<p className={`leading-relaxed ${isRaceNotification ? 'text-white text-lg font-medium' : 'text-gray-300'}`}>
|
||||||
{notification.body}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Race performance stats */}
|
{/* Race performance stats */}
|
||||||
@@ -213,9 +243,9 @@ export default function ModalNotification({
|
|||||||
</div>
|
</div>
|
||||||
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
|
<div className="bg-black/20 rounded-lg p-3 border border-yellow-400/20">
|
||||||
<div className="text-xs text-yellow-300 font-medium mb-1">RATING CHANGE</div>
|
<div className="text-xs text-yellow-300 font-medium mb-1">RATING CHANGE</div>
|
||||||
<div className={`text-2xl font-bold ${(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
<div className={`text-2xl font-bold ${ratingChange >= 0 ? 'text-green-400' : 'text-red-400'}`}>
|
||||||
{(notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0) >= 0 ? '+' : ''}
|
{ratingChange >= 0 ? '+' : ''}
|
||||||
{notification.data?.provisionalRatingChange || notification.data?.finalRatingChange || 0}
|
{ratingChange}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -228,18 +258,18 @@ export default function ModalNotification({
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-warning-amber">Response Required</p>
|
<p className="text-sm font-medium text-warning-amber">Response Required</p>
|
||||||
<p className="text-xs text-gray-400">
|
<p className="text-xs text-gray-400">
|
||||||
Please respond by {deadline.toLocaleDateString()} at {deadline.toLocaleTimeString()}
|
Please respond by {deadline ? deadline.toLocaleDateString() : ''} at {deadline ? deadline.toLocaleTimeString() : ''}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Additional context from data */}
|
{/* Additional context from data */}
|
||||||
{notification.data?.protestId && (
|
{protestId && (
|
||||||
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
<div className="mt-4 p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
|
||||||
<p className="text-xs text-gray-500 mb-1">Related Protest</p>
|
<p className="text-xs text-gray-500 mb-1">Related Protest</p>
|
||||||
<p className="text-sm text-gray-300 font-mono">
|
<p className="text-sm text-gray-300 font-mono">
|
||||||
{notification.data.protestId}
|
{protestId}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -267,14 +297,14 @@ export default function ModalNotification({
|
|||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => onDismiss ? onDismiss(notification) : handleAction(notification, 'dismiss')}
|
onClick={() => (onDismiss ? onDismiss(notification) : onAction(notification, 'dismiss'))}
|
||||||
className="shadow-lg hover:shadow-yellow-400/30"
|
className="shadow-lg hover:shadow-yellow-400/30"
|
||||||
>
|
>
|
||||||
Dismiss
|
Dismiss
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
onClick={() => handleAction({ label: 'Share Achievement', type: 'secondary', actionId: 'share' })}
|
onClick={() => handleAction({ id: 'share', label: 'Share Achievement', type: 'secondary' })}
|
||||||
className="shadow-lg hover:shadow-yellow-400/30"
|
className="shadow-lg hover:shadow-yellow-400/30"
|
||||||
>
|
>
|
||||||
🎉 Share
|
🎉 Share
|
||||||
@@ -307,4 +337,4 @@ export default function ModalNotification({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const notificationColors: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
import { useNotifications } from './NotificationProvider';
|
import { useNotifications } from './NotificationProvider';
|
||||||
import type { Notification } from './NotificationProvider';
|
import type { Notification } from './notificationTypes';
|
||||||
|
|
||||||
export default function NotificationCenter() {
|
export default function NotificationCenter() {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
@@ -208,4 +208,4 @@ export default function NotificationCenter() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,28 +6,7 @@ import { v4 as uuid } from 'uuid';
|
|||||||
import ModalNotification from './ModalNotification';
|
import ModalNotification from './ModalNotification';
|
||||||
import ToastNotification from './ToastNotification';
|
import ToastNotification from './ToastNotification';
|
||||||
|
|
||||||
export type NotificationVariant = 'toast' | 'modal' | 'center';
|
import type { Notification, NotificationAction, NotificationVariant } from './notificationTypes';
|
||||||
|
|
||||||
export interface NotificationAction {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
type?: 'primary' | 'secondary' | 'danger';
|
|
||||||
href?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Notification {
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
title?: string;
|
|
||||||
message: string;
|
|
||||||
createdAt: Date;
|
|
||||||
variant: NotificationVariant;
|
|
||||||
actionUrl?: string;
|
|
||||||
requiresResponse?: boolean;
|
|
||||||
actions?: NotificationAction[];
|
|
||||||
data?: Record<string, unknown>;
|
|
||||||
read: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AddNotificationInput {
|
interface AddNotificationInput {
|
||||||
id?: string;
|
id?: string;
|
||||||
@@ -184,4 +163,4 @@ export default function NotificationProvider({ children }: NotificationProviderP
|
|||||||
)}
|
)}
|
||||||
</NotificationContext.Provider>
|
</NotificationContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { Notification } from '@core/notifications/application';
|
import type { Notification } from './notificationTypes';
|
||||||
import {
|
import {
|
||||||
Bell,
|
Bell,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
@@ -120,8 +120,8 @@ export default function ToastNotification({
|
|||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="flex items-start justify-between gap-2">
|
||||||
<p className="text-sm font-semibold text-white truncate">
|
<p className="text-sm font-semibold text-white truncate">
|
||||||
{notification.title}
|
{notification.title ?? 'Notification'}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
@@ -134,7 +134,7 @@ export default function ToastNotification({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-400 line-clamp-2 mt-1">
|
<p className="text-xs text-gray-400 line-clamp-2 mt-1">
|
||||||
{notification.body}
|
{notification.message}
|
||||||
</p>
|
</p>
|
||||||
{notification.actionUrl && (
|
{notification.actionUrl && (
|
||||||
<button
|
<button
|
||||||
@@ -151,4 +151,4 @@ export default function ToastNotification({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
22
apps/website/components/notifications/notificationTypes.ts
Normal file
22
apps/website/components/notifications/notificationTypes.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
export type NotificationVariant = 'toast' | 'modal' | 'center';
|
||||||
|
|
||||||
|
export interface NotificationAction {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
type?: 'primary' | 'secondary' | 'danger';
|
||||||
|
href?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Notification {
|
||||||
|
id: string;
|
||||||
|
type: string;
|
||||||
|
title?: string;
|
||||||
|
message: string;
|
||||||
|
createdAt: Date;
|
||||||
|
variant: NotificationVariant;
|
||||||
|
actionUrl?: string;
|
||||||
|
requiresResponse?: boolean;
|
||||||
|
actions?: NotificationAction[];
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
read: boolean;
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import Image from 'next/image';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||||
import DriverRating from '@/components/profile/DriverRatingPill';
|
import DriverRating from '@/components/profile/DriverRatingPill';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
export interface DriverSummaryPillProps {
|
export interface DriverSummaryPillProps {
|
||||||
driver: DriverDTO;
|
driver: DriverDTO;
|
||||||
@@ -17,8 +18,10 @@ export interface DriverSummaryPillProps {
|
|||||||
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
|
export default function DriverSummaryPill(props: DriverSummaryPillProps) {
|
||||||
const { driver, rating, rank, avatarSrc, onClick, href } = props;
|
const { driver, rating, rank, avatarSrc, onClick, href } = props;
|
||||||
|
|
||||||
|
const { mediaService } = useServices();
|
||||||
|
|
||||||
const resolvedAvatar =
|
const resolvedAvatar =
|
||||||
avatarSrc ?? getImageService().getDriverAvatar(driver.id);
|
avatarSrc ?? mediaService.getDriverAvatar(driver.id);
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
<>
|
<>
|
||||||
@@ -70,4 +73,4 @@ export default function DriverSummaryPill(props: DriverSummaryPillProps) {
|
|||||||
{content}
|
{content}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDT
|
|||||||
import Button from '../ui/Button';
|
import Button from '../ui/Button';
|
||||||
import DriverRatingPill from '@/components/profile/DriverRatingPill';
|
import DriverRatingPill from '@/components/profile/DriverRatingPill';
|
||||||
import CountryFlag from '@/components/ui/CountryFlag';
|
import CountryFlag from '@/components/ui/CountryFlag';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
interface ProfileHeaderProps {
|
interface ProfileHeaderProps {
|
||||||
driver: GetDriverOutputDTO;
|
driver: GetDriverOutputDTO;
|
||||||
@@ -25,12 +26,14 @@ export default function ProfileHeader({
|
|||||||
teamName,
|
teamName,
|
||||||
teamTag,
|
teamTag,
|
||||||
}: ProfileHeaderProps) {
|
}: ProfileHeaderProps) {
|
||||||
|
const { mediaService } = useServices();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between">
|
<div className="flex items-start justify-between">
|
||||||
<div className="flex items-start gap-4">
|
<div className="flex items-start gap-4">
|
||||||
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
|
<div className="w-20 h-20 rounded-full bg-gradient-to-br from-primary-blue to-purple-600 overflow-hidden flex items-center justify-center">
|
||||||
<Image
|
<Image
|
||||||
src={getImageService().getDriverAvatar(driver.id)}
|
src={mediaService.getDriverAvatar(driver.id)}
|
||||||
alt={driver.name}
|
alt={driver.name}
|
||||||
width={80}
|
width={80}
|
||||||
height={80}
|
height={80}
|
||||||
@@ -76,4 +79,4 @@ export default function ProfileHeader({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default function UserPill() {
|
|||||||
|
|
||||||
const dto = await driverService.findById(primaryDriverId);
|
const dto = await driverService.findById(primaryDriverId);
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setDriver(dto);
|
setDriver(dto ? (dto as unknown as DriverDTO) : null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,36 +120,10 @@ export default function UserPill() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const driverStats = getDriverStats(primaryDriverId);
|
// Driver rating + rank are not exposed by the current API contract for the lightweight
|
||||||
const allRankings = getAllDriverRankings();
|
// driver DTO used in the header. Keep it null until the API provides it.
|
||||||
|
const rating: number | null = null;
|
||||||
let rating: number | null = driverStats?.rating ?? null;
|
const rank: number | null = null;
|
||||||
let rank: number | null = null;
|
|
||||||
let totalDrivers: number | null = null;
|
|
||||||
|
|
||||||
if (driverStats) {
|
|
||||||
totalDrivers = allRankings.length || null;
|
|
||||||
|
|
||||||
if (typeof driverStats.overallRank === 'number' && driverStats.overallRank > 0) {
|
|
||||||
rank = driverStats.overallRank;
|
|
||||||
} else {
|
|
||||||
const indexInGlobal = allRankings.findIndex(
|
|
||||||
(stat) => stat.driverId === driverStats.driverId,
|
|
||||||
);
|
|
||||||
if (indexInGlobal !== -1) {
|
|
||||||
rank = indexInGlobal + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rating === null) {
|
|
||||||
const globalEntry = allRankings.find(
|
|
||||||
(stat) => stat.driverId === driverStats.driverId,
|
|
||||||
);
|
|
||||||
if (globalEntry) {
|
|
||||||
rating = globalEntry.rating;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
|
const avatarSrc = mediaService.getDriverAvatar(primaryDriverId);
|
||||||
|
|
||||||
@@ -369,4 +343,4 @@ export default function UserPill() {
|
|||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@
|
|||||||
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 type { ProtestIncident } from '@core/racing/domain/entities/Protest';
|
import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO';
|
||||||
|
import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Video,
|
Video,
|
||||||
@@ -37,6 +39,7 @@ export default function FileProtestModal({
|
|||||||
protestingDriverId,
|
protestingDriverId,
|
||||||
participants,
|
participants,
|
||||||
}: FileProtestModalProps) {
|
}: FileProtestModalProps) {
|
||||||
|
const { raceService } = useServices();
|
||||||
const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form');
|
const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form');
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -69,14 +72,10 @@ export default function FileProtestModal({
|
|||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const useCase = getFileProtestUseCase();
|
const incident: ProtestIncidentDTO = {
|
||||||
|
|
||||||
const incident: ProtestIncident = {
|
|
||||||
lap: parseInt(lap, 10),
|
lap: parseInt(lap, 10),
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
...(timeInRace
|
...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}),
|
||||||
? { timeInRace: parseInt(timeInRace, 10) }
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const command = {
|
const command = {
|
||||||
@@ -84,15 +83,11 @@ export default function FileProtestModal({
|
|||||||
protestingDriverId,
|
protestingDriverId,
|
||||||
accusedDriverId,
|
accusedDriverId,
|
||||||
incident,
|
incident,
|
||||||
...(comment.trim()
|
...(comment.trim() ? { comment: comment.trim() } : {}),
|
||||||
? { comment: comment.trim() }
|
...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}),
|
||||||
: {}),
|
} satisfies FileProtestCommandDTO;
|
||||||
...(proofVideoUrl.trim()
|
|
||||||
? { proofVideoUrl: proofVideoUrl.trim() }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
await useCase.execute(command);
|
await raceService.fileProtest(command);
|
||||||
|
|
||||||
setStep('success');
|
setStep('success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -290,4 +285,4 @@ export default function FileProtestModal({
|
|||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import type { RaceWithResultsDTO } from '@core/testing-support';
|
|
||||||
|
type RaceWithResults = {
|
||||||
|
raceId: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
winnerName: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
interface LatestResultsSidebarProps {
|
interface LatestResultsSidebarProps {
|
||||||
results: RaceWithResultsDTO[];
|
results: RaceWithResults[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
export default function LatestResultsSidebar({ results }: LatestResultsSidebarProps) {
|
||||||
@@ -14,7 +21,10 @@ export default function LatestResultsSidebar({ results }: LatestResultsSidebarPr
|
|||||||
<Card className="bg-iron-gray/80">
|
<Card className="bg-iron-gray/80">
|
||||||
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
|
<h3 className="text-sm font-semibold text-white mb-3">Latest results</h3>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{results.slice(0, 4).map(result => (
|
{results.slice(0, 4).map((result) => {
|
||||||
|
const scheduledAt = typeof result.scheduledAt === 'string' ? new Date(result.scheduledAt) : result.scheduledAt;
|
||||||
|
|
||||||
|
return (
|
||||||
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
|
<li key={result.raceId} className="flex items-start justify-between gap-3 text-xs">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white truncate">{result.track}</p>
|
<p className="text-white truncate">{result.track}</p>
|
||||||
@@ -23,14 +33,15 @@ export default function LatestResultsSidebar({ results }: LatestResultsSidebarPr
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||||
{result.scheduledAt.toLocaleDateString(undefined, {
|
{scheduledAt.toLocaleDateString(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
import type { Race } from '@core/racing/domain/entities/Race';
|
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
|
|
||||||
|
type UpcomingRace = {
|
||||||
|
id: string;
|
||||||
|
track: string;
|
||||||
|
car: string;
|
||||||
|
scheduledAt: string | Date;
|
||||||
|
};
|
||||||
|
|
||||||
interface UpcomingRacesSidebarProps {
|
interface UpcomingRacesSidebarProps {
|
||||||
races: Race[];
|
races: UpcomingRace[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProps) {
|
||||||
@@ -25,21 +31,25 @@ export default function UpcomingRacesSidebar({ races }: UpcomingRacesSidebarProp
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{races.slice(0, 4).map(race => (
|
{races.slice(0, 4).map((race) => {
|
||||||
|
const scheduledAt = typeof race.scheduledAt === 'string' ? new Date(race.scheduledAt) : race.scheduledAt;
|
||||||
|
|
||||||
|
return (
|
||||||
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
|
<li key={race.id} className="flex items-start justify-between gap-3 text-xs">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-white truncate">{race.track}</p>
|
<p className="text-white truncate">{race.track}</p>
|
||||||
<p className="text-gray-400 truncate">{race.car}</p>
|
<p className="text-gray-400 truncate">{race.car}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right text-gray-500 whitespace-nowrap">
|
<div className="text-right text-gray-500 whitespace-nowrap">
|
||||||
{race.scheduledAt.toLocaleDateString(undefined, {
|
{scheduledAt.toLocaleDateString(undefined, {
|
||||||
month: 'short',
|
month: 'short',
|
||||||
day: 'numeric'
|
day: 'numeric'
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ export interface PendingRequestDTO {
|
|||||||
id: string;
|
id: string;
|
||||||
sponsorId: string;
|
sponsorId: string;
|
||||||
sponsorName: string;
|
sponsorName: string;
|
||||||
sponsorLogo?: string;
|
sponsorLogo?: string | undefined;
|
||||||
tier: 'main' | 'secondary';
|
tier: 'main' | 'secondary';
|
||||||
offeredAmount: number;
|
offeredAmount: number;
|
||||||
currency: string;
|
currency: string;
|
||||||
formattedAmount: string;
|
formattedAmount: string;
|
||||||
message?: string;
|
message?: string | undefined;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
platformFee: number;
|
platformFee: number;
|
||||||
netAmount: number;
|
netAmount: number;
|
||||||
@@ -238,4 +238,4 @@ export default function PendingSponsorshipRequests({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ interface CreateTeamFormProps {
|
|||||||
|
|
||||||
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
|
export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
const { teamService } = useServices();
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
name: '',
|
name: '',
|
||||||
tag: '',
|
tag: '',
|
||||||
@@ -57,16 +59,13 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
|
|||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const useCase = getCreateTeamUseCase();
|
const result = await teamService.createTeam({
|
||||||
const result = await useCase.execute({
|
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
tag: formData.tag.toUpperCase(),
|
tag: formData.tag.toUpperCase(),
|
||||||
description: formData.description,
|
description: formData.description,
|
||||||
ownerId: currentDriverId,
|
|
||||||
leagues: [],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const teamId = result.team.id;
|
const teamId = result.id;
|
||||||
|
|
||||||
if (onSuccess) {
|
if (onSuccess) {
|
||||||
onSuccess(teamId);
|
onSuccess(teamId);
|
||||||
@@ -169,4 +168,4 @@ export default function CreateTeamForm({ onCancel, onSuccess }: CreateTeamFormPr
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewMode
|
|||||||
import TeamCard from './TeamCard';
|
import TeamCard from './TeamCard';
|
||||||
|
|
||||||
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
|
type TeamSpecialization = 'endurance' | 'sprint' | 'mixed';
|
||||||
|
|
||||||
interface SkillLevelConfig {
|
interface SkillLevelConfig {
|
||||||
id: SkillLevel;
|
id: SkillLevel;
|
||||||
@@ -35,6 +36,13 @@ export default function SkillLevelSection({
|
|||||||
|
|
||||||
if (teams.length === 0) return null;
|
if (teams.length === 0) return null;
|
||||||
|
|
||||||
|
const specialization = (teamSpecialization: string | undefined): TeamSpecialization | undefined => {
|
||||||
|
if (teamSpecialization === 'endurance' || teamSpecialization === 'sprint' || teamSpecialization === 'mixed') {
|
||||||
|
return teamSpecialization;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
{/* Section Header */}
|
{/* Section Header */}
|
||||||
@@ -81,12 +89,12 @@ export default function SkillLevelSection({
|
|||||||
name={team.name}
|
name={team.name}
|
||||||
description={team.description ?? ''}
|
description={team.description ?? ''}
|
||||||
memberCount={team.memberCount}
|
memberCount={team.memberCount}
|
||||||
rating={team.rating}
|
rating={null}
|
||||||
totalWins={team.totalWins}
|
totalWins={team.totalWins}
|
||||||
totalRaces={team.totalRaces}
|
totalRaces={team.totalRaces}
|
||||||
performanceLevel={team.performanceLevel}
|
performanceLevel={team.performanceLevel as SkillLevel}
|
||||||
isRecruiting={team.isRecruiting}
|
isRecruiting={team.isRecruiting}
|
||||||
specialization={team.specialization}
|
specialization={specialization(team.specialization)}
|
||||||
region={team.region ?? ''}
|
region={team.region ?? ''}
|
||||||
languages={team.languages}
|
languages={team.languages}
|
||||||
onClick={() => onTeamClick(team.id)}
|
onClick={() => onTeamClick(team.id)}
|
||||||
@@ -95,4 +103,4 @@ export default function SkillLevelSection({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,21 +5,19 @@ import Card from '@/components/ui/Card';
|
|||||||
import Button from '@/components/ui/Button';
|
import Button from '@/components/ui/Button';
|
||||||
import Input from '@/components/ui/Input';
|
import Input from '@/components/ui/Input';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
|
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||||
|
import type { TeamJoinRequestViewModel } from '@/lib/view-models/TeamJoinRequestViewModel';
|
||||||
|
import type { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||||
|
import type { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
|
||||||
|
|
||||||
interface TeamAdminProps {
|
interface TeamAdminProps {
|
||||||
team: {
|
team: Pick<TeamDetailsViewModel, 'id' | 'name' | 'tag' | 'description' | 'ownerId'>;
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
tag: string;
|
|
||||||
description: string;
|
|
||||||
ownerId: string;
|
|
||||||
};
|
|
||||||
onUpdate: () => void;
|
onUpdate: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
||||||
const [joinRequests, setJoinRequests] = useState<TeamAdminJoinRequestViewModel[]>([]);
|
const { teamJoinService, teamService } = useServices();
|
||||||
|
const [joinRequests, setJoinRequests] = useState<TeamJoinRequestViewModel[]>([]);
|
||||||
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
const [requestDrivers, setRequestDrivers] = useState<Record<string, DriverDTO>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [editMode, setEditMode] = useState(false);
|
const [editMode, setEditMode] = useState(false);
|
||||||
@@ -33,22 +31,13 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
const load = async () => {
|
const load = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const viewModel = await loadTeamAdminViewModel({
|
// Current build only supports read-only join requests. Driver hydration is
|
||||||
id: team.id,
|
// not provided by the API response, so we only display driverId.
|
||||||
name: team.name,
|
const currentUserId = team.ownerId;
|
||||||
tag: team.tag,
|
const isOwner = true;
|
||||||
description: team.description,
|
const requests = await teamJoinService.getJoinRequests(team.id, currentUserId, isOwner);
|
||||||
ownerId: team.ownerId,
|
setJoinRequests(requests);
|
||||||
});
|
setRequestDrivers({});
|
||||||
setJoinRequests(viewModel.requests);
|
|
||||||
|
|
||||||
const driversById: Record<string, DriverDTO> = {};
|
|
||||||
for (const request of viewModel.requests) {
|
|
||||||
if (request.driver) {
|
|
||||||
driversById[request.driverId] = request.driver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRequestDrivers(driversById);
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -59,16 +48,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
|
|
||||||
const handleApprove = async (requestId: string) => {
|
const handleApprove = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
const updated = await approveTeamJoinRequestAndReload(requestId, team.id);
|
void requestId;
|
||||||
setJoinRequests(updated);
|
await teamJoinService.approveJoinRequest();
|
||||||
const driversById: Record<string, DriverDTO> = {};
|
|
||||||
for (const request of updated) {
|
|
||||||
if (request.driver) {
|
|
||||||
driversById[request.driverId] = request.driver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRequestDrivers(driversById);
|
|
||||||
onUpdate();
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
alert(error instanceof Error ? error.message : 'Failed to approve request');
|
||||||
}
|
}
|
||||||
@@ -76,15 +57,8 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
|
|
||||||
const handleReject = async (requestId: string) => {
|
const handleReject = async (requestId: string) => {
|
||||||
try {
|
try {
|
||||||
const updated = await rejectTeamJoinRequestAndReload(requestId, team.id);
|
void requestId;
|
||||||
setJoinRequests(updated);
|
await teamJoinService.rejectJoinRequest();
|
||||||
const driversById: Record<string, DriverDTO> = {};
|
|
||||||
for (const request of updated) {
|
|
||||||
if (request.driver) {
|
|
||||||
driversById[request.driverId] = request.driver;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
setRequestDrivers(driversById);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
alert(error instanceof Error ? error.message : 'Failed to reject request');
|
||||||
}
|
}
|
||||||
@@ -92,13 +66,16 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
|
|
||||||
const handleSaveChanges = async () => {
|
const handleSaveChanges = async () => {
|
||||||
try {
|
try {
|
||||||
await updateTeamDetails({
|
const result: UpdateTeamViewModel = await teamService.updateTeam(team.id, {
|
||||||
teamId: team.id,
|
|
||||||
name: editedTeam.name,
|
name: editedTeam.name,
|
||||||
tag: editedTeam.tag,
|
tag: editedTeam.tag,
|
||||||
description: editedTeam.description,
|
description: editedTeam.description,
|
||||||
updatedByDriverId: team.ownerId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.successMessage);
|
||||||
|
}
|
||||||
|
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
onUpdate();
|
onUpdate();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -201,40 +178,37 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
) : joinRequests.length > 0 ? (
|
) : joinRequests.length > 0 ? (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{joinRequests.map((request) => {
|
{joinRequests.map((request) => {
|
||||||
const driver = requestDrivers[request.driverId] ?? request.driver;
|
const driver = requestDrivers[request.driverId] ?? null;
|
||||||
if (!driver) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={request.id}
|
key={request.requestId}
|
||||||
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
className="flex items-center justify-between p-4 rounded-lg bg-deep-graphite border border-charcoal-outline"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4 flex-1">
|
<div className="flex items-center gap-4 flex-1">
|
||||||
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
|
<div className="w-12 h-12 rounded-full bg-primary-blue/20 flex items-center justify-center text-lg font-bold text-white">
|
||||||
{driver.name.charAt(0)}
|
{(driver?.name ?? request.driverId).charAt(0)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<h4 className="text-white font-medium">{driver.name}</h4>
|
<h4 className="text-white font-medium">{driver?.name ?? request.driverId}</h4>
|
||||||
<p className="text-sm text-gray-400">
|
<p className="text-sm text-gray-400">
|
||||||
{driver.country} • Requested {new Date(request.requestedAt).toLocaleDateString()}
|
{driver?.country ?? 'Unknown'} • Requested {new Date(request.requestedAt).toLocaleDateString()}
|
||||||
</p>
|
</p>
|
||||||
{request.message && (
|
{/* Request message is not part of current API contract */}
|
||||||
<p className="text-sm text-gray-300 mt-1 italic">
|
|
||||||
"{request.message}"
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => handleApprove(request.id)}
|
onClick={() => handleApprove(request.requestId)}
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
Approve
|
Approve
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="danger"
|
variant="danger"
|
||||||
onClick={() => handleReject(request.id)}
|
onClick={() => handleReject(request.requestId)}
|
||||||
|
disabled
|
||||||
>
|
>
|
||||||
Reject
|
Reject
|
||||||
</Button>
|
</Button>
|
||||||
@@ -266,4 +240,4 @@ export default function TeamAdmin({ team, onUpdate }: TeamAdminProps) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import {
|
|||||||
Languages,
|
Languages,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
interface TeamCardProps {
|
interface TeamCardProps {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -77,8 +79,8 @@ export default function TeamCard({
|
|||||||
languages,
|
languages,
|
||||||
onClick,
|
onClick,
|
||||||
}: TeamCardProps) {
|
}: TeamCardProps) {
|
||||||
const imageService = getImageService();
|
const { mediaService } = useServices();
|
||||||
const logoUrl = logo || imageService.getTeamLogo(id);
|
const logoUrl = logo || mediaService.getTeamLogo(id);
|
||||||
const performanceBadge = getPerformanceBadge(performanceLevel);
|
const performanceBadge = getPerformanceBadge(performanceLevel);
|
||||||
const specializationBadge = getSpecializationBadge(specialization);
|
const specializationBadge = getSpecializationBadge(specialization);
|
||||||
|
|
||||||
@@ -206,4 +208,4 @@ export default function TeamCard({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
@@ -25,8 +26,8 @@ export default function TeamLadderRow({
|
|||||||
totalRaces,
|
totalRaces,
|
||||||
}: TeamLadderRowProps) {
|
}: TeamLadderRowProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const imageService = getImageService();
|
const { mediaService } = useServices();
|
||||||
const logo = teamLogoUrl ?? imageService.getTeamLogo(teamId);
|
const logo = teamLogoUrl ?? mediaService.getTeamLogo(teamId);
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
router.push(`/teams/${teamId}`);
|
router.push(`/teams/${teamId}`);
|
||||||
@@ -74,4 +75,4 @@ export default function TeamLadderRow({
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ export default function TeamLeaderboardPreview({
|
|||||||
{/* Rating */}
|
{/* Rating */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<p className="text-purple-400 font-mono font-semibold">
|
<p className="text-purple-400 font-mono font-semibold">
|
||||||
{(team as any).rating?.toLocaleString() || '—'}
|
{'—'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">Rating</p>
|
<p className="text-xs text-gray-500">Rating</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -172,4 +172,4 @@ export default function TeamLeaderboardPreview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,13 +4,11 @@ import { useState, useEffect } from 'react';
|
|||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
import DriverIdentity from '@/components/drivers/DriverIdentity';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { TeamRole } from '@core/racing/domain/types/TeamMembership';
|
import type { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||||
|
|
||||||
interface TeamMembershipSummary {
|
type TeamRole = 'owner' | 'admin' | 'member';
|
||||||
driverId: string;
|
|
||||||
role: TeamRole;
|
type TeamMembershipSummary = Pick<TeamMemberViewModel, 'driverId' | 'role' | 'joinedAt'>;
|
||||||
joinedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TeamRosterProps {
|
interface TeamRosterProps {
|
||||||
teamId: string;
|
teamId: string;
|
||||||
@@ -64,7 +62,7 @@ export default function TeamRoster({
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'owner':
|
case 'owner':
|
||||||
return 'bg-warning-amber/20 text-warning-amber';
|
return 'bg-warning-amber/20 text-warning-amber';
|
||||||
case 'manager':
|
case 'admin':
|
||||||
return 'bg-primary-blue/20 text-primary-blue';
|
return 'bg-primary-blue/20 text-primary-blue';
|
||||||
default:
|
default:
|
||||||
return 'bg-charcoal-outline text-gray-300';
|
return 'bg-charcoal-outline text-gray-300';
|
||||||
@@ -79,9 +77,9 @@ export default function TeamRoster({
|
|||||||
switch (role) {
|
switch (role) {
|
||||||
case 'owner':
|
case 'owner':
|
||||||
return 0;
|
return 0;
|
||||||
case 'manager':
|
case 'admin':
|
||||||
return 1;
|
return 1;
|
||||||
case 'driver':
|
case 'member':
|
||||||
return 2;
|
return 2;
|
||||||
default:
|
default:
|
||||||
return 3;
|
return 3;
|
||||||
@@ -192,8 +190,8 @@ export default function TeamRoster({
|
|||||||
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
onChangeRole?.(driver.id, e.target.value as TeamRole)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="driver">Driver</option>
|
<option value="member">Member</option>
|
||||||
<option value="manager">Manager</option>
|
<option value="admin">Admin</option>
|
||||||
</select>
|
</select>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -214,4 +212,4 @@ export default function TeamRoster({
|
|||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
|||||||
<button
|
<button
|
||||||
key={team.id}
|
key={team.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onTeamClick(team.id)}
|
onClick={() => onClick(team.id)}
|
||||||
className="flex flex-col items-center group"
|
className="flex flex-col items-center group"
|
||||||
>
|
>
|
||||||
{/* Team card */}
|
{/* Team card */}
|
||||||
@@ -142,7 +142,7 @@ export default function TopThreePodium({ teams, onClick }: 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 as any).rating?.toLocaleString() || '—'}
|
{'—'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{/* Stats row */}
|
{/* Stats row */}
|
||||||
@@ -172,4 +172,4 @@ export default function TopThreePodium({ teams, onClick }: TopThreePodiumProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
|
|
||||||
|
|
||||||
export function useLeagueMemberships(leagueId: string) {
|
export function useLeagueMemberships(leagueId: string) {
|
||||||
const { leagueMembershipService } = useServices();
|
const { leagueMembershipService } = useServices();
|
||||||
@@ -24,4 +23,4 @@ export function useLeagueMembership(leagueId: string, driverId: string) {
|
|||||||
},
|
},
|
||||||
enabled: !!leagueId && !!driverId,
|
enabled: !!leagueId && !!driverId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ export function useRaceDetailMutation() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRaceStewardingData(raceId: string, driverId: string) {
|
export function useRaceStewardingData(raceId: string, driverId: string) {
|
||||||
const { raceService } = useServices();
|
const { raceStewardingService } = useServices();
|
||||||
|
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['raceStewardingData', raceId, driverId],
|
queryKey: ['raceStewardingData', raceId, driverId],
|
||||||
queryFn: () => raceService.getRaceStewardingData(raceId, driverId),
|
queryFn: () => raceStewardingService.getRaceStewardingData(raceId, driverId),
|
||||||
enabled: !!raceId && !!driverId,
|
enabled: !!raceId && !!driverId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,4 +152,4 @@ export function useReopenRace() {
|
|||||||
queryClient.invalidateQueries({ queryKey: ['allRacesPageData'] });
|
queryClient.invalidateQueries({ queryKey: ['allRacesPageData'] });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import { AuthSessionDTO } from '../../types/generated/AuthSessionDTO';
|
import { AuthSessionDTO } from '../../types/generated/AuthSessionDTO';
|
||||||
import { LoginParams } from '../../types/generated/LoginParams';
|
import { LoginParamsDTO } from '../../types/generated/LoginParamsDTO';
|
||||||
import { SignupParams } from '../../types/generated/SignupParams';
|
import { SignupParamsDTO } from '../../types/generated/SignupParamsDTO';
|
||||||
import { LoginWithIracingCallbackParams } from '../../types/generated/LoginWithIracingCallbackParams';
|
import { LoginWithIracingCallbackParamsDTO } from '../../types/generated/LoginWithIracingCallbackParamsDTO';
|
||||||
import { IracingAuthRedirectResult } from '../../types/generated/IracingAuthRedirectResult';
|
import { IracingAuthRedirectResultDTO } from '../../types/generated/IracingAuthRedirectResultDTO';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth API Client
|
* Auth API Client
|
||||||
@@ -12,12 +12,12 @@ import { IracingAuthRedirectResult } from '../../types/generated/IracingAuthRedi
|
|||||||
*/
|
*/
|
||||||
export class AuthApiClient extends BaseApiClient {
|
export class AuthApiClient extends BaseApiClient {
|
||||||
/** Sign up with email */
|
/** Sign up with email */
|
||||||
signup(params: SignupParams): Promise<AuthSessionDTO> {
|
signup(params: SignupParamsDTO): Promise<AuthSessionDTO> {
|
||||||
return this.post<AuthSessionDTO>('/auth/signup', params);
|
return this.post<AuthSessionDTO>('/auth/signup', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Login with email */
|
/** Login with email */
|
||||||
login(params: LoginParams): Promise<AuthSessionDTO> {
|
login(params: LoginParamsDTO): Promise<AuthSessionDTO> {
|
||||||
return this.post<AuthSessionDTO>('/auth/login', params);
|
return this.post<AuthSessionDTO>('/auth/login', params);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -32,13 +32,22 @@ export class AuthApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Start iRacing auth redirect */
|
/** Start iRacing auth redirect */
|
||||||
startIracingAuthRedirect(returnTo?: string): Promise<IracingAuthRedirectResult> {
|
startIracingAuthRedirect(returnTo?: string): Promise<IracingAuthRedirectResultDTO> {
|
||||||
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
|
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
|
||||||
return this.get<IracingAuthRedirectResult>(`/auth/iracing/start${query}`);
|
return this.get<IracingAuthRedirectResultDTO>(`/auth/iracing/start${query}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience: build iRacing auth start URL.
|
||||||
|
* Used by AuthService for view-layer navigation.
|
||||||
|
*/
|
||||||
|
getIracingAuthUrl(returnTo?: string): string {
|
||||||
|
const query = returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : '';
|
||||||
|
return `${this.baseUrl}/auth/iracing/start${query}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Login with iRacing callback */
|
/** Login with iRacing callback */
|
||||||
loginWithIracingCallback(params: LoginWithIracingCallbackParams): Promise<AuthSessionDTO> {
|
loginWithIracingCallback(params: LoginWithIracingCallbackParamsDTO): Promise<AuthSessionDTO> {
|
||||||
const query = new URLSearchParams();
|
const query = new URLSearchParams();
|
||||||
query.append('code', params.code);
|
query.append('code', params.code);
|
||||||
query.append('state', params.state);
|
query.append('state', params.state);
|
||||||
@@ -47,4 +56,4 @@ export class AuthApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
return this.get<AuthSessionDTO>(`/auth/iracing/callback?${query.toString()}`);
|
return this.get<AuthSessionDTO>(`/auth/iracing/callback?${query.toString()}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { Logger } from '../../interfaces/Logger';
|
|||||||
import { ErrorReporter } from '../../interfaces/ErrorReporter';
|
import { ErrorReporter } from '../../interfaces/ErrorReporter';
|
||||||
|
|
||||||
export class BaseApiClient {
|
export class BaseApiClient {
|
||||||
private baseUrl: string;
|
protected baseUrl: string;
|
||||||
private errorReporter: ErrorReporter;
|
private errorReporter: ErrorReporter;
|
||||||
private logger: Logger;
|
private logger: Logger;
|
||||||
|
|
||||||
@@ -19,12 +19,16 @@ export class BaseApiClient {
|
|||||||
this.logger = logger;
|
this.logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected async request<T>(method: string, path: string, data?: object): Promise<T> {
|
protected async request<T>(method: string, path: string, data?: object | FormData): Promise<T> {
|
||||||
this.logger.info(`${method} ${path}`);
|
this.logger.info(`${method} ${path}`);
|
||||||
|
|
||||||
const headers: HeadersInit = {
|
const isFormData = typeof FormData !== 'undefined' && data instanceof FormData;
|
||||||
'Content-Type': 'application/json',
|
|
||||||
};
|
const headers: HeadersInit = isFormData
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
|
||||||
const config: RequestInit = {
|
const config: RequestInit = {
|
||||||
method,
|
method,
|
||||||
@@ -33,7 +37,7 @@ export class BaseApiClient {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (data) {
|
if (data) {
|
||||||
config.body = JSON.stringify(data);
|
config.body = isFormData ? data : JSON.stringify(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await fetch(`${this.baseUrl}${path}`, config);
|
const response = await fetch(`${this.baseUrl}${path}`, config);
|
||||||
@@ -45,7 +49,10 @@ export class BaseApiClient {
|
|||||||
} catch {
|
} catch {
|
||||||
// Keep default error message
|
// Keep default error message
|
||||||
}
|
}
|
||||||
const error = new Error(errorData.message || `API request failed with status ${response.status}`);
|
const error = new Error(
|
||||||
|
errorData.message || `API request failed with status ${response.status}`,
|
||||||
|
) as Error & { status?: number };
|
||||||
|
error.status = response.status;
|
||||||
this.errorReporter.report(error);
|
this.errorReporter.report(error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -76,4 +83,4 @@ export class BaseApiClient {
|
|||||||
protected patch<T>(path: string, data: object): Promise<T> {
|
protected patch<T>(path: string, data: object): Promise<T> {
|
||||||
return this.request<T>('PATCH', path, data);
|
return this.request<T>('PATCH', path, data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,5 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import {
|
import type { DashboardOverviewDTO } from '../../types/generated/DashboardOverviewDTO';
|
||||||
DashboardDriverSummaryDTO,
|
|
||||||
DashboardRaceSummaryDTO,
|
|
||||||
DashboardLeagueStandingSummaryDTO,
|
|
||||||
DashboardFeedItemSummaryDTO,
|
|
||||||
DashboardFriendSummaryDTO,
|
|
||||||
DashboardRecentResultDTO,
|
|
||||||
} from '../../types/generated';
|
|
||||||
|
|
||||||
// Define DashboardOverviewDTO using generated types
|
|
||||||
export type DashboardOverviewDto = {
|
|
||||||
currentDriver: DashboardDriverSummaryDTO | null;
|
|
||||||
myUpcomingRaces: DashboardRaceSummaryDTO[];
|
|
||||||
otherUpcomingRaces: DashboardRaceSummaryDTO[];
|
|
||||||
upcomingRaces: DashboardRaceSummaryDTO[];
|
|
||||||
activeLeaguesCount: number;
|
|
||||||
nextRace: DashboardRaceSummaryDTO | null;
|
|
||||||
recentResults: DashboardRecentResultDTO[];
|
|
||||||
leagueStandingsSummaries: DashboardLeagueStandingSummaryDTO[];
|
|
||||||
feedSummary: {
|
|
||||||
feedItems: DashboardFeedItemSummaryDTO[];
|
|
||||||
};
|
|
||||||
friends: DashboardFriendSummaryDTO[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Dashboard API Client
|
* Dashboard API Client
|
||||||
@@ -31,7 +8,7 @@ export type DashboardOverviewDto = {
|
|||||||
*/
|
*/
|
||||||
export class DashboardApiClient extends BaseApiClient {
|
export class DashboardApiClient extends BaseApiClient {
|
||||||
/** Get dashboard overview data */
|
/** Get dashboard overview data */
|
||||||
getDashboardOverview(): Promise<DashboardOverviewDto> {
|
getDashboardOverview(): Promise<DashboardOverviewDTO> {
|
||||||
return this.get<DashboardOverviewDto>('/dashboard/overview');
|
return this.get<DashboardOverviewDTO>('/dashboard/overview');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
// Import generated types
|
import type { CompleteOnboardingInputDTO } from '../../types/generated/CompleteOnboardingInputDTO';
|
||||||
import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO, DriverProfileDTO, GetDriverOutputDTO } from '../../types/generated';
|
import type { CompleteOnboardingOutputDTO } from '../../types/generated/CompleteOnboardingOutputDTO';
|
||||||
|
import type { DriverRegistrationStatusDTO } from '../../types/generated/DriverRegistrationStatusDTO';
|
||||||
|
import type { DriverLeaderboardItemDTO } from '../../types/generated/DriverLeaderboardItemDTO';
|
||||||
|
import type { GetDriverOutputDTO } from '../../types/generated/GetDriverOutputDTO';
|
||||||
|
import type { GetDriverProfileOutputDTO } from '../../types/generated/GetDriverProfileOutputDTO';
|
||||||
|
|
||||||
type DriversLeaderboardDto = {
|
type DriversLeaderboardDto = {
|
||||||
drivers: DriverLeaderboardItemDTO[];
|
drivers: DriverLeaderboardItemDTO[];
|
||||||
@@ -38,12 +42,12 @@ export class DriversApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get driver profile with full details */
|
/** Get driver profile with full details */
|
||||||
getDriverProfile(driverId: string): Promise<DriverProfileDTO> {
|
getDriverProfile(driverId: string): Promise<GetDriverProfileOutputDTO> {
|
||||||
return this.get<DriverProfileDTO>(`/drivers/${driverId}/profile`);
|
return this.get<GetDriverProfileOutputDTO>(`/drivers/${driverId}/profile`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Update current driver profile */
|
/** Update current driver profile */
|
||||||
updateProfile(updates: { bio?: string; country?: string }): Promise<GetDriverOutputDTO> {
|
updateProfile(updates: { bio?: string; country?: string }): Promise<GetDriverOutputDTO> {
|
||||||
return this.put<GetDriverOutputDTO>('/drivers/profile', updates);
|
return this.put<GetDriverOutputDTO>('/drivers/profile', updates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import type {
|
import type { AllLeaguesWithCapacityDTO } from '../../types/generated/AllLeaguesWithCapacityDTO';
|
||||||
AllLeaguesWithCapacityDto,
|
import type { TotalLeaguesDTO } from '../../types/generated/TotalLeaguesDTO';
|
||||||
LeagueStatsDto,
|
import type { LeagueStandingsDTO } from '../../types/generated/LeagueStandingsDTO';
|
||||||
LeagueStandingsDto,
|
import type { LeagueScheduleDTO } from '../../types/generated/LeagueScheduleDTO';
|
||||||
LeagueScheduleDto,
|
import type { LeagueMembershipsDTO } from '../../types/generated/LeagueMembershipsDTO';
|
||||||
LeagueMembershipsDto,
|
import type { CreateLeagueInputDTO } from '../../types/generated/CreateLeagueInputDTO';
|
||||||
CreateLeagueInputDto,
|
import type { CreateLeagueOutputDTO } from '../../types/generated/CreateLeagueOutputDTO';
|
||||||
CreateLeagueOutputDto,
|
import type { SponsorshipDetailDTO } from '../../types/generated/SponsorshipDetailDTO';
|
||||||
SponsorshipDetailDTO,
|
import type { RaceDTO } from '../../types/generated/RaceDTO';
|
||||||
RaceDTO,
|
import type { GetLeagueAdminConfigOutputDTO } from '../../types/generated/GetLeagueAdminConfigOutputDTO';
|
||||||
} from '../../dtos';
|
import type { LeagueScoringPresetDTO } from '../../types/generated/LeagueScoringPresetDTO';
|
||||||
|
import type { LeagueSeasonSummaryDTO } from '../../types/generated/LeagueSeasonSummaryDTO';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Leagues API Client
|
* Leagues API Client
|
||||||
@@ -18,33 +19,33 @@ import type {
|
|||||||
*/
|
*/
|
||||||
export class LeaguesApiClient extends BaseApiClient {
|
export class LeaguesApiClient extends BaseApiClient {
|
||||||
/** Get all leagues with capacity information */
|
/** Get all leagues with capacity information */
|
||||||
getAllWithCapacity(): Promise<AllLeaguesWithCapacityDto> {
|
getAllWithCapacity(): Promise<AllLeaguesWithCapacityDTO> {
|
||||||
return this.get<AllLeaguesWithCapacityDto>('/leagues/all-with-capacity');
|
return this.get<AllLeaguesWithCapacityDTO>('/leagues/all-with-capacity');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get total number of leagues */
|
/** Get total number of leagues */
|
||||||
getTotal(): Promise<LeagueStatsDto> {
|
getTotal(): Promise<TotalLeaguesDTO> {
|
||||||
return this.get<LeagueStatsDto>('/leagues/total-leagues');
|
return this.get<TotalLeaguesDTO>('/leagues/total-leagues');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get league standings */
|
/** Get league standings */
|
||||||
getStandings(leagueId: string): Promise<LeagueStandingsDto> {
|
getStandings(leagueId: string): Promise<LeagueStandingsDTO> {
|
||||||
return this.get<LeagueStandingsDto>(`/leagues/${leagueId}/standings`);
|
return this.get<LeagueStandingsDTO>(`/leagues/${leagueId}/standings`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get league schedule */
|
/** Get league schedule */
|
||||||
getSchedule(leagueId: string): Promise<LeagueScheduleDto> {
|
getSchedule(leagueId: string): Promise<LeagueScheduleDTO> {
|
||||||
return this.get<LeagueScheduleDto>(`/leagues/${leagueId}/schedule`);
|
return this.get<LeagueScheduleDTO>(`/leagues/${leagueId}/schedule`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get league memberships */
|
/** Get league memberships */
|
||||||
getMemberships(leagueId: string): Promise<LeagueMembershipsDto> {
|
getMemberships(leagueId: string): Promise<LeagueMembershipsDTO> {
|
||||||
return this.get<LeagueMembershipsDto>(`/leagues/${leagueId}/memberships`);
|
return this.get<LeagueMembershipsDTO>(`/leagues/${leagueId}/memberships`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new league */
|
/** Create a new league */
|
||||||
create(input: CreateLeagueInputDto): Promise<CreateLeagueOutputDto> {
|
create(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
|
||||||
return this.post<CreateLeagueOutputDto>('/leagues', input);
|
return this.post<CreateLeagueOutputDTO>('/leagues', input);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Remove a member from league */
|
/** Remove a member from league */
|
||||||
@@ -58,8 +59,8 @@ export class LeaguesApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get league seasons */
|
/** Get league seasons */
|
||||||
getSeasons(leagueId: string): Promise<{ seasons: Array<{ id: string; status: string }> }> {
|
getSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||||
return this.get<{ seasons: Array<{ id: string; status: string }> }>(`/leagues/${leagueId}/seasons`);
|
return this.get<LeagueSeasonSummaryDTO[]>(`/leagues/${leagueId}/seasons`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get season sponsorships */
|
/** Get season sponsorships */
|
||||||
@@ -68,13 +69,13 @@ export class LeaguesApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get league config */
|
/** Get league config */
|
||||||
getLeagueConfig(leagueId: string): Promise<{ config: any }> {
|
getLeagueConfig(leagueId: string): Promise<GetLeagueAdminConfigOutputDTO> {
|
||||||
return this.get<{ config: any }>(`/leagues/${leagueId}/config`);
|
return this.get<GetLeagueAdminConfigOutputDTO>(`/leagues/${leagueId}/config`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get league scoring presets */
|
/** Get league scoring presets */
|
||||||
getScoringPresets(): Promise<{ presets: any[] }> {
|
getScoringPresets(): Promise<{ presets: LeagueScoringPresetDTO[] }> {
|
||||||
return this.get<{ presets: any[] }>(`/leagues/scoring-presets`);
|
return this.get<{ presets: LeagueScoringPresetDTO[] }>(`/leagues/scoring-presets`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Transfer league ownership */
|
/** Transfer league ownership */
|
||||||
@@ -89,4 +90,4 @@ export class LeaguesApiClient extends BaseApiClient {
|
|||||||
getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> {
|
getRaces(leagueId: string): Promise<{ races: RaceDTO[] }> {
|
||||||
return this.get<{ races: RaceDTO[] }>(`/leagues/${leagueId}/races`);
|
return this.get<{ races: RaceDTO[] }>(`/leagues/${leagueId}/races`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import type {
|
import type { DeleteMediaOutputDTO } from '../../types/generated/DeleteMediaOutputDTO';
|
||||||
DeleteMediaOutputDTO,
|
import type { GetAvatarOutputDTO } from '../../types/generated/GetAvatarOutputDTO';
|
||||||
GetMediaOutputDTO,
|
import type { GetMediaOutputDTO } from '../../types/generated/GetMediaOutputDTO';
|
||||||
RequestAvatarGenerationInputDTO,
|
import type { RequestAvatarGenerationInputDTO } from '../../types/generated/RequestAvatarGenerationInputDTO';
|
||||||
RequestAvatarGenerationOutputDTO,
|
import type { RequestAvatarGenerationOutputDTO } from '../../types/generated/RequestAvatarGenerationOutputDTO';
|
||||||
UpdateAvatarInputDTO,
|
import type { UpdateAvatarInputDTO } from '../../types/generated/UpdateAvatarInputDTO';
|
||||||
UpdateAvatarOutputDTO,
|
import type { UpdateAvatarOutputDTO } from '../../types/generated/UpdateAvatarOutputDTO';
|
||||||
UploadMediaOutputDTO,
|
import type { UploadMediaOutputDTO } from '../../types/generated/UploadMediaOutputDTO';
|
||||||
} from '../generated';
|
|
||||||
import type { GetAvatarOutputDTO } from '../generated';
|
|
||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -51,4 +49,4 @@ export class MediaApiClient extends BaseApiClient {
|
|||||||
updateAvatar(input: UpdateAvatarInputDTO): Promise<UpdateAvatarOutputDTO> {
|
updateAvatar(input: UpdateAvatarInputDTO): Promise<UpdateAvatarOutputDTO> {
|
||||||
return this.put<UpdateAvatarOutputDTO>(`/media/avatar/${input.driverId}`, { avatarUrl: input.avatarUrl });
|
return this.put<UpdateAvatarOutputDTO>(`/media/avatar/${input.driverId}`, { avatarUrl: input.avatarUrl });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import type { PaymentDto, MembershipFeeDto, MemberPaymentDto, PrizeDto, WalletDto, TransactionDto, UpdatePaymentStatusInputDTO } from '../types/generated';
|
import type { MembershipFeeDTO } from '../../types/generated/MembershipFeeDTO';
|
||||||
|
import type { MemberPaymentDTO } from '../../types/generated/MemberPaymentDTO';
|
||||||
|
import type { PaymentDTO } from '../../types/generated/PaymentDTO';
|
||||||
|
import type { PrizeDTO } from '../../types/generated/PrizeDTO';
|
||||||
|
import type { TransactionDTO } from '../../types/generated/TransactionDTO';
|
||||||
|
import type { UpdatePaymentStatusInputDTO } from '../../types/generated/UpdatePaymentStatusInputDTO';
|
||||||
|
import type { WalletDTO } from '../../types/generated/WalletDTO';
|
||||||
|
|
||||||
// Define missing types that are not fully generated
|
// Define missing types that are not fully generated
|
||||||
type GetPaymentsOutputDto = { payments: PaymentDto[] };
|
type GetPaymentsOutputDto = { payments: PaymentDTO[] };
|
||||||
type CreatePaymentInputDto = {
|
type CreatePaymentInputDto = {
|
||||||
type: 'sponsorship' | 'membership_fee';
|
type: 'sponsorship' | 'membership_fee';
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -11,15 +17,15 @@ type CreatePaymentInputDto = {
|
|||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId?: string;
|
seasonId?: string;
|
||||||
};
|
};
|
||||||
type CreatePaymentOutputDto = { payment: PaymentDto };
|
type CreatePaymentOutputDto = { payment: PaymentDTO };
|
||||||
type GetMembershipFeesOutputDto = {
|
type GetMembershipFeesOutputDto = {
|
||||||
fee: MembershipFeeDto | null;
|
fee: MembershipFeeDTO | null;
|
||||||
payments: MemberPaymentDto[]
|
payments: MemberPaymentDTO[]
|
||||||
};
|
};
|
||||||
type GetPrizesOutputDto = { prizes: PrizeDto[] };
|
type GetPrizesOutputDto = { prizes: PrizeDTO[] };
|
||||||
type GetWalletOutputDto = {
|
type GetWalletOutputDto = {
|
||||||
wallet: WalletDto;
|
wallet: WalletDTO;
|
||||||
transactions: TransactionDto[]
|
transactions: TransactionDTO[]
|
||||||
};
|
};
|
||||||
type ProcessWalletTransactionInputDto = {
|
type ProcessWalletTransactionInputDto = {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
@@ -30,8 +36,8 @@ type ProcessWalletTransactionInputDto = {
|
|||||||
referenceType?: 'sponsorship' | 'membership_fee' | 'prize';
|
referenceType?: 'sponsorship' | 'membership_fee' | 'prize';
|
||||||
};
|
};
|
||||||
type ProcessWalletTransactionOutputDto = {
|
type ProcessWalletTransactionOutputDto = {
|
||||||
wallet: WalletDto;
|
wallet: WalletDTO;
|
||||||
transaction: TransactionDto
|
transaction: TransactionDTO
|
||||||
};
|
};
|
||||||
type UpdateMemberPaymentInputDto = {
|
type UpdateMemberPaymentInputDto = {
|
||||||
feeId: string;
|
feeId: string;
|
||||||
@@ -39,16 +45,16 @@ type UpdateMemberPaymentInputDto = {
|
|||||||
status?: 'pending' | 'paid' | 'overdue';
|
status?: 'pending' | 'paid' | 'overdue';
|
||||||
paidAt?: Date | string;
|
paidAt?: Date | string;
|
||||||
};
|
};
|
||||||
type UpdateMemberPaymentOutputDto = { payment: MemberPaymentDto };
|
type UpdateMemberPaymentOutputDto = { payment: MemberPaymentDTO };
|
||||||
type GetWalletTransactionsOutputDto = { transactions: TransactionDto[] };
|
type GetWalletTransactionsOutputDto = { transactions: TransactionDTO[] };
|
||||||
type UpdatePaymentStatusOutputDto = { payment: PaymentDto };
|
type UpdatePaymentStatusOutputDto = { payment: PaymentDTO };
|
||||||
type UpsertMembershipFeeInputDto = {
|
type UpsertMembershipFeeInputDto = {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId?: string;
|
seasonId?: string;
|
||||||
type: 'season' | 'monthly' | 'per_race';
|
type: 'season' | 'monthly' | 'per_race';
|
||||||
amount: number;
|
amount: number;
|
||||||
};
|
};
|
||||||
type UpsertMembershipFeeOutputDto = { fee: MembershipFeeDto };
|
type UpsertMembershipFeeOutputDto = { fee: MembershipFeeDTO };
|
||||||
type CreatePrizeInputDto = {
|
type CreatePrizeInputDto = {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
@@ -58,12 +64,12 @@ type CreatePrizeInputDto = {
|
|||||||
type: 'cash' | 'merchandise' | 'other';
|
type: 'cash' | 'merchandise' | 'other';
|
||||||
description?: string;
|
description?: string;
|
||||||
};
|
};
|
||||||
type CreatePrizeOutputDto = { prize: PrizeDto };
|
type CreatePrizeOutputDto = { prize: PrizeDTO };
|
||||||
type AwardPrizeInputDto = {
|
type AwardPrizeInputDto = {
|
||||||
prizeId: string;
|
prizeId: string;
|
||||||
driverId: string;
|
driverId: string;
|
||||||
};
|
};
|
||||||
type AwardPrizeOutputDto = { prize: PrizeDto };
|
type AwardPrizeOutputDto = { prize: PrizeDTO };
|
||||||
type DeletePrizeInputDto = { prizeId: string };
|
type DeletePrizeInputDto = { prizeId: string };
|
||||||
type DeletePrizeOutputDto = { success: boolean };
|
type DeletePrizeOutputDto = { success: boolean };
|
||||||
|
|
||||||
@@ -149,4 +155,4 @@ export class PaymentsApiClient extends BaseApiClient {
|
|||||||
processWalletTransaction(input: ProcessWalletTransactionInputDto): Promise<ProcessWalletTransactionOutputDto> {
|
processWalletTransaction(input: ProcessWalletTransactionInputDto): Promise<ProcessWalletTransactionOutputDto> {
|
||||||
return this.post<ProcessWalletTransactionOutputDto>('/payments/wallets/transactions', input);
|
return this.post<ProcessWalletTransactionOutputDto>('/payments/wallets/transactions', input);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { BaseApiClient } from '../base/BaseApiClient';
|
import { BaseApiClient } from '../base/BaseApiClient';
|
||||||
import type {
|
import type { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
|
||||||
LeagueAdminProtestsDTO,
|
import type { LeagueAdminProtestsDTO } from '../../types/generated/LeagueAdminProtestsDTO';
|
||||||
ApplyPenaltyCommandDTO,
|
import type { RaceProtestsDTO } from '../../types/generated/RaceProtestsDTO';
|
||||||
RequestProtestDefenseCommandDTO,
|
import type { RequestProtestDefenseCommandDTO } from '../../types/generated/RequestProtestDefenseCommandDTO';
|
||||||
ReviewProtestCommandDTO,
|
import type { ReviewProtestCommandDTO } from '../../types/generated/ReviewProtestCommandDTO';
|
||||||
} from '../../types/generated';
|
|
||||||
import type { RaceProtestsDTO } from '../../types';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Protests API Client
|
* Protests API Client
|
||||||
@@ -42,4 +40,4 @@ export class ProtestsApiClient extends BaseApiClient {
|
|||||||
getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
|
getRaceProtests(raceId: string): Promise<RaceProtestsDTO> {
|
||||||
return this.get<RaceProtestsDTO>(`/races/${raceId}/protests`);
|
return this.get<RaceProtestsDTO>(`/races/${raceId}/protests`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { RaceDetailLeagueDTO } from '../../types/generated/RaceDetailLeague
|
|||||||
import type { RaceDetailEntryDTO } from '../../types/generated/RaceDetailEntryDTO';
|
import type { RaceDetailEntryDTO } from '../../types/generated/RaceDetailEntryDTO';
|
||||||
import type { RaceDetailRegistrationDTO } from '../../types/generated/RaceDetailRegistrationDTO';
|
import type { RaceDetailRegistrationDTO } from '../../types/generated/RaceDetailRegistrationDTO';
|
||||||
import type { RaceDetailUserResultDTO } from '../../types/generated/RaceDetailUserResultDTO';
|
import type { RaceDetailUserResultDTO } from '../../types/generated/RaceDetailUserResultDTO';
|
||||||
|
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
|
||||||
|
|
||||||
// Define missing types
|
// Define missing types
|
||||||
type RacesPageDataDTO = { races: RacesPageDataRaceDTO[] };
|
type RacesPageDataDTO = { races: RacesPageDataRaceDTO[] };
|
||||||
@@ -42,8 +43,9 @@ export class RacesApiClient extends BaseApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Get races page data */
|
/** Get races page data */
|
||||||
getPageData(): Promise<RacesPageDataDTO> {
|
getPageData(leagueId?: string): Promise<RacesPageDataDTO> {
|
||||||
return this.get<RacesPageDataDTO>('/races/page-data');
|
const query = leagueId ? `?leagueId=${encodeURIComponent(leagueId)}` : '';
|
||||||
|
return this.get<RacesPageDataDTO>(`/races/page-data${query}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Get race detail */
|
/** Get race detail */
|
||||||
@@ -90,4 +92,9 @@ export class RacesApiClient extends BaseApiClient {
|
|||||||
reopen(raceId: string): Promise<void> {
|
reopen(raceId: string): Promise<void> {
|
||||||
return this.post<void>(`/races/${raceId}/reopen`, {});
|
return this.post<void>(`/races/${raceId}/reopen`, {});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/** File a protest */
|
||||||
|
fileProtest(input: FileProtestCommandDTO): Promise<void> {
|
||||||
|
return this.post<void>('/races/protests/file', input);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,10 +11,11 @@ import {
|
|||||||
} from 'react';
|
} from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
|
|
||||||
import type { AuthSession } from './AuthService';
|
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||||
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
session: AuthSession | null;
|
session: SessionViewModel | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (returnTo?: string) => void;
|
login: (returnTo?: string) => void;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
@@ -24,33 +25,24 @@ type AuthContextValue = {
|
|||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
interface AuthProviderProps {
|
interface AuthProviderProps {
|
||||||
initialSession?: AuthSession | null;
|
initialSession?: SessionViewModel | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AuthProvider({ initialSession = null, children }: AuthProviderProps) {
|
export function AuthProvider({ initialSession = null, children }: AuthProviderProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [session, setSession] = useState<AuthSession | null>(initialSession);
|
const { sessionService, authService } = useServices();
|
||||||
|
const [session, setSession] = useState<SessionViewModel | null>(initialSession);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const fetchSession = useCallback(async () => {
|
const fetchSession = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/auth/session', {
|
const current = await sessionService.getSession();
|
||||||
method: 'GET',
|
setSession(current);
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
setSession(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { session: AuthSession | null };
|
|
||||||
setSession(data.session ?? null);
|
|
||||||
} catch {
|
} catch {
|
||||||
setSession(null);
|
setSession(null);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [sessionService]);
|
||||||
|
|
||||||
const refreshSession = useCallback(async () => {
|
const refreshSession = useCallback(async () => {
|
||||||
await fetchSession();
|
await fetchSession();
|
||||||
@@ -79,17 +71,14 @@ export function AuthProvider({ initialSession = null, children }: AuthProviderPr
|
|||||||
const logout = useCallback(async () => {
|
const logout = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await fetch('/api/auth/logout', {
|
await authService.logout();
|
||||||
method: 'POST',
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
setSession(null);
|
setSession(null);
|
||||||
router.push('/');
|
router.push('/');
|
||||||
router.refresh();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [router]);
|
}, [authService, router]);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -111,4 +100,4 @@ export function useAuth(): AuthContextValue {
|
|||||||
throw new Error('useAuth must be used within an AuthProvider');
|
throw new Error('useAuth must be used within an AuthProvider');
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { CreateLeagueInputDTO } from '@/lib/types/CreateLeagueInputDTO';
|
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
|
||||||
import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages';
|
import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages';
|
||||||
import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier';
|
import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier';
|
||||||
|
|
||||||
@@ -267,21 +267,11 @@ export class LeagueWizardCommandModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO {
|
toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO {
|
||||||
let maxMembers: number;
|
|
||||||
|
|
||||||
if (this.structure.mode === 'solo') {
|
|
||||||
maxMembers = this.structure.maxDrivers ?? 0;
|
|
||||||
} else {
|
|
||||||
const teams = this.structure.maxTeams ?? 0;
|
|
||||||
const perTeam = this.structure.driversPerTeam ?? 0;
|
|
||||||
maxMembers = teams * perTeam;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: this.basics.name.trim(),
|
name: this.basics.name.trim(),
|
||||||
description: this.basics.description?.trim() ?? '',
|
description: this.basics.description?.trim() ?? '',
|
||||||
isPublic: this.basics.visibility === 'public',
|
// API currently only supports public/private. Treat unlisted as private for now.
|
||||||
maxMembers,
|
visibility: this.basics.visibility === 'public' ? 'public' : 'private',
|
||||||
ownerId,
|
ownerId,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -311,4 +301,4 @@ export class LeagueWizardCommandModel {
|
|||||||
stewarding: instance.stewarding,
|
stewarding: instance.stewarding,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { PenaltiesApiClient } from '../api/penalties/PenaltiesApiClient';
|
|||||||
import { PenaltyService } from './penalties/PenaltyService';
|
import { PenaltyService } from './penalties/PenaltyService';
|
||||||
import { ConsoleErrorReporter } from '../infrastructure/logging/ConsoleErrorReporter';
|
import { ConsoleErrorReporter } from '../infrastructure/logging/ConsoleErrorReporter';
|
||||||
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
|
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
|
||||||
|
import { LandingService } from './landing/LandingService';
|
||||||
|
|
||||||
// Services
|
// Services
|
||||||
import { RaceService } from './races/RaceService';
|
import { RaceService } from './races/RaceService';
|
||||||
@@ -88,6 +89,23 @@ export class ServiceFactory {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Legacy compatibility: older pages/components were written against a static ServiceFactory.
|
||||||
|
* Prefer `useServices()` + react-query hooks.
|
||||||
|
*/
|
||||||
|
private static defaultInstance: ServiceFactory | null = null;
|
||||||
|
|
||||||
|
private static getDefaultInstance(): ServiceFactory {
|
||||||
|
if (!this.defaultInstance) {
|
||||||
|
this.defaultInstance = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
|
||||||
|
}
|
||||||
|
return this.defaultInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
static getSponsorService(): SponsorService {
|
||||||
|
return this.getDefaultInstance().createSponsorService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create RaceService instance
|
* Create RaceService instance
|
||||||
*/
|
*/
|
||||||
@@ -151,8 +169,8 @@ export class ServiceFactory {
|
|||||||
/**
|
/**
|
||||||
* Create LeagueMembershipService instance
|
* Create LeagueMembershipService instance
|
||||||
*/
|
*/
|
||||||
createLeagueMembershipService(): LeagueMembershipService {
|
createLeagueMembershipService(): LeagueMembershipService {
|
||||||
return new LeagueMembershipService(this.apiClients.leagues);
|
return new LeagueMembershipService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,4 +297,11 @@ export class ServiceFactory {
|
|||||||
createPenaltyService(): PenaltyService {
|
createPenaltyService(): PenaltyService {
|
||||||
return new PenaltyService(this.apiClients.penalties);
|
return new PenaltyService(this.apiClients.penalties);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
/**
|
||||||
|
* Create LandingService instance (used by server components)
|
||||||
|
*/
|
||||||
|
createLandingService(): LandingService {
|
||||||
|
return new LandingService(this.apiClients.races, this.apiClients.leagues, this.apiClients.teams);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,35 +1,35 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { createContext, useContext, useMemo, ReactNode } from 'react';
|
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { createContext, ReactNode, useContext, useMemo } from 'react';
|
||||||
import { ServiceFactory } from './ServiceFactory';
|
import { ServiceFactory } from './ServiceFactory';
|
||||||
|
|
||||||
// Import all service types
|
// Import all service types
|
||||||
import { RaceService } from './races/RaceService';
|
import { AnalyticsService } from './analytics/AnalyticsService';
|
||||||
import { RaceResultsService } from './races/RaceResultsService';
|
import { AuthService } from './auth/AuthService';
|
||||||
import { RaceStewardingService } from './races/RaceStewardingService';
|
import { SessionService } from './auth/SessionService';
|
||||||
import { DriverService } from './drivers/DriverService';
|
import { DashboardService } from './dashboard/DashboardService';
|
||||||
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
import { DriverRegistrationService } from './drivers/DriverRegistrationService';
|
||||||
import { TeamService } from './teams/TeamService';
|
import { DriverService } from './drivers/DriverService';
|
||||||
import { TeamJoinService } from './teams/TeamJoinService';
|
|
||||||
import { LeagueService } from './leagues/LeagueService';
|
|
||||||
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
|
||||||
|
import { LeagueService } from './leagues/LeagueService';
|
||||||
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
|
||||||
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
|
import { LeagueStewardingService } from './leagues/LeagueStewardingService';
|
||||||
import { LeagueWalletService } from './leagues/LeagueWalletService';
|
import { LeagueWalletService } from './leagues/LeagueWalletService';
|
||||||
|
import { AvatarService } from './media/AvatarService';
|
||||||
|
import { MediaService } from './media/MediaService';
|
||||||
|
import { MembershipFeeService } from './payments/MembershipFeeService';
|
||||||
|
import { PaymentService } from './payments/PaymentService';
|
||||||
|
import { WalletService } from './payments/WalletService';
|
||||||
|
import { PenaltyService } from './penalties/PenaltyService';
|
||||||
|
import { ProtestService } from './protests/ProtestService';
|
||||||
|
import { RaceResultsService } from './races/RaceResultsService';
|
||||||
|
import { RaceService } from './races/RaceService';
|
||||||
|
import { RaceStewardingService } from './races/RaceStewardingService';
|
||||||
import { SponsorService } from './sponsors/SponsorService';
|
import { SponsorService } from './sponsors/SponsorService';
|
||||||
import { SponsorshipService } from './sponsors/SponsorshipService';
|
import { SponsorshipService } from './sponsors/SponsorshipService';
|
||||||
import { PaymentService } from './payments/PaymentService';
|
import { TeamJoinService } from './teams/TeamJoinService';
|
||||||
import { AnalyticsService } from './analytics/AnalyticsService';
|
import { TeamService } from './teams/TeamService';
|
||||||
import { DashboardService } from './dashboard/DashboardService';
|
|
||||||
import { MediaService } from './media/MediaService';
|
|
||||||
import { AvatarService } from './media/AvatarService';
|
|
||||||
import { WalletService } from './payments/WalletService';
|
|
||||||
import { MembershipFeeService } from './payments/MembershipFeeService';
|
|
||||||
import { AuthService } from './auth/AuthService';
|
|
||||||
import { SessionService } from './auth/SessionService';
|
|
||||||
import { ProtestService } from './protests/ProtestService';
|
|
||||||
import { PenaltyService } from './penalties/PenaltyService';
|
|
||||||
|
|
||||||
export interface Services {
|
export interface Services {
|
||||||
raceService: RaceService;
|
raceService: RaceService;
|
||||||
@@ -115,7 +115,7 @@ export function ServiceProvider({ children }: ServiceProviderProps) {
|
|||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Before using this check for enhanced hooks that use react-query
|
||||||
export function useServices(): Services {
|
export function useServices(): Services {
|
||||||
const services = useContext(ServicesContext);
|
const services = useContext(ServicesContext);
|
||||||
if (!services) {
|
if (!services) {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { AuthApiClient } from '../../api/auth/AuthApiClient';
|
import { AuthApiClient } from '../../api/auth/AuthApiClient';
|
||||||
import { SessionViewModel } from '../../view-models/SessionViewModel';
|
import { SessionViewModel } from '../../view-models/SessionViewModel';
|
||||||
import type { LoginParams } from '../../types/generated/LoginParams';
|
import type { LoginParamsDTO } from '../../types/generated/LoginParamsDTO';
|
||||||
import type { SignupParams } from '../../types/generated/SignupParams';
|
import type { SignupParamsDTO } from '../../types/generated/SignupParamsDTO';
|
||||||
import type { LoginWithIracingCallbackParams } from '../../types/generated/LoginWithIracingCallbackParams';
|
import type { LoginWithIracingCallbackParamsDTO } from '../../types/generated/LoginWithIracingCallbackParamsDTO';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auth Service
|
* Auth Service
|
||||||
@@ -18,7 +18,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Sign up a new user
|
* Sign up a new user
|
||||||
*/
|
*/
|
||||||
async signup(params: SignupParams): Promise<SessionViewModel> {
|
async signup(params: SignupParamsDTO): Promise<SessionViewModel> {
|
||||||
try {
|
try {
|
||||||
const dto = await this.apiClient.signup(params);
|
const dto = await this.apiClient.signup(params);
|
||||||
return new SessionViewModel(dto.user);
|
return new SessionViewModel(dto.user);
|
||||||
@@ -30,7 +30,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Log in an existing user
|
* Log in an existing user
|
||||||
*/
|
*/
|
||||||
async login(params: LoginParams): Promise<SessionViewModel> {
|
async login(params: LoginParamsDTO): Promise<SessionViewModel> {
|
||||||
try {
|
try {
|
||||||
const dto = await this.apiClient.login(params);
|
const dto = await this.apiClient.login(params);
|
||||||
return new SessionViewModel(dto.user);
|
return new SessionViewModel(dto.user);
|
||||||
@@ -60,7 +60,7 @@ export class AuthService {
|
|||||||
/**
|
/**
|
||||||
* Login with iRacing callback
|
* Login with iRacing callback
|
||||||
*/
|
*/
|
||||||
async loginWithIracingCallback(params: LoginWithIracingCallbackParams): Promise<SessionViewModel> {
|
async loginWithIracingCallback(params: LoginWithIracingCallbackParamsDTO): Promise<SessionViewModel> {
|
||||||
try {
|
try {
|
||||||
const dto = await this.apiClient.loginWithIracingCallback(params);
|
const dto = await this.apiClient.loginWithIracingCallback(params);
|
||||||
return new SessionViewModel(dto.user);
|
return new SessionViewModel(dto.user);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient';
|
||||||
import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO";
|
import type { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO';
|
||||||
import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO";
|
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||||
import type { DriverDTO } from "@/lib/types/generated/DriverDTO";
|
|
||||||
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
|
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
|
||||||
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
|
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
|
||||||
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
|
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
|
||||||
@@ -50,7 +49,92 @@ export class DriverService {
|
|||||||
*/
|
*/
|
||||||
async getDriverProfile(driverId: string): Promise<DriverProfileViewModel> {
|
async getDriverProfile(driverId: string): Promise<DriverProfileViewModel> {
|
||||||
const dto = await this.apiClient.getDriverProfile(driverId);
|
const dto = await this.apiClient.getDriverProfile(driverId);
|
||||||
return new DriverProfileViewModel(dto);
|
return new DriverProfileViewModel({
|
||||||
|
currentDriver: dto.currentDriver
|
||||||
|
? {
|
||||||
|
id: dto.currentDriver.id,
|
||||||
|
name: dto.currentDriver.name,
|
||||||
|
country: dto.currentDriver.country,
|
||||||
|
avatarUrl: dto.currentDriver.avatarUrl,
|
||||||
|
iracingId: dto.currentDriver.iracingId ?? null,
|
||||||
|
joinedAt: dto.currentDriver.joinedAt,
|
||||||
|
rating: dto.currentDriver.rating ?? null,
|
||||||
|
globalRank: dto.currentDriver.globalRank ?? null,
|
||||||
|
consistency: dto.currentDriver.consistency ?? null,
|
||||||
|
bio: dto.currentDriver.bio ?? null,
|
||||||
|
totalDrivers: dto.currentDriver.totalDrivers ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
stats: dto.stats
|
||||||
|
? {
|
||||||
|
totalRaces: dto.stats.totalRaces,
|
||||||
|
wins: dto.stats.wins,
|
||||||
|
podiums: dto.stats.podiums,
|
||||||
|
dnfs: dto.stats.dnfs,
|
||||||
|
avgFinish: dto.stats.avgFinish ?? null,
|
||||||
|
bestFinish: dto.stats.bestFinish ?? null,
|
||||||
|
worstFinish: dto.stats.worstFinish ?? null,
|
||||||
|
finishRate: dto.stats.finishRate ?? null,
|
||||||
|
winRate: dto.stats.winRate ?? null,
|
||||||
|
podiumRate: dto.stats.podiumRate ?? null,
|
||||||
|
percentile: dto.stats.percentile ?? null,
|
||||||
|
rating: dto.stats.rating ?? null,
|
||||||
|
consistency: dto.stats.consistency ?? null,
|
||||||
|
overallRank: dto.stats.overallRank ?? null,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
finishDistribution: dto.finishDistribution
|
||||||
|
? {
|
||||||
|
totalRaces: dto.finishDistribution.totalRaces,
|
||||||
|
wins: dto.finishDistribution.wins,
|
||||||
|
podiums: dto.finishDistribution.podiums,
|
||||||
|
topTen: dto.finishDistribution.topTen,
|
||||||
|
dnfs: dto.finishDistribution.dnfs,
|
||||||
|
other: dto.finishDistribution.other,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
teamMemberships: dto.teamMemberships.map((m) => ({
|
||||||
|
teamId: m.teamId,
|
||||||
|
teamName: m.teamName,
|
||||||
|
teamTag: m.teamTag ?? null,
|
||||||
|
role: m.role,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
isCurrent: m.isCurrent,
|
||||||
|
})),
|
||||||
|
socialSummary: {
|
||||||
|
friendsCount: dto.socialSummary.friendsCount,
|
||||||
|
friends: dto.socialSummary.friends.map((f) => ({
|
||||||
|
id: f.id,
|
||||||
|
name: f.name,
|
||||||
|
country: f.country,
|
||||||
|
avatarUrl: f.avatarUrl,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
extendedProfile: dto.extendedProfile
|
||||||
|
? {
|
||||||
|
socialHandles: dto.extendedProfile.socialHandles.map((h) => ({
|
||||||
|
platform: h.platform as any,
|
||||||
|
handle: h.handle,
|
||||||
|
url: h.url,
|
||||||
|
})),
|
||||||
|
achievements: dto.extendedProfile.achievements.map((a) => ({
|
||||||
|
id: a.id,
|
||||||
|
title: a.title,
|
||||||
|
description: a.description,
|
||||||
|
icon: a.icon as any,
|
||||||
|
rarity: a.rarity as any,
|
||||||
|
earnedAt: a.earnedAt,
|
||||||
|
})),
|
||||||
|
racingStyle: dto.extendedProfile.racingStyle,
|
||||||
|
favoriteTrack: dto.extendedProfile.favoriteTrack,
|
||||||
|
favoriteCar: dto.extendedProfile.favoriteCar,
|
||||||
|
timezone: dto.extendedProfile.timezone,
|
||||||
|
availableHours: dto.extendedProfile.availableHours,
|
||||||
|
lookingForTeam: dto.extendedProfile.lookingForTeam,
|
||||||
|
openToRequests: dto.extendedProfile.openToRequests,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -65,15 +149,15 @@ export class DriverService {
|
|||||||
/**
|
/**
|
||||||
* Find driver by ID
|
* Find driver by ID
|
||||||
*/
|
*/
|
||||||
async findById(id: string): Promise<DriverDTO | null> {
|
async findById(id: string): Promise<GetDriverOutputDTO | null> {
|
||||||
return this.apiClient.getDriver(id);
|
return this.apiClient.getDriver(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find multiple drivers by IDs
|
* Find multiple drivers by IDs
|
||||||
*/
|
*/
|
||||||
async findByIds(ids: string[]): Promise<DriverDTO[]> {
|
async findByIds(ids: string[]): Promise<GetDriverOutputDTO[]> {
|
||||||
const drivers = await Promise.all(ids.map(id => this.apiClient.getDriver(id)));
|
const drivers = await Promise.all(ids.map(id => this.apiClient.getDriver(id)));
|
||||||
return drivers.filter((d): d is DriverDTO => d !== null);
|
return drivers.filter((d): d is GetDriverOutputDTO => d !== null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,17 @@
|
|||||||
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
import { RacesApiClient } from '@/lib/api/races/RacesApiClient';
|
||||||
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||||
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
import { TeamsApiClient } from '@/lib/api/teams/TeamsApiClient';
|
||||||
|
import type { AllLeaguesWithCapacityDTO } from '@/lib/types/generated/AllLeaguesWithCapacityDTO';
|
||||||
|
import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
||||||
|
import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO';
|
||||||
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
|
import type { LeagueSummaryDTO } from '@/lib/types/generated/LeagueSummaryDTO';
|
||||||
import type { GetAllTeamsOutputDTO, TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||||
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
import { RacesPageViewModel } from '@/lib/view-models/RacesPageViewModel';
|
||||||
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
|
import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel';
|
||||||
import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel';
|
import { LeagueCardViewModel } from '@/lib/view-models/LeagueCardViewModel';
|
||||||
import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel';
|
import { TeamCardViewModel } from '@/lib/view-models/TeamCardViewModel';
|
||||||
import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel';
|
import { UpcomingRaceCardViewModel } from '@/lib/view-models/UpcomingRaceCardViewModel';
|
||||||
|
|
||||||
// DTO matching backend RacesPageDataDTO for discovery usage
|
|
||||||
interface RacesPageDataDTO {
|
|
||||||
races: {
|
|
||||||
id: string;
|
|
||||||
track: string;
|
|
||||||
car: string;
|
|
||||||
scheduledAt: string;
|
|
||||||
status: string;
|
|
||||||
leagueId: string;
|
|
||||||
leagueName: string;
|
|
||||||
strengthOfField: number | null;
|
|
||||||
isUpcoming: boolean;
|
|
||||||
isLive: boolean;
|
|
||||||
isPast: boolean;
|
|
||||||
}[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class LandingService {
|
export class LandingService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly racesApi: RacesApiClient,
|
private readonly racesApi: RacesApiClient,
|
||||||
@@ -36,21 +22,21 @@ export class LandingService {
|
|||||||
async getHomeDiscovery(): Promise<HomeDiscoveryViewModel> {
|
async getHomeDiscovery(): Promise<HomeDiscoveryViewModel> {
|
||||||
const [racesDto, leaguesDto, teamsDto] = await Promise.all([
|
const [racesDto, leaguesDto, teamsDto] = await Promise.all([
|
||||||
this.racesApi.getPageData() as Promise<RacesPageDataDTO>,
|
this.racesApi.getPageData() as Promise<RacesPageDataDTO>,
|
||||||
this.leaguesApi.getAllWithCapacity() as Promise<{ leagues: LeagueSummaryDTO[] }>,
|
this.leaguesApi.getAllWithCapacity() as Promise<AllLeaguesWithCapacityDTO>,
|
||||||
this.teamsApi.getAll(),
|
this.teamsApi.getAll() as Promise<GetAllTeamsOutputDTO>,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const racesVm = new RacesPageViewModel(racesDto);
|
const racesVm = new RacesPageViewModel(racesDto);
|
||||||
|
|
||||||
const topLeagues = leaguesDto.leagues.slice(0, 4).map(
|
const topLeagues = leaguesDto.leagues.slice(0, 4).map(
|
||||||
league => new LeagueCardViewModel({
|
(league: LeagueSummaryDTO) => new LeagueCardViewModel({
|
||||||
id: league.id,
|
id: league.id,
|
||||||
name: league.name,
|
name: league.name,
|
||||||
description: 'Competitive iRacing league',
|
description: 'Competitive iRacing league',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const teams = (teamsDto as GetAllTeamsOutputDTO).teams.slice(0, 4).map(
|
const teams = teamsDto.teams.slice(0, 4).map(
|
||||||
(team: TeamListItemDTO) =>
|
(team: TeamListItemDTO) =>
|
||||||
new TeamCardViewModel({
|
new TeamCardViewModel({
|
||||||
id: team.id,
|
id: team.id,
|
||||||
|
|||||||
@@ -47,6 +47,26 @@ export class LeagueMembershipService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Join a league.
|
||||||
|
*
|
||||||
|
* NOTE: The API currently exposes membership mutations via league member management endpoints.
|
||||||
|
* For now we keep the website decoupled by consuming only the API through this service.
|
||||||
|
*/
|
||||||
|
async joinLeague(leagueId: string, driverId: string): Promise<void> {
|
||||||
|
// Temporary: no join endpoint exposed yet in API.
|
||||||
|
// Keep behavior predictable for UI.
|
||||||
|
throw new Error('Joining leagues is not available in this build.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leave a league.
|
||||||
|
*/
|
||||||
|
async leaveLeague(leagueId: string, driverId: string): Promise<void> {
|
||||||
|
// Temporary: no leave endpoint exposed yet in API.
|
||||||
|
throw new Error('Leaving leagues is not available in this build.');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set memberships in cache (for use after API calls).
|
* Set memberships in cache (for use after API calls).
|
||||||
*/
|
*/
|
||||||
@@ -118,4 +138,4 @@ export class LeagueMembershipService {
|
|||||||
clearLeagueMemberships(leagueId: string): void {
|
clearLeagueMemberships(leagueId: string): void {
|
||||||
LeagueMembershipService.clearLeagueMemberships(leagueId);
|
LeagueMembershipService.clearLeagueMemberships(leagueId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
|
|||||||
import { RaceDTO } from "@/lib/types/generated/RaceDTO";
|
import { RaceDTO } from "@/lib/types/generated/RaceDTO";
|
||||||
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
|
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
|
||||||
import { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
|
import { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
|
||||||
|
import type { LeagueMembership } from "@/lib/types/LeagueMembership";
|
||||||
|
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -48,9 +50,9 @@ export class LeagueService {
|
|||||||
name: league.name,
|
name: league.name,
|
||||||
description: league.description ?? '',
|
description: league.description ?? '',
|
||||||
ownerId: league.ownerId,
|
ownerId: league.ownerId,
|
||||||
createdAt: '', // Not provided by API
|
createdAt: league.createdAt,
|
||||||
maxDrivers: league.maxMembers,
|
maxDrivers: league.settings.maxDrivers ?? 0,
|
||||||
usedDriverSlots: league.memberCount,
|
usedDriverSlots: league.usedSlots,
|
||||||
structureSummary: 'TBD',
|
structureSummary: 'TBD',
|
||||||
timingSummary: 'TBD'
|
timingSummary: 'TBD'
|
||||||
}));
|
}));
|
||||||
@@ -66,16 +68,20 @@ export class LeagueService {
|
|||||||
// League memberships (roles, statuses)
|
// League memberships (roles, statuses)
|
||||||
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
||||||
|
|
||||||
|
const memberships: LeagueMembership[] = membershipsDto.members.map((m) => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
leagueId,
|
||||||
|
role: (m.role as LeagueMembership['role']) ?? 'member',
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
status: 'active',
|
||||||
|
}));
|
||||||
|
|
||||||
// Resolve unique drivers that appear in standings
|
// Resolve unique drivers that appear in standings
|
||||||
const driverIds: string[] = Array.from(new Set(dto.standings.map((entry: any) => entry.driverId)));
|
const driverIds: string[] = Array.from(new Set(dto.standings.map((entry: any) => entry.driverId)));
|
||||||
const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient.getDriver(id)));
|
const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient.getDriver(id)));
|
||||||
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
|
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||||
|
|
||||||
const dtoWithExtras = {
|
const dtoWithExtras = { standings: dto.standings, drivers, memberships };
|
||||||
standings: dto.standings,
|
|
||||||
drivers,
|
|
||||||
memberships: membershipsDto.members,
|
|
||||||
};
|
|
||||||
|
|
||||||
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
|
return new LeagueStandingsViewModel(dtoWithExtras, currentUserId);
|
||||||
}
|
}
|
||||||
@@ -96,6 +102,13 @@ export class LeagueService {
|
|||||||
return new LeagueScheduleViewModel(dto);
|
return new LeagueScheduleViewModel(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get seasons for a league
|
||||||
|
*/
|
||||||
|
async getLeagueSeasons(leagueId: string): Promise<LeagueSeasonSummaryDTO[]> {
|
||||||
|
return this.apiClient.getSeasons(leagueId);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get league memberships
|
* Get league memberships
|
||||||
*/
|
*/
|
||||||
@@ -162,18 +175,19 @@ export class LeagueService {
|
|||||||
// Get membership
|
// Get membership
|
||||||
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
const membershipsDto = await this.apiClient.getMemberships(leagueId);
|
||||||
const membership = membershipsDto.members.find((m: any) => m.driverId === currentDriverId);
|
const membership = membershipsDto.members.find((m: any) => m.driverId === currentDriverId);
|
||||||
const isAdmin = membership ? ['admin', 'owner'].includes(membership.role) : false;
|
const isAdmin = membership ? ['admin', 'owner'].includes((membership as any).role) : false;
|
||||||
|
|
||||||
// Get main sponsor
|
// Get main sponsor
|
||||||
let mainSponsor = null;
|
let mainSponsor = null;
|
||||||
try {
|
try {
|
||||||
const seasonsDto = await this.apiClient.getSeasons(leagueId);
|
const seasons = await this.apiClient.getSeasons(leagueId);
|
||||||
const activeSeason = seasonsDto.seasons.find((s: any) => s.status === 'active') ?? seasonsDto.seasons[0];
|
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||||
if (activeSeason) {
|
if (activeSeason) {
|
||||||
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.id);
|
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
||||||
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active');
|
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active');
|
||||||
if (mainSponsorship) {
|
if (mainSponsorship) {
|
||||||
const sponsor = await this.sponsorsApiClient.getSponsor(mainSponsorship.sponsorId);
|
const sponsorResult = await this.sponsorsApiClient.getSponsor((mainSponsorship as any).sponsorId ?? (mainSponsorship as any).sponsor?.id);
|
||||||
|
const sponsor = (sponsorResult as any)?.sponsor ?? null;
|
||||||
if (sponsor) {
|
if (sponsor) {
|
||||||
mainSponsor = {
|
mainSponsor = {
|
||||||
name: sponsor.name,
|
name: sponsor.name,
|
||||||
@@ -241,7 +255,11 @@ export class LeagueService {
|
|||||||
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO));
|
const allRaces = leagueRaces.races.map(r => new RaceViewModel(r as RaceDTO));
|
||||||
|
|
||||||
// League stats endpoint currently returns global league statistics rather than per-league values
|
// League stats endpoint currently returns global league statistics rather than per-league values
|
||||||
const leagueStats = await this.apiClient.getTotal();
|
const leagueStats: LeagueStatsDTO = {
|
||||||
|
totalMembers: league.usedSlots,
|
||||||
|
totalRaces: allRaces.length,
|
||||||
|
averageRating: 0,
|
||||||
|
};
|
||||||
|
|
||||||
// Get sponsors
|
// Get sponsors
|
||||||
const sponsors = await this.getLeagueSponsors(leagueId);
|
const sponsors = await this.getLeagueSponsors(leagueId);
|
||||||
@@ -268,16 +286,17 @@ export class LeagueService {
|
|||||||
private async getLeagueSponsors(leagueId: string): Promise<SponsorInfo[]> {
|
private async getLeagueSponsors(leagueId: string): Promise<SponsorInfo[]> {
|
||||||
try {
|
try {
|
||||||
const seasons = await this.apiClient.getSeasons(leagueId);
|
const seasons = await this.apiClient.getSeasons(leagueId);
|
||||||
const activeSeason = seasons.seasons.find((s: any) => s.status === 'active') ?? seasons.seasons[0];
|
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||||
|
|
||||||
if (!activeSeason) return [];
|
if (!activeSeason) return [];
|
||||||
|
|
||||||
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.id);
|
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.seasonId);
|
||||||
const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active');
|
const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active');
|
||||||
|
|
||||||
const sponsorInfos: SponsorInfo[] = [];
|
const sponsorInfos: SponsorInfo[] = [];
|
||||||
for (const sponsorship of activeSponsorships) {
|
for (const sponsorship of activeSponsorships) {
|
||||||
const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId);
|
const sponsorResult = await this.sponsorsApiClient.getSponsor((sponsorship as any).sponsorId ?? (sponsorship as any).sponsor?.id);
|
||||||
|
const sponsor = (sponsorResult as any)?.sponsor ?? null;
|
||||||
if (sponsor) {
|
if (sponsor) {
|
||||||
// Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed
|
// Tagline is not supplied by the sponsor API in this build; callers may derive one from marketing content if needed
|
||||||
sponsorInfos.push({
|
sponsorInfos.push({
|
||||||
@@ -285,7 +304,7 @@ export class LeagueService {
|
|||||||
name: sponsor.name,
|
name: sponsor.name,
|
||||||
logoUrl: sponsor.logoUrl ?? '',
|
logoUrl: sponsor.logoUrl ?? '',
|
||||||
websiteUrl: sponsor.websiteUrl ?? '',
|
websiteUrl: sponsor.websiteUrl ?? '',
|
||||||
tier: sponsorship.tier,
|
tier: ((sponsorship as any).tier as 'main' | 'secondary') ?? 'secondary',
|
||||||
tagline: '',
|
tagline: '',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -312,4 +331,4 @@ export class LeagueService {
|
|||||||
const result = await this.apiClient.getScoringPresets();
|
const result = await this.apiClient.getScoringPresets();
|
||||||
return result.presets;
|
return result.presets;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
||||||
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
||||||
import type { LeagueConfigFormModel } from "@core/racing/application";
|
import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
|
||||||
import type { LeagueScoringPresetDTO } from "@core/racing/application/ports/LeagueScoringPresetProvider";
|
import type { LeagueScoringPresetDTO } from "@/lib/types/generated/LeagueScoringPresetDTO";
|
||||||
import type { GetDriverOutputDTO } from "@/lib/types/generated/GetDriverOutputDTO";
|
|
||||||
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
|
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
|
||||||
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
|
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ export class LeagueSettingsService {
|
|||||||
|
|
||||||
// Get config
|
// Get config
|
||||||
const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId);
|
const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId);
|
||||||
const config: LeagueConfigFormModel = configDto.config;
|
const config: LeagueConfigFormModel = (configDto.form ?? undefined) as unknown as LeagueConfigFormModel;
|
||||||
|
|
||||||
// Get presets
|
// Get presets
|
||||||
const presetsDto = await this.leaguesApiClient.getScoringPresets();
|
const presetsDto = await this.leaguesApiClient.getScoringPresets();
|
||||||
@@ -102,4 +101,4 @@ export class LeagueSettingsService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { RacesApiClient } from '../../api/races/RacesApiClient';
|
|||||||
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
import { RaceDetailViewModel } from '../../view-models/RaceDetailViewModel';
|
||||||
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
|
import { RacesPageViewModel } from '../../view-models/RacesPageViewModel';
|
||||||
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
|
import { RaceStatsViewModel } from '../../view-models/RaceStatsViewModel';
|
||||||
|
import type { FileProtestCommandDTO } from '../../types/generated/FileProtestCommandDTO';
|
||||||
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
|
import type { RaceStatsDTO } from '../../types/generated/RaceStatsDTO';
|
||||||
/**
|
/**
|
||||||
* Race Service
|
* Race Service
|
||||||
@@ -33,6 +34,14 @@ export class RaceService {
|
|||||||
return new RacesPageViewModel(dto);
|
return new RacesPageViewModel(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get races page data filtered by league
|
||||||
|
*/
|
||||||
|
async getLeagueRacesPageData(leagueId: string): Promise<RacesPageViewModel> {
|
||||||
|
const dto = await this.apiClient.getPageData(leagueId);
|
||||||
|
return new RacesPageViewModel(dto);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all races page data with view model transformation
|
* Get all races page data with view model transformation
|
||||||
* Currently same as getRacesPageData, but can be extended for different filtering
|
* Currently same as getRacesPageData, but can be extended for different filtering
|
||||||
@@ -54,14 +63,14 @@ export class RaceService {
|
|||||||
* Register for a race
|
* Register for a race
|
||||||
*/
|
*/
|
||||||
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
|
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
|
||||||
await this.apiClient.register(raceId, { leagueId, driverId });
|
await this.apiClient.register(raceId, { raceId, leagueId, driverId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Withdraw from a race
|
* Withdraw from a race
|
||||||
*/
|
*/
|
||||||
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
|
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
|
||||||
await this.apiClient.withdraw(raceId, { driverId });
|
await this.apiClient.withdraw(raceId, { raceId, driverId });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -85,6 +94,13 @@ export class RaceService {
|
|||||||
await this.apiClient.reopen(raceId);
|
await this.apiClient.reopen(raceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File a protest
|
||||||
|
*/
|
||||||
|
async fileProtest(input: FileProtestCommandDTO): Promise<void> {
|
||||||
|
await this.apiClient.fileProtest(input);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find races by league ID
|
* Find races by league ID
|
||||||
*
|
*
|
||||||
@@ -92,7 +108,8 @@ export class RaceService {
|
|||||||
* so this method deliberately signals that the operation is unavailable instead of making
|
* so this method deliberately signals that the operation is unavailable instead of making
|
||||||
* assumptions about URL structure.
|
* assumptions about URL structure.
|
||||||
*/
|
*/
|
||||||
async findByLeagueId(_leagueId: string): Promise<never> {
|
async findByLeagueId(leagueId: string): Promise<RacesPageViewModel['races']> {
|
||||||
throw new Error('Finding races by league ID is not supported in this build');
|
const page = await this.getLeagueRacesPageData(leagueId);
|
||||||
|
return page.races;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
|
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
|
||||||
import type { GetEntitySponsorshipPricingResultDto } from '../../api/sponsors/SponsorsApiClient';
|
|
||||||
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
|
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
|
||||||
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
|
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
|
||||||
import { SponsorshipRequestViewModel } from '../../view-models/SponsorshipRequestViewModel';
|
import { SponsorshipRequestViewModel } from '../../view-models/SponsorshipRequestViewModel';
|
||||||
import type { SponsorSponsorshipsDTO } from '../../types/generated';
|
import type { GetPendingSponsorshipRequestsOutputDTO } from '../../types/generated/GetPendingSponsorshipRequestsOutputDTO';
|
||||||
|
import type { SponsorshipRequestDTO } from '../../types/generated/SponsorshipRequestDTO';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sponsorship Service
|
* Sponsorship Service
|
||||||
@@ -20,8 +20,16 @@ export class SponsorshipService {
|
|||||||
* Get sponsorship pricing with view model transformation
|
* Get sponsorship pricing with view model transformation
|
||||||
*/
|
*/
|
||||||
async getSponsorshipPricing(): Promise<SponsorshipPricingViewModel> {
|
async getSponsorshipPricing(): Promise<SponsorshipPricingViewModel> {
|
||||||
|
// Pricing shape isn't finalized in the API yet.
|
||||||
|
// Keep a predictable, UI-friendly structure until a dedicated DTO is introduced.
|
||||||
const dto = await this.apiClient.getPricing();
|
const dto = await this.apiClient.getPricing();
|
||||||
return new SponsorshipPricingViewModel(dto);
|
const main = dto.pricing.find((p) => p.entityType === 'main')?.price ?? 0;
|
||||||
|
const secondary = dto.pricing.find((p) => p.entityType === 'secondary')?.price ?? 0;
|
||||||
|
return new SponsorshipPricingViewModel({
|
||||||
|
mainSlotPrice: main,
|
||||||
|
secondarySlotPrice: secondary,
|
||||||
|
currency: 'USD',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -39,21 +47,22 @@ export class SponsorshipService {
|
|||||||
* Get pending sponsorship requests for an entity
|
* Get pending sponsorship requests for an entity
|
||||||
*/
|
*/
|
||||||
async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<SponsorshipRequestViewModel[]> {
|
async getPendingSponsorshipRequests(params: { entityType: string; entityId: string }): Promise<SponsorshipRequestViewModel[]> {
|
||||||
const dto = await this.apiClient.getPendingSponsorshipRequests(params);
|
const dto = (await this.apiClient.getPendingSponsorshipRequests(params)) as unknown as GetPendingSponsorshipRequestsOutputDTO;
|
||||||
return dto.requests.map(dto => new SponsorshipRequestViewModel(dto));
|
const requests = (dto as any).requests as SponsorshipRequestDTO[];
|
||||||
|
return (requests ?? []).map((r: SponsorshipRequestDTO) => new SponsorshipRequestViewModel(r));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Accept a sponsorship request
|
* Accept a sponsorship request
|
||||||
*/
|
*/
|
||||||
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<void> {
|
async acceptSponsorshipRequest(requestId: string, respondedBy: string): Promise<void> {
|
||||||
await this.apiClient.acceptSponsorshipRequest(requestId, respondedBy);
|
await this.apiClient.acceptSponsorshipRequest(requestId, { respondedBy });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reject a sponsorship request
|
* Reject a sponsorship request
|
||||||
*/
|
*/
|
||||||
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<void> {
|
async rejectSponsorshipRequest(requestId: string, respondedBy: string, reason?: string): Promise<void> {
|
||||||
await this.apiClient.rejectSponsorshipRequest(requestId, respondedBy, reason);
|
await this.apiClient.rejectSponsorshipRequest(requestId, { respondedBy, ...(reason ? { reason } : {}) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
|||||||
import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel';
|
import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel';
|
||||||
import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
|
import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
|
||||||
import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel';
|
import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel';
|
||||||
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
|
|
||||||
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
|
||||||
import type { GetAllTeamsOutputDTO, TeamListItemDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
import type { GetAllTeamsOutputDTO } from '@/lib/types/generated/GetAllTeamsOutputDTO';
|
||||||
|
import type { TeamListItemDTO } from '@/lib/types/generated/TeamListItemDTO';
|
||||||
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
|
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
|
||||||
import type { GetTeamMembersOutputDTO, TeamMemberDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO';
|
import type { GetTeamMembersOutputDTO } from '@/lib/types/generated/GetTeamMembersOutputDTO';
|
||||||
|
import type { TeamMemberDTO } from '@/lib/types/generated/TeamMemberDTO';
|
||||||
import type { CreateTeamInputDTO } from '@/lib/types/generated/CreateTeamInputDTO';
|
import type { CreateTeamInputDTO } from '@/lib/types/generated/CreateTeamInputDTO';
|
||||||
import type { CreateTeamOutputDTO } from '@/lib/types/generated/CreateTeamOutputDTO';
|
import type { CreateTeamOutputDTO } from '@/lib/types/generated/CreateTeamOutputDTO';
|
||||||
import type { UpdateTeamInputDTO } from '@/lib/types/generated/UpdateTeamInputDTO';
|
import type { UpdateTeamInputDTO } from '@/lib/types/generated/UpdateTeamInputDTO';
|
||||||
@@ -71,19 +72,19 @@ export class TeamService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get driver's team with view model transformation
|
* Get driver's team with view model transformation
|
||||||
*/
|
*/
|
||||||
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
|
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
|
||||||
const dto: GetDriverTeamOutputDTO | null = await this.apiClient.getDriverTeam(driverId);
|
const dto: GetDriverTeamOutputDTO | null = await this.apiClient.getDriverTeam(driverId);
|
||||||
return dto ? new DriverTeamViewModel(dto) : null;
|
return dto ? new DriverTeamViewModel(dto) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get team membership for a driver
|
* Get team membership for a driver
|
||||||
*/
|
*/
|
||||||
async getMembership(teamId: string, driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
|
async getMembership(teamId: string, driverId: string): Promise<GetTeamMembershipOutputDTO | null> {
|
||||||
return this.apiClient.getMembership(teamId, driverId);
|
return this.apiClient.getMembership(teamId, driverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove a driver from the team
|
* Remove a driver from the team
|
||||||
@@ -91,11 +92,11 @@ export class TeamService {
|
|||||||
* The backend does not yet expose a dedicated endpoint for removing team memberships,
|
* The backend does not yet expose a dedicated endpoint for removing team memberships,
|
||||||
* so this method fails explicitly to avoid silently ignoring removal requests.
|
* so this method fails explicitly to avoid silently ignoring removal requests.
|
||||||
*/
|
*/
|
||||||
async removeMembership(teamId: string, driverId: string): Promise<void> {
|
async removeMembership(teamId: string, driverId: string): Promise<void> {
|
||||||
void teamId;
|
void teamId;
|
||||||
void driverId;
|
void driverId;
|
||||||
throw new Error('Team membership removal is not supported in this build');
|
throw new Error('Team membership removal is not supported in this build');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update team membership role
|
* Update team membership role
|
||||||
@@ -103,10 +104,10 @@ export class TeamService {
|
|||||||
* Role updates for team memberships are not supported by the current API surface;
|
* Role updates for team memberships are not supported by the current API surface;
|
||||||
* callers must treat this as an unavailable operation.
|
* callers must treat this as an unavailable operation.
|
||||||
*/
|
*/
|
||||||
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
|
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
|
||||||
void teamId;
|
void teamId;
|
||||||
void driverId;
|
void driverId;
|
||||||
void role;
|
void role;
|
||||||
throw new Error('Team membership role updates are not supported in this build');
|
throw new Error('Team membership role updates are not supported in this build');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,32 +1,34 @@
|
|||||||
export interface LeagueConfigFormModel {
|
export interface LeagueConfigFormModel {
|
||||||
leagueId?: string;
|
leagueId?: string;
|
||||||
basics?: {
|
basics: {
|
||||||
name: string;
|
name: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
visibility: 'public' | 'private' | 'unlisted';
|
visibility: 'public' | 'private' | 'unlisted';
|
||||||
gameId: string;
|
gameId: string;
|
||||||
};
|
};
|
||||||
structure?: {
|
structure: {
|
||||||
mode: 'solo' | 'fixedTeams';
|
mode: 'solo' | 'fixedTeams';
|
||||||
maxDrivers?: number;
|
maxDrivers?: number;
|
||||||
maxTeams?: number;
|
maxTeams?: number;
|
||||||
driversPerTeam?: number;
|
driversPerTeam?: number;
|
||||||
|
/** Prototype flag kept for backward compatibility with older wizard UI */
|
||||||
|
multiClassEnabled?: boolean;
|
||||||
};
|
};
|
||||||
championships?: {
|
championships: {
|
||||||
enableDriverChampionship: boolean;
|
enableDriverChampionship: boolean;
|
||||||
enableTeamChampionship: boolean;
|
enableTeamChampionship: boolean;
|
||||||
enableNationsChampionship: boolean;
|
enableNationsChampionship: boolean;
|
||||||
enableTrophyChampionship: boolean;
|
enableTrophyChampionship: boolean;
|
||||||
};
|
};
|
||||||
scoring?: {
|
scoring: {
|
||||||
patternId?: string;
|
patternId?: string;
|
||||||
customScoringEnabled?: boolean;
|
customScoringEnabled?: boolean;
|
||||||
};
|
};
|
||||||
dropPolicy?: {
|
dropPolicy: {
|
||||||
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
strategy: 'none' | 'bestNResults' | 'dropWorstN';
|
||||||
n?: number;
|
n?: number;
|
||||||
};
|
};
|
||||||
timings?: {
|
timings: {
|
||||||
practiceMinutes?: number;
|
practiceMinutes?: number;
|
||||||
qualifyingMinutes?: number;
|
qualifyingMinutes?: number;
|
||||||
sprintRaceMinutes?: number;
|
sprintRaceMinutes?: number;
|
||||||
@@ -39,9 +41,24 @@ export interface LeagueConfigFormModel {
|
|||||||
recurrenceStrategy?: string;
|
recurrenceStrategy?: string;
|
||||||
timezoneId?: string;
|
timezoneId?: string;
|
||||||
seasonStartDate?: string;
|
seasonStartDate?: string;
|
||||||
|
/** Prototype fields used by scheduling UI */
|
||||||
|
seasonEndDate?: string;
|
||||||
|
raceStartTime?: string;
|
||||||
};
|
};
|
||||||
stewarding?: {
|
stewarding: {
|
||||||
decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel';
|
/**
|
||||||
|
* Matches API `LeagueConfigFormModelStewardingDTO.decisionMode`.
|
||||||
|
* Website should not depend on core enums.
|
||||||
|
*/
|
||||||
|
decisionMode:
|
||||||
|
| 'single_steward'
|
||||||
|
| 'committee_vote'
|
||||||
|
// legacy wizard values
|
||||||
|
| 'owner_only'
|
||||||
|
| 'admin_vote'
|
||||||
|
| 'steward_panel'
|
||||||
|
| 'admin_only'
|
||||||
|
| 'steward_vote';
|
||||||
requiredVotes?: number;
|
requiredVotes?: number;
|
||||||
requireDefense: boolean;
|
requireDefense: boolean;
|
||||||
defenseTimeLimit: number;
|
defenseTimeLimit: number;
|
||||||
@@ -51,4 +68,4 @@ export interface LeagueConfigFormModel {
|
|||||||
notifyAccusedOnProtest: boolean;
|
notifyAccusedOnProtest: boolean;
|
||||||
notifyOnVoteRequired: boolean;
|
notifyOnVoteRequired: boolean;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,4 +6,8 @@
|
|||||||
|
|
||||||
export interface ActivityItemDTO {
|
export interface ActivityItemDTO {
|
||||||
id: string;
|
id: string;
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
time: string;
|
||||||
|
impressions?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/**
|
||||||
|
* Auto-generated DTO from OpenAPI spec
|
||||||
|
* This file is generated by scripts/generate-api-types.ts
|
||||||
|
* Do not edit manually - regenerate using: npm run api:sync-types
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { LeagueSummaryDTO } from './LeagueSummaryDTO';
|
||||||
|
|
||||||
|
export interface AllLeaguesWithCapacityAndScoringDTO {
|
||||||
|
leagues: LeagueSummaryDTO[];
|
||||||
|
totalCount: number;
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user