refactor page to use services

This commit is contained in:
2025-12-18 15:58:09 +01:00
parent f54fa5de5b
commit fc386db06a
45 changed files with 2254 additions and 1292 deletions

View File

@@ -0,0 +1,71 @@
import { BaseApiClient } from '../base/BaseApiClient';
// DTOs
export type DriverDto = {
id: string;
name: string;
avatarUrl: string;
country: string;
totalRaces: number;
wins: number;
podiums: number;
rating: number;
globalRank: number;
consistency: number;
};
export type RaceDto = {
id: string;
track: string;
car: string;
scheduledAt: string; // ISO date string
isMyLeague: boolean;
leagueName?: string;
};
export type LeagueStandingDto = {
leagueId: string;
leagueName: string;
position: number;
points: number;
totalDrivers: number;
};
export type FeedItemDto = {
id: string;
type: string;
headline: string;
body: string | null;
timestamp: string; // ISO date string
ctaHref?: string;
ctaLabel?: string;
};
export type FriendDto = {
id: string;
name: string;
avatarUrl: string;
country: string;
};
export type DashboardOverviewDto = {
currentDriver: DriverDto;
nextRace: RaceDto | null;
upcomingRaces: RaceDto[];
leagueStandings: LeagueStandingDto[];
feedItems: FeedItemDto[];
friends: FriendDto[];
activeLeaguesCount: number;
};
/**
* Dashboard API Client
*
* Handles dashboard overview data aggregation.
*/
export class DashboardApiClient extends BaseApiClient {
/** Get dashboard overview data */
getDashboardOverview(): Promise<DashboardOverviewDto> {
return this.get<DashboardOverviewDto>('/dashboard/overview');
}
}

View File

@@ -1,6 +1,6 @@
import { BaseApiClient } from '../base/BaseApiClient';
// Import generated types
import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO } from '../../types/generated';
import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO, DriverProfileDTO } from '../../types/generated';
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
@@ -40,4 +40,14 @@ export class DriversApiClient extends BaseApiClient {
getRegistrationStatus(driverId: string, raceId: string): Promise<DriverRegistrationStatusDTO> {
return this.get<DriverRegistrationStatusDTO>(`/drivers/${driverId}/races/${raceId}/registration-status`);
}
/** Get driver by ID */
getDriver(driverId: string): Promise<DriverDTO | null> {
return this.get<DriverDTO | null>(`/drivers/${driverId}`);
}
/** Get driver profile with full details */
getDriverProfile(driverId: string): Promise<DriverProfileDTO> {
return this.get<DriverProfileDTO>(`/drivers/${driverId}/profile`);
}
}

View File

@@ -7,6 +7,7 @@ import { MediaApiClient } from './media/MediaApiClient';
import { AnalyticsApiClient } from './analytics/AnalyticsApiClient';
import { AuthApiClient } from './auth/AuthApiClient';
import { PaymentsApiClient } from './payments/PaymentsApiClient';
import { DashboardApiClient } from './dashboard/DashboardApiClient';
/**
* Main API Client
@@ -23,6 +24,7 @@ export class ApiClient {
public readonly analytics: AnalyticsApiClient;
public readonly auth: AuthApiClient;
public readonly payments: PaymentsApiClient;
public readonly dashboard: DashboardApiClient;
constructor(baseUrl: string) {
this.leagues = new LeaguesApiClient(baseUrl);
@@ -34,6 +36,7 @@ export class ApiClient {
this.analytics = new AnalyticsApiClient(baseUrl);
this.auth = new AuthApiClient(baseUrl);
this.payments = new PaymentsApiClient(baseUrl);
this.dashboard = new DashboardApiClient(baseUrl);
}
}

View File

@@ -49,4 +49,32 @@ export class LeaguesApiClient extends BaseApiClient {
removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> {
return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/remove`, { performerDriverId });
}
/** Get league seasons */
getSeasons(leagueId: string): Promise<{ seasons: Array<{ id: string; status: string }> }> {
return this.get<{ seasons: Array<{ id: string; status: string }> }>(`/leagues/${leagueId}/seasons`);
}
/** Get season sponsorships */
getSeasonSponsorships(seasonId: string): Promise<{ sponsorships: Array<{ sponsorId: string; tier: string; status: string }> }> {
return this.get<{ sponsorships: Array<{ sponsorId: string; tier: string; status: string }> }>(`/seasons/${seasonId}/sponsorships`);
}
/** Get league config */
getLeagueConfig(leagueId: string): Promise<{ config: any }> {
return this.get<{ config: any }>(`/leagues/${leagueId}/config`);
}
/** Get league scoring presets */
getScoringPresets(): Promise<{ presets: any[] }> {
return this.get<{ presets: any[] }>(`/leagues/scoring-presets`);
}
/** Transfer league ownership */
transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<{ success: boolean }> {
return this.post<{ success: boolean }>(`/leagues/${leagueId}/transfer-ownership`, {
currentOwnerId,
newOwnerId,
});
}
}

View File

@@ -1,15 +1,15 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
RequestAvatarGenerationInputDto,
RequestAvatarGenerationOutputDto,
UploadMediaInputDto,
UploadMediaOutputDto,
GetMediaOutputDto,
DeleteMediaOutputDto,
GetAvatarOutputDto,
UpdateAvatarInputDto,
UpdateAvatarOutputDto,
DeleteMediaOutputDto,
GetMediaOutputDto,
RequestAvatarGenerationInputDto,
RequestAvatarGenerationOutputDto,
UpdateAvatarInputDto,
UpdateAvatarOutputDto,
UploadMediaInputDto,
UploadMediaOutputDto,
} from '../../dtos';
import type { GetAvatarOutputDto } from '../../types/GetAvatarOutputDto';
import { BaseApiClient } from '../base/BaseApiClient';
/**
* Media API Client

View File

@@ -0,0 +1,33 @@
import { BaseApiClient } from '../base/BaseApiClient';
import type {
LeagueAdminProtestsDTO,
ApplyPenaltyCommandDTO,
RequestProtestDefenseCommandDTO,
} from '../../types';
/**
* Protests API Client
*
* Handles all protest-related API operations.
*/
export class ProtestsApiClient extends BaseApiClient {
/** Get protests for a league */
getLeagueProtests(leagueId: string): Promise<LeagueAdminProtestsDTO> {
return this.get<LeagueAdminProtestsDTO>(`/leagues/${leagueId}/protests`);
}
/** Get a specific protest for a league */
getLeagueProtest(leagueId: string, protestId: string): Promise<LeagueAdminProtestsDTO> {
return this.get<LeagueAdminProtestsDTO>(`/leagues/${leagueId}/protests/${protestId}`);
}
/** Apply a penalty */
applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> {
return this.post<void>('/races/penalties/apply', input);
}
/** Request protest defense */
requestDefense(input: RequestProtestDefenseCommandDTO): Promise<void> {
return this.post<void>('/races/protests/defense/request', input);
}
}

View File

@@ -39,4 +39,9 @@ export class SponsorsApiClient extends BaseApiClient {
getSponsorships(sponsorId: string): Promise<SponsorSponsorshipsDTO | null> {
return this.get<SponsorSponsorshipsDTO | null>(`/sponsors/${sponsorId}/sponsorships`);
}
/** Get sponsor by ID */
getSponsor(sponsorId: string): Promise<SponsorDTO | null> {
return this.get<SponsorDTO | null>(`/sponsors/${sponsorId}`);
}
}

View File

@@ -1,15 +1,16 @@
import { BaseApiClient } from '../base/BaseApiClient';
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import type {
AllTeamsDto,
TeamDetailsDto,
TeamMembersDto,
TeamJoinRequestsDto,
CreateTeamInputDto,
CreateTeamOutputDto,
DriverTeamDto,
TeamDetailsDto,
TeamJoinRequestsDto,
TeamMembersDto,
UpdateTeamInputDto,
UpdateTeamOutputDto,
DriverTeamDto,
} from '../../dtos';
import { BaseApiClient } from '../base/BaseApiClient';
/**
* Teams API Client
@@ -51,4 +52,9 @@ export class TeamsApiClient extends BaseApiClient {
getDriverTeam(driverId: string): Promise<DriverTeamDto | null> {
return this.get<DriverTeamDto | null>(`/teams/driver/${driverId}`);
}
/** Get membership for a driver in a team */
getMembership(teamId: string, driverId: string): Promise<LeagueMemberDTO | null> {
return this.get<LeagueMemberDTO | null>(`/teams/${teamId}/members/${driverId}`);
}
}

View File

@@ -0,0 +1,58 @@
import { ApplyPenaltyCommandDTO } from '../../types';
export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
export interface ProtestDecisionData {
decision: 'uphold' | 'dismiss' | null;
penaltyType: PenaltyType;
penaltyValue: number;
stewardNotes: string;
}
export class ProtestDecisionCommandModel {
decision: 'uphold' | 'dismiss' | null = null;
penaltyType: PenaltyType = 'time_penalty';
penaltyValue: number = 5;
stewardNotes: string = '';
constructor(initial: Partial<ProtestDecisionData> = {}) {
this.decision = initial.decision ?? null;
this.penaltyType = initial.penaltyType ?? 'time_penalty';
this.penaltyValue = initial.penaltyValue ?? 5;
this.stewardNotes = initial.stewardNotes ?? '';
}
get isValid(): boolean {
return this.decision !== null && this.stewardNotes.trim().length > 0;
}
get canSubmit(): boolean {
return this.isValid;
}
reset(): void {
this.decision = null;
this.penaltyType = 'time_penalty';
this.penaltyValue = 5;
this.stewardNotes = '';
}
toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO {
return {
raceId,
driverId,
stewardId,
type: this.penaltyType,
value: this.getPenaltyValue(),
reason: 'Protest upheld', // TODO: Make this configurable
protestId,
notes: this.stewardNotes,
};
}
private getPenaltyValue(): number {
// Some penalties don't require a value
const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning'];
return penaltiesWithoutValue.includes(this.penaltyType) ? 0 : this.penaltyValue;
}
}

View File

@@ -7,6 +7,8 @@ import { PaymentsApiClient } from '../api/payments/PaymentsApiClient';
import { AuthApiClient } from '../api/auth/AuthApiClient';
import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient';
import { MediaApiClient } from '../api/media/MediaApiClient';
import { DashboardApiClient } from '../api/dashboard/DashboardApiClient';
import { ProtestsApiClient } from '../api/protests/ProtestsApiClient';
import { ConsoleErrorReporter } from '../infrastructure/logging/ConsoleErrorReporter';
import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger';
@@ -19,17 +21,20 @@ import { TeamService } from './teams/TeamService';
import { TeamJoinService } from './teams/TeamJoinService';
import { LeagueService } from './leagues/LeagueService';
import { LeagueMembershipService } from './leagues/LeagueMembershipService';
import { LeagueSettingsService } from './leagues/LeagueSettingsService';
import { SponsorService } from './sponsors/SponsorService';
import { SponsorshipService } from './sponsors/SponsorshipService';
import { PaymentService } from './payments/PaymentService';
import { AnalyticsService } from './analytics/AnalyticsService';
import { DashboardService } from './analytics/DashboardService';
import { DashboardService as AnalyticsDashboardService } from './analytics/DashboardService';
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';
/**
* ServiceFactory - Composition root for all services
@@ -52,6 +57,8 @@ export class ServiceFactory {
auth: AuthApiClient;
analytics: AnalyticsApiClient;
media: MediaApiClient;
dashboard: DashboardApiClient;
protests: ProtestsApiClient;
};
constructor(baseUrl: string) {
@@ -66,6 +73,8 @@ export class ServiceFactory {
auth: new AuthApiClient(baseUrl, this.errorReporter, this.logger),
analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger),
media: new MediaApiClient(baseUrl, this.errorReporter, this.logger),
dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger),
protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger),
};
}
@@ -115,15 +124,22 @@ export class ServiceFactory {
* Create LeagueService instance
*/
createLeagueService(): LeagueService {
return new LeagueService(this.apiClients.leagues);
return new LeagueService(this.apiClients.leagues, this.apiClients.drivers, this.apiClients.sponsors, this.apiClients.races);
}
/**
* Create LeagueMembershipService instance
*/
createLeagueMembershipService(): LeagueMembershipService {
return new LeagueMembershipService(this.apiClients.leagues);
}
* Create LeagueMembershipService instance
*/
createLeagueMembershipService(): LeagueMembershipService {
return new LeagueMembershipService(this.apiClients.leagues);
}
/**
* Create LeagueSettingsService instance
*/
createLeagueSettingsService(): LeagueSettingsService {
return new LeagueSettingsService(this.apiClients.leagues, this.apiClients.drivers);
}
/**
* Create SponsorService instance
@@ -154,11 +170,18 @@ export class ServiceFactory {
}
/**
* Create DashboardService instance
*/
createDashboardService(): DashboardService {
return new DashboardService(this.apiClients.analytics);
}
* Create Analytics DashboardService instance
*/
createAnalyticsDashboardService(): AnalyticsDashboardService {
return new AnalyticsDashboardService(this.apiClients.analytics);
}
/**
* Create DashboardService instance
*/
createDashboardService(): DashboardService {
return new DashboardService(this.apiClients.dashboard);
}
/**
* Create MediaService instance
@@ -201,4 +224,11 @@ export class ServiceFactory {
createSessionService(): SessionService {
return new SessionService(this.apiClients.auth);
}
/**
* Create ProtestService instance
*/
createProtestService(): ProtestService {
return new ProtestService(this.apiClients.protests);
}
}

View File

@@ -0,0 +1,22 @@
import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel';
import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient';
/**
* Dashboard Service
*
* Orchestrates dashboard operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class DashboardService {
constructor(
private readonly apiClient: DashboardApiClient
) {}
/**
* Get dashboard overview data with view model transformation
*/
async getDashboardOverview(): Promise<DashboardOverviewViewModel> {
const dto = await this.apiClient.getDashboardOverview();
return new DashboardOverviewViewModel(dto);
}
}

View File

@@ -1,8 +1,10 @@
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO";
import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO";
import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel";
import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel";
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { DriverProfileViewModel } from "@/lib/view-models/DriverProfileViewModel";
// TODO: Create proper DriverDTO in generated types
type DriverDTO = {
@@ -41,8 +43,8 @@ export class DriverService {
}
/**
* Get current driver with view model transformation
*/
* Get current driver with view model transformation
*/
async getCurrentDriver(): Promise<DriverViewModel | null> {
const dto = await this.apiClient.getCurrent();
if (!dto) {
@@ -50,4 +52,12 @@ export class DriverService {
}
return new DriverViewModel(dto);
}
/**
* Get driver profile with full details and view model transformation
*/
async getDriverProfile(driverId: string): Promise<DriverProfileViewModel> {
const dto = await this.apiClient.getDriverProfile(driverId);
return new DriverProfileViewModel(dto);
}
}

View File

@@ -1,4 +1,7 @@
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO";
import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
@@ -8,7 +11,14 @@ import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewM
import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel";
import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel";
import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel";
import { LeagueDetailViewModel } from "@/lib/view-models/LeagueDetailViewModel";
import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel";
import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
import { DriverDTO } from "@/lib/types/DriverDTO";
import { RaceDTO } from "@/lib/types/generated/RaceDTO";
import { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
import { LeagueMembershipsDTO } from "@/lib/types/generated/LeagueMembershipsDTO";
/**
@@ -22,7 +32,10 @@ export class LeagueService {
private readonly throttle = new ThrottleBlocker(500);
constructor(
private readonly apiClient: LeaguesApiClient
private readonly apiClient: LeaguesApiClient,
private readonly driversApiClient: DriversApiClient,
private readonly sponsorsApiClient: SponsorsApiClient,
private readonly racesApiClient: RacesApiClient
) {}
/**
@@ -93,4 +106,156 @@ export class LeagueService {
const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId);
return new RemoveMemberViewModel(dto);
}
/**
* Get league detail with owner, membership, and sponsor info
*/
async getLeagueDetail(leagueId: string, currentDriverId: string): Promise<LeagueDetailViewModel | null> {
// For now, assume league data comes from getAllWithCapacity or a new endpoint
// Since API may not have detailed league, we'll mock or assume
// In real implementation, add getLeagueDetail to API
const allLeagues = await this.apiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null;
// Assume league has description, ownerId - need to update DTO
const league = {
id: leagueDto.id,
name: leagueDto.name,
description: 'Description not available', // TODO: add to API
ownerId: 'owner-id', // TODO: add to API
};
// Get owner
const owner = await this.driversApiClient.getDriver(league.ownerId);
const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`;
// Get membership
const membershipsDto = await this.apiClient.getMemberships(leagueId);
const membership = membershipsDto.members.find(m => m.driverId === currentDriverId);
const isAdmin = membership ? ['admin', 'owner'].includes(membership.role) : false;
// Get main sponsor
let mainSponsor = null;
try {
const seasonsDto = await this.apiClient.getSeasons(leagueId);
const activeSeason = seasonsDto.seasons.find((s: any) => s.status === 'active') ?? seasonsDto.seasons[0];
if (activeSeason) {
const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.id);
const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active');
if (mainSponsorship) {
const sponsor = await this.sponsorsApiClient.getSponsor(mainSponsorship.sponsorId);
if (sponsor) {
mainSponsor = {
name: sponsor.name,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
};
}
}
}
} catch (error) {
console.warn('Failed to load main sponsor:', error);
}
return new LeagueDetailViewModel(
league.id,
league.name,
league.description,
league.ownerId,
ownerName,
mainSponsor,
isAdmin
);
}
/**
* Get comprehensive league detail page data
*/
async getLeagueDetailPageData(leagueId: string): Promise<LeagueDetailPageViewModel | null> {
try {
// Get league basic info
const allLeagues = await this.apiClient.getAllWithCapacity();
const league = allLeagues.leagues.find(l => l.id === leagueId);
if (!league) return null;
// Get owner
const owner = await this.driversApiClient.getDriver(league.ownerId);
// Get scoring config - TODO: implement API endpoint
const scoringConfig: LeagueScoringConfigDTO | null = null; // TODO: fetch from API
// Get all drivers - TODO: implement API endpoint for all drivers
const drivers: DriverDTO[] = []; // TODO: fetch from API
// Get memberships
const memberships = await this.apiClient.getMemberships(leagueId);
// Get all races for this league - TODO: implement API endpoint
const allRaces: RaceDTO[] = []; // TODO: fetch from API
// Get league stats
const leagueStats = await this.apiClient.getTotal(); // TODO: get stats for specific league
// Get sponsors
const sponsors = await this.getLeagueSponsors(leagueId);
return new LeagueDetailPageViewModel(
league,
owner,
scoringConfig,
drivers,
memberships,
allRaces,
leagueStats,
sponsors
);
} catch (error) {
console.error('Failed to load league detail page data:', error);
return null;
}
}
/**
* Get sponsors for a league
*/
private async getLeagueSponsors(leagueId: string): Promise<SponsorInfo[]> {
try {
const seasons = await this.apiClient.getSeasons(leagueId);
const activeSeason = seasons.seasons.find((s: any) => s.status === 'active') ?? seasons.seasons[0];
if (!activeSeason) return [];
const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.id);
const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active');
const sponsorInfos: SponsorInfo[] = [];
for (const sponsorship of activeSponsorships) {
const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId);
if (sponsor) {
// TODO: Get tagline from testing support or API
sponsorInfos.push({
id: sponsor.id,
name: sponsor.name,
logoUrl: sponsor.logoUrl ?? '',
websiteUrl: sponsor.websiteUrl ?? '',
tier: sponsorship.tier,
tagline: '', // TODO: fetch tagline
});
}
}
// Sort: main sponsors first, then secondary
sponsorInfos.sort((a, b) => {
if (a.tier === 'main' && b.tier !== 'main') return -1;
if (a.tier !== 'main' && b.tier === 'main') return 1;
return 0;
});
return sponsorInfos;
} catch (error) {
console.warn('Failed to load sponsors:', error);
return [];
}
}
}

View File

@@ -0,0 +1,95 @@
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel";
import type { LeagueScoringPresetDTO } from "@/lib/types/LeagueScoringPresetDTO";
import type { DriverDTO } from "@/lib/types/DriverDTO";
import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel";
import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel";
/**
* League Settings Service
*
* Orchestrates league settings operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class LeagueSettingsService {
constructor(
private readonly leaguesApiClient: LeaguesApiClient,
private readonly driversApiClient: DriversApiClient
) {}
/**
* Get league settings with view model transformation
*/
async getLeagueSettings(leagueId: string): Promise<LeagueSettingsViewModel | null> {
try {
// Get league basic info
const allLeagues = await this.leaguesApiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
if (!leagueDto) return null;
// Assume league has ownerId - need to update API
const league = {
id: leagueDto.id,
name: leagueDto.name,
ownerId: 'owner-id', // TODO: add to API
};
// Get config
const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId);
const config: LeagueConfigFormModel = configDto.config;
// Get presets
const presetsDto = await this.leaguesApiClient.getScoringPresets();
const presets: LeagueScoringPresetDTO[] = presetsDto.presets;
// Get owner
const ownerDriver = await this.driversApiClient.getDriver(league.ownerId);
let owner: DriverSummaryViewModel | null = null;
if (ownerDriver) {
// TODO: get rating and rank from API
owner = new DriverSummaryViewModel({
driver: ownerDriver,
rating: ownerDriver.rating ?? null,
rank: null, // TODO: get from API
});
}
// Get members
const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId);
const members: DriverDTO[] = [];
for (const member of membershipsDto.members) {
if (member.driverId !== league.ownerId && member.role !== 'owner') {
const driver = await this.driversApiClient.getDriver(member.driverId);
if (driver) {
members.push(driver);
}
}
}
return new LeagueSettingsViewModel({
league,
config,
presets,
owner,
members,
});
} catch (error) {
console.error('Failed to load league settings:', error);
return null;
}
}
/**
* Transfer league ownership
*/
async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<boolean> {
try {
const result = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId);
return result.success;
} catch (error) {
console.error('Failed to transfer ownership:', error);
throw error;
}
}
}

View File

@@ -34,10 +34,17 @@ export class MediaService {
}
/**
* Delete media by ID with view model transformation
*/
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
const dto = await this.apiClient.deleteMedia(mediaId);
return new DeleteMediaViewModel(dto);
}
}
* Delete media by ID with view model transformation
*/
async deleteMedia(mediaId: string): Promise<DeleteMediaViewModel> {
const dto = await this.apiClient.deleteMedia(mediaId);
return new DeleteMediaViewModel(dto);
}
/**
* Get team logo URL
*/
getTeamLogo(teamId: string): string {
return `/api/media/teams/${teamId}/logo`;
}
}

View File

@@ -0,0 +1,70 @@
import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient';
import { ProtestViewModel } from '../../view-models/ProtestViewModel';
import type { LeagueAdminProtestsDTO, ApplyPenaltyCommandDTO, RequestProtestDefenseCommandDTO, DriverSummaryDTO } from '../../types';
/**
* Protest Service
*
* Orchestrates protest operations by coordinating API calls and view model creation.
* All dependencies are injected via constructor.
*/
export class ProtestService {
constructor(
private readonly apiClient: ProtestsApiClient
) {}
/**
* Get protests for a league with view model transformation
*/
async getLeagueProtests(leagueId: string): Promise<{
protests: ProtestViewModel[];
racesById: LeagueAdminProtestsDTO['racesById'];
driversById: LeagueAdminProtestsDTO['driversById'];
}> {
const dto = await this.apiClient.getLeagueProtests(leagueId);
return {
protests: dto.protests.map(protest => new ProtestViewModel(protest)),
racesById: dto.racesById,
driversById: dto.driversById,
};
}
/**
* Get a single protest by ID from league protests
*/
async getProtestById(leagueId: string, protestId: string): Promise<{
protest: ProtestViewModel;
race: LeagueAdminProtestsDTO['racesById'][string];
protestingDriver: DriverSummaryDTO;
accusedDriver: DriverSummaryDTO;
} | null> {
const dto = await this.apiClient.getLeagueProtest(leagueId, protestId);
const protest = dto.protests[0];
if (!protest) return null;
const race = Object.values(dto.racesById)[0];
const protestingDriver = dto.driversById[protest.protestingDriverId];
const accusedDriver = dto.driversById[protest.accusedDriverId];
return {
protest: new ProtestViewModel(protest),
race,
protestingDriver,
accusedDriver,
};
}
/**
* Apply a penalty
*/
async applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> {
await this.apiClient.applyPenalty(input);
}
/**
* Request protest defense
*/
async requestDefense(input: RequestProtestDefenseCommandDTO): Promise<void> {
await this.apiClient.requestDefense(input);
}
}

View File

@@ -54,6 +54,34 @@ export class RaceService {
return new RaceStatsViewModel(dto);
}
/**
* Register for a race
*/
async registerForRace(raceId: string, leagueId: string, driverId: string): Promise<void> {
await this.apiClient.register(raceId, { leagueId, driverId });
}
/**
* Withdraw from a race
*/
async withdrawFromRace(raceId: string, driverId: string): Promise<void> {
await this.apiClient.withdraw(raceId, { driverId });
}
/**
* Cancel a race
*/
async cancelRace(raceId: string): Promise<void> {
await this.apiClient.cancel(raceId);
}
/**
* Complete a race
*/
async completeRace(raceId: string): Promise<void> {
await this.apiClient.complete(raceId);
}
/**
* Transform API races page data to view model format
*/

View File

@@ -4,6 +4,7 @@ import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel';
import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel';
import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel';
import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO';
import type { TeamsApiClient } from '../../api/teams/TeamsApiClient';
// TODO: Move these types to apps/website/lib/types/generated when available
@@ -71,10 +72,33 @@ export class TeamService {
}
/**
* Get driver's team with view model transformation
*/
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
const dto = await this.apiClient.getDriverTeam(driverId);
return dto ? new DriverTeamViewModel(dto) : null;
}
}
* Get driver's team with view model transformation
*/
async getDriverTeam(driverId: string): Promise<DriverTeamViewModel | null> {
const dto = await this.apiClient.getDriverTeam(driverId);
return dto ? new DriverTeamViewModel(dto) : null;
}
/**
* Get team membership for a driver
*/
async getMembership(teamId: string, driverId: string): Promise<LeagueMemberDTO | null> {
return this.apiClient.getMembership(teamId, driverId);
}
/**
* Remove a driver from the team
*/
async removeMembership(teamId: string, driverId: string): Promise<void> {
// TODO: Implement when API endpoint is available
throw new Error('Not implemented: API endpoint for removing team membership');
}
/**
* Update team membership role
*/
async updateMembership(teamId: string, driverId: string, role: string): Promise<void> {
// TODO: Implement when API endpoint is available
throw new Error('Not implemented: API endpoint for updating team membership role');
}
}

View File

@@ -0,0 +1,13 @@
/**
* 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
*/
export interface DriverDTO {
id: string;
name: string;
avatarUrl?: string;
iracingId?: string;
rating?: number;
}

View File

@@ -0,0 +1,9 @@
/**
* 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
*/
export interface GetAvatarOutputDto {
avatarUrl: string;
}

View File

@@ -0,0 +1,15 @@
export type LeagueScoringPresetPrimaryChampionshipType =
| 'driver'
| 'team'
| 'nations'
| 'trophy';
export interface LeagueScoringPresetDTO {
id: string;
name: string;
description: string;
primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType;
sessionSummary: string;
bonusSummary: string;
dropPolicySummary: string;
}

View File

@@ -0,0 +1,14 @@
/**
* 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
*/
export interface RaceDetailEntryDTO {
id: string;
name: string;
country: string;
avatarUrl: string;
rating: number | null;
isCurrentUser: boolean;
}

View File

@@ -8,5 +8,9 @@ export interface ApplyPenaltyCommandDTO {
raceId: string;
driverId: string;
stewardId: string;
enum: string;
type: string;
value?: number;
reason: string;
protestId?: string;
notes?: string;
}

View File

@@ -0,0 +1,100 @@
export interface DriverProfileDriverSummaryDTO {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface DriverProfileStatsDTO {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface DriverProfileFinishDistributionDTO {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface DriverProfileTeamMembershipDTO {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
export interface DriverProfileSocialFriendSummaryDTO {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface DriverProfileSocialSummaryDTO {
friendsCount: number;
friends: DriverProfileSocialFriendSummaryDTO[];
}
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface DriverProfileAchievementDTO {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: DriverProfileAchievementRarity;
earnedAt: string;
}
export interface DriverProfileSocialHandleDTO {
platform: DriverProfileSocialPlatform;
handle: string;
url: string;
}
export interface DriverProfileExtendedProfileDTO {
socialHandles: DriverProfileSocialHandleDTO[];
achievements: DriverProfileAchievementDTO[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export interface DriverProfileDTO {
currentDriver: DriverProfileDriverSummaryDTO | null;
stats: DriverProfileStatsDTO | null;
finishDistribution: DriverProfileFinishDistributionDTO | null;
teamMemberships: DriverProfileTeamMembershipDTO[];
socialSummary: DriverProfileSocialSummaryDTO;
extendedProfile: DriverProfileExtendedProfileDTO | null;
}

View File

@@ -0,0 +1,19 @@
/**
* 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 { ProtestDTO } from './ProtestDTO';
import { RaceDTO } from './RaceDTO';
export interface DriverSummaryDTO {
id: string;
name: string;
}
export interface LeagueAdminProtestsDTO {
protests: ProtestDTO[];
racesById: { [raceId: string]: RaceDTO };
driversById: { [driverId: string]: DriverSummaryDTO };
}

View File

@@ -0,0 +1,181 @@
import { DashboardOverviewDto, DriverDto, RaceDto, LeagueStandingDto, FeedItemDto, FriendDto } from '../api/dashboard/DashboardApiClient';
export class DriverViewModel {
constructor(private readonly dto: DriverDto) {}
get id(): string {
return this.dto.id;
}
get name(): string {
return this.dto.name;
}
get avatarUrl(): string {
return this.dto.avatarUrl;
}
get country(): string {
return this.dto.country;
}
get totalRaces(): number {
return this.dto.totalRaces;
}
get wins(): number {
return this.dto.wins;
}
get podiums(): number {
return this.dto.podiums;
}
get rating(): number {
return this.dto.rating;
}
get globalRank(): number {
return this.dto.globalRank;
}
get consistency(): number {
return this.dto.consistency;
}
}
export class RaceViewModel {
constructor(private readonly dto: RaceDto) {}
get id(): string {
return this.dto.id;
}
get track(): string {
return this.dto.track;
}
get car(): string {
return this.dto.car;
}
get scheduledAt(): Date {
return new Date(this.dto.scheduledAt);
}
get isMyLeague(): boolean {
return this.dto.isMyLeague;
}
get leagueName(): string | undefined {
return this.dto.leagueName;
}
}
export class LeagueStandingViewModel {
constructor(private readonly dto: LeagueStandingDto) {}
get leagueId(): string {
return this.dto.leagueId;
}
get leagueName(): string {
return this.dto.leagueName;
}
get position(): number {
return this.dto.position;
}
get points(): number {
return this.dto.points;
}
get totalDrivers(): number {
return this.dto.totalDrivers;
}
}
export class DashboardFeedItemSummaryViewModel {
constructor(private readonly dto: FeedItemDto) {}
get id(): string {
return this.dto.id;
}
get type(): string {
return this.dto.type;
}
get headline(): string {
return this.dto.headline;
}
get body(): string | null {
return this.dto.body;
}
get timestamp(): Date {
return new Date(this.dto.timestamp);
}
get ctaHref(): string | undefined {
return this.dto.ctaHref;
}
get ctaLabel(): string | undefined {
return this.dto.ctaLabel;
}
}
export class FriendViewModel {
constructor(private readonly dto: FriendDto) {}
get id(): string {
return this.dto.id;
}
get name(): string {
return this.dto.name;
}
get avatarUrl(): string {
return this.dto.avatarUrl;
}
get country(): string {
return this.dto.country;
}
}
export class DashboardOverviewViewModel {
constructor(private readonly dto: DashboardOverviewDto) {}
get currentDriver(): DriverViewModel {
return new DriverViewModel(this.dto.currentDriver);
}
get nextRace(): RaceViewModel | null {
return this.dto.nextRace ? new RaceViewModel(this.dto.nextRace) : null;
}
get upcomingRaces(): RaceViewModel[] {
return this.dto.upcomingRaces.map(dto => new RaceViewModel(dto));
}
get leagueStandings(): LeagueStandingViewModel[] {
return this.dto.leagueStandings.map(dto => new LeagueStandingViewModel(dto));
}
get feedItems(): DashboardFeedItemSummaryViewModel[] {
return this.dto.feedItems.map(dto => new DashboardFeedItemSummaryViewModel(dto));
}
get friends(): FriendViewModel[] {
return this.dto.friends.map(dto => new FriendViewModel(dto));
}
get activeLeaguesCount(): number {
return this.dto.activeLeaguesCount;
}
}

View File

@@ -0,0 +1,141 @@
export interface DriverProfileDriverSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
iracingId: string | null;
joinedAt: string;
rating: number | null;
globalRank: number | null;
consistency: number | null;
bio: string | null;
totalDrivers: number | null;
}
export interface DriverProfileStatsViewModel {
totalRaces: number;
wins: number;
podiums: number;
dnfs: number;
avgFinish: number | null;
bestFinish: number | null;
worstFinish: number | null;
finishRate: number | null;
winRate: number | null;
podiumRate: number | null;
percentile: number | null;
rating: number | null;
consistency: number | null;
overallRank: number | null;
}
export interface DriverProfileFinishDistributionViewModel {
totalRaces: number;
wins: number;
podiums: number;
topTen: number;
dnfs: number;
other: number;
}
export interface DriverProfileTeamMembershipViewModel {
teamId: string;
teamName: string;
teamTag: string | null;
role: string;
joinedAt: string;
isCurrent: boolean;
}
export interface DriverProfileSocialFriendSummaryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
}
export interface DriverProfileSocialSummaryViewModel {
friendsCount: number;
friends: DriverProfileSocialFriendSummaryViewModel[];
}
export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord';
export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary';
export interface DriverProfileAchievementViewModel {
id: string;
title: string;
description: string;
icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap';
rarity: DriverProfileAchievementRarity;
earnedAt: string;
}
export interface DriverProfileSocialHandleViewModel {
platform: DriverProfileSocialPlatform;
handle: string;
url: string;
}
export interface DriverProfileExtendedProfileViewModel {
socialHandles: DriverProfileSocialHandleViewModel[];
achievements: DriverProfileAchievementViewModel[];
racingStyle: string;
favoriteTrack: string;
favoriteCar: string;
timezone: string;
availableHours: string;
lookingForTeam: boolean;
openToRequests: boolean;
}
export interface DriverProfileViewModel {
currentDriver: DriverProfileDriverSummaryViewModel | null;
stats: DriverProfileStatsViewModel | null;
finishDistribution: DriverProfileFinishDistributionViewModel | null;
teamMemberships: DriverProfileTeamMembershipViewModel[];
socialSummary: DriverProfileSocialSummaryViewModel;
extendedProfile: DriverProfileExtendedProfileViewModel | null;
}
/**
* Driver Profile View Model
*
* Represents a fully prepared UI state for driver profile display.
* Transforms API DTOs into UI-ready data structures.
*/
export class DriverProfileViewModel {
constructor(private readonly dto: DriverProfileViewModel) {}
get currentDriver(): DriverProfileDriverSummaryViewModel | null {
return this.dto.currentDriver;
}
get stats(): DriverProfileStatsViewModel | null {
return this.dto.stats;
}
get finishDistribution(): DriverProfileFinishDistributionViewModel | null {
return this.dto.finishDistribution;
}
get teamMemberships(): DriverProfileTeamMembershipViewModel[] {
return this.dto.teamMemberships;
}
get socialSummary(): DriverProfileSocialSummaryViewModel {
return this.dto.socialSummary;
}
get extendedProfile(): DriverProfileExtendedProfileViewModel | null {
return this.dto.extendedProfile;
}
/**
* Get the raw DTO for serialization or further processing
*/
toDTO(): DriverProfileViewModel {
return this.dto;
}
}

View File

@@ -0,0 +1,21 @@
import type { DriverDTO } from '../types/DriverDTO';
/**
* View Model for driver summary with rating and rank
* Transform from DTO to ViewModel with UI fields
*/
export class DriverSummaryViewModel {
driver: DriverDTO;
rating: number | null;
rank: number | null;
constructor(dto: {
driver: DriverDTO;
rating?: number | null;
rank?: number | null;
}) {
this.driver = dto.driver;
this.rating = dto.rating ?? null;
this.rank = dto.rank ?? null;
}
}

View File

@@ -0,0 +1,192 @@
import { LeagueWithCapacityDTO } from '../types/generated/LeagueWithCapacityDTO';
import { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO';
import { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO';
import { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO';
import { LeagueStandingsDTO } from '../types/generated/LeagueStandingsDTO';
import { DriverDTO } from '../types/DriverDTO';
import { RaceDTO } from '../types/generated/RaceDTO';
import { LeagueScoringConfigDTO } from '../types/LeagueScoringConfigDTO';
// Sponsor info type
export interface SponsorInfo {
id: string;
name: string;
logoUrl?: string;
websiteUrl?: string;
tier: 'main' | 'secondary';
tagline?: string;
}
// Driver summary for management section
export interface DriverSummary {
driver: DriverDTO;
rating: number | null;
rank: number | null;
}
// League membership with role
export interface LeagueMembershipWithRole {
driverId: string;
role: 'owner' | 'admin' | 'steward' | 'member';
status: 'active' | 'inactive';
joinedAt: string;
}
export class LeagueDetailPageViewModel {
// League basic info
id: string;
name: string;
description?: string;
ownerId: string;
createdAt: string;
settings: {
maxDrivers?: number;
};
socialLinks?: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
// Owner info
owner: DriverDTO | null;
// Scoring configuration
scoringConfig: LeagueScoringConfigDTO | null;
// Drivers and memberships
drivers: DriverDTO[];
memberships: LeagueMembershipWithRole[];
// Races
allRaces: RaceDTO[];
runningRaces: RaceDTO[];
// Stats
averageSOF: number | null;
completedRacesCount: number;
// Sponsors
sponsors: SponsorInfo[];
// Sponsor insights data
sponsorInsights: {
avgViewsPerRace: number;
totalImpressions: number;
engagementRate: string;
estimatedReach: number;
mainSponsorAvailable: boolean;
secondarySlotsAvailable: number;
mainSponsorPrice: number;
secondaryPrice: number;
tier: 'premium' | 'standard' | 'starter';
trustScore: number;
discordMembers: number;
monthlyActivity: number;
};
// Driver summaries for management
ownerSummary: DriverSummary | null;
adminSummaries: DriverSummary[];
stewardSummaries: DriverSummary[];
constructor(
league: LeagueWithCapacityDTO,
owner: DriverDTO | null,
scoringConfig: LeagueScoringConfigDTO | null,
drivers: DriverDTO[],
memberships: LeagueMembershipsDTO,
allRaces: RaceDTO[],
leagueStats: LeagueStatsDTO,
sponsors: SponsorInfo[]
) {
this.id = league.id;
this.name = league.name;
this.description = league.description;
this.ownerId = league.ownerId;
this.createdAt = league.createdAt;
this.settings = {
maxDrivers: league.maxDrivers,
};
this.socialLinks = league.socialLinks;
this.owner = owner;
this.scoringConfig = scoringConfig;
this.drivers = drivers;
this.memberships = memberships.memberships.map(m => ({
driverId: m.driverId,
role: m.role,
status: m.status,
joinedAt: m.joinedAt,
}));
this.allRaces = allRaces;
this.runningRaces = allRaces.filter(r => r.status === 'running');
this.averageSOF = leagueStats.averageSOF ?? null;
this.completedRacesCount = leagueStats.completedRaces ?? 0;
this.sponsors = sponsors;
// Calculate sponsor insights
const memberCount = this.memberships.length;
const mainSponsorTaken = this.sponsors.some(s => s.tier === 'main');
const secondaryTaken = this.sponsors.filter(s => s.tier === 'secondary').length;
this.sponsorInsights = {
avgViewsPerRace: 5400 + memberCount * 50,
totalImpressions: 45000 + memberCount * 500,
engagementRate: (3.5 + (memberCount / 50)).toFixed(1),
estimatedReach: memberCount * 150,
mainSponsorAvailable: !mainSponsorTaken,
secondarySlotsAvailable: Math.max(0, 2 - secondaryTaken),
mainSponsorPrice: 800 + Math.floor(memberCount * 10),
secondaryPrice: 250 + Math.floor(memberCount * 3),
tier: (this.averageSOF && this.averageSOF > 3000 ? 'premium' : this.averageSOF && this.averageSOF > 2000 ? 'standard' : 'starter') as 'premium' | 'standard' | 'starter',
trustScore: Math.min(100, 60 + memberCount + this.completedRacesCount),
discordMembers: memberCount * 3,
monthlyActivity: Math.min(100, 40 + this.completedRacesCount * 2),
};
// Build driver summaries
this.ownerSummary = this.buildDriverSummary(this.ownerId);
this.adminSummaries = this.memberships
.filter(m => m.role === 'admin')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
this.stewardSummaries = this.memberships
.filter(m => m.role === 'steward')
.slice(0, 3)
.map(m => this.buildDriverSummary(m.driverId))
.filter((s): s is DriverSummary => s !== null);
}
private buildDriverSummary(driverId: string): DriverSummary | null {
const driver = this.drivers.find(d => d.id === driverId);
if (!driver) return null;
// TODO: Get driver stats and rankings from service
// For now, return basic info
return {
driver,
rating: null, // TODO: fetch from service
rank: null, // TODO: fetch from service
};
}
// UI helper methods
get isSponsorMode(): boolean {
// TODO: implement sponsor mode check
return false;
}
get currentUserMembership(): LeagueMembershipWithRole | null {
// TODO: get current user ID and find membership
return null;
}
get canEndRaces(): boolean {
return this.currentUserMembership?.role === 'admin' || this.currentUserMembership?.role === 'owner';
}
}

View File

@@ -0,0 +1,35 @@
export interface MainSponsorInfo {
name: string;
logoUrl: string;
websiteUrl: string;
}
export class LeagueDetailViewModel {
id: string;
name: string;
description: string;
ownerId: string;
ownerName: string;
mainSponsor: MainSponsorInfo | null;
isAdmin: boolean;
constructor(
id: string,
name: string,
description: string,
ownerId: string,
ownerName: string,
mainSponsor: MainSponsorInfo | null,
isAdmin: boolean
) {
this.id = id;
this.name = name;
this.description = description;
this.ownerId = ownerId;
this.ownerName = ownerName;
this.mainSponsor = mainSponsor;
this.isAdmin = isAdmin;
}
// UI-specific getters can be added here if needed
}

View File

@@ -0,0 +1,18 @@
import type { LeagueScoringPresetDTO } from '../types/LeagueScoringPresetDTO';
/**
* View Model for league scoring presets
* Transform from DTO to ViewModel with UI fields
*/
export class LeagueScoringPresetsViewModel {
presets: LeagueScoringPresetDTO[];
totalCount: number;
constructor(dto: {
presets: LeagueScoringPresetDTO[];
totalCount?: number;
}) {
this.presets = dto.presets;
this.totalCount = dto.totalCount ?? dto.presets.length;
}
}

View File

@@ -0,0 +1,39 @@
import type { LeagueConfigFormModel } from '../types/LeagueConfigFormModel';
import type { LeagueScoringPresetDTO } from '../types/LeagueScoringPresetDTO';
import type { DriverDTO } from '../types/DriverDTO';
import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel';
import { DriverSummaryViewModel } from './DriverSummaryViewModel';
/**
* View Model for league settings page
* Combines league config, presets, owner, and members
*/
export class LeagueSettingsViewModel {
league: {
id: string;
name: string;
ownerId: string;
};
config: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
owner: DriverSummaryViewModel | null;
members: DriverDTO[];
constructor(dto: {
league: {
id: string;
name: string;
ownerId: string;
};
config: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[];
owner: DriverSummaryViewModel | null;
members: DriverDTO[];
}) {
this.league = dto.league;
this.config = dto.config;
this.presets = dto.presets;
this.owner = dto.owner;
this.members = dto.members;
}
}

View File

@@ -7,29 +7,27 @@ import { ProtestDTO } from '../types/generated/ProtestDTO';
export class ProtestViewModel {
id: string;
raceId: string;
complainantId: string;
defendantId: string;
protestingDriverId: string;
accusedDriverId: string;
description: string;
status: string;
createdAt: string;
submittedAt: string;
constructor(dto: ProtestDTO) {
this.id = dto.id;
this.raceId = dto.raceId;
this.complainantId = dto.complainantId;
this.defendantId = dto.defendantId;
this.protestingDriverId = dto.protestingDriverId;
this.accusedDriverId = dto.accusedDriverId;
this.description = dto.description;
this.status = dto.status;
this.createdAt = dto.createdAt;
this.submittedAt = dto.submittedAt;
}
/** UI-specific: Formatted created date */
get formattedCreatedAt(): string {
return new Date(this.createdAt).toLocaleString();
/** UI-specific: Formatted submitted date */
get formattedSubmittedAt(): string {
return new Date(this.submittedAt).toLocaleString();
}
/** UI-specific: Status display */
/** UI-specific: Status display - placeholder since status not in current DTO */
get statusDisplay(): string {
return this.status.charAt(0).toUpperCase() + this.status.slice(1);
return 'Pending'; // TODO: Update when status is added to DTO
}
}

View File

@@ -1,10 +1,10 @@
import { describe, it, expect } from 'vitest';
import { RaceDetailViewModel } from './RaceDetailViewModel';
import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import { describe, expect, it } from 'vitest';
import type { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
import type { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import type { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
import type { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
import type { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO';
import { RaceDetailViewModel } from './RaceDetailViewModel';
describe('RaceDetailViewModel', () => {
const createMockRace = (overrides?: Partial<RaceDetailRaceDTO>): RaceDetailRaceDTO => ({

View File

@@ -1,8 +1,8 @@
import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
import { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO';
export class RaceDetailViewModel {
race: RaceDetailRaceDTO | null;