website cleanup

This commit is contained in:
2025-12-24 21:44:58 +01:00
parent 9b683a59d3
commit d78854a4c6
277 changed files with 6141 additions and 2693 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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);
} }

View File

@@ -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',
},
};
}
} }

View File

@@ -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()

View File

@@ -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;
} }

View File

@@ -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;
} }
} }

View File

@@ -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;
} }

View 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;
}

View File

@@ -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;

View File

@@ -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;
} }
} }

View File

@@ -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';
} }

View File

@@ -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 = '';
} }

View File

@@ -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 {

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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;
} }

View File

@@ -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[];
} }

View File

@@ -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;
}

View 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;
}

View 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[];
}

View 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[];
}

View 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;
}

View 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;
}

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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);

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
</> </>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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({
)} )}
</> </>
); );
} }

View File

@@ -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

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View 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;
}

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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>
); );
} }

View File

@@ -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,
}); });
} }

View File

@@ -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'] });
}, },
}); });
} }

View File

@@ -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()}`);
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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');
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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`);
} }
} }

View File

@@ -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 });
} }
} }

View File

@@ -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);
} }
} }

View File

@@ -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`);
} }
} }

View File

@@ -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);
}
}

View File

@@ -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;
} }

View File

@@ -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,
}; };
} }
} }

View File

@@ -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);
}
}

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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);
} }
} }

View File

@@ -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,

View File

@@ -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);
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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;
} }
} }
} }

View File

@@ -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;
} }
} }

View File

@@ -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 } : {}) });
} }
} }

View File

@@ -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');
} }
} }

View File

@@ -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;
}; };
} }

View File

@@ -6,4 +6,8 @@
export interface ActivityItemDTO { export interface ActivityItemDTO {
id: string; id: string;
type: string;
message: string;
time: string;
impressions?: number;
} }

View File

@@ -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