move static data

This commit is contained in:
2025-12-26 00:20:53 +01:00
parent c977defd6a
commit b6cbb81388
63 changed files with 1482 additions and 418 deletions

View File

@@ -1,6 +1,7 @@
import { BaseApiClient } from '../base/BaseApiClient';
import { RacePenaltiesDTO } from '../../types/generated/RacePenaltiesDTO';
import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO';
/**
* Penalties API Client
@@ -13,6 +14,11 @@ export class PenaltiesApiClient extends BaseApiClient {
return this.get<RacePenaltiesDTO>(`/races/${raceId}/penalties`);
}
/** Get allowed penalty types and semantics */
getPenaltyTypesReference(): Promise<PenaltyTypesReferenceDTO> {
return this.get<PenaltyTypesReferenceDTO>('/races/reference/penalty-types');
}
/** Apply a penalty */
applyPenalty(input: ApplyPenaltyCommandDTO): Promise<void> {
return this.post<void>('/races/penalties/apply', input);

View File

@@ -1,6 +1,5 @@
import type { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { LeagueWizardValidationMessages } from '@/lib/display-objects/LeagueWizardValidationMessages';
import { ScoringPresetApplier } from '@/lib/utilities/ScoringPresetApplier';
export type WizardStep = 1 | 2 | 3 | 4 | 5 | 6 | 7;
@@ -263,7 +262,7 @@ export class LeagueWizardCommandModel {
patternId,
customScoringEnabled: false,
};
this.timings = ScoringPresetApplier.applyToTimings(patternId, this.timings);
// Timing defaults are applied by the UI using the selected preset's `defaultTimings` from the API.
}
toCreateLeagueCommand(ownerId: string): CreateLeagueInputDTO {

View File

@@ -1,19 +1,15 @@
import { ApplyPenaltyCommandDTO } from '../../types/generated/ApplyPenaltyCommandDTO';
export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
export interface ProtestDecisionData {
decision: 'uphold' | 'dismiss' | null;
penaltyType: PenaltyType;
penaltyValue: number;
penaltyType: string;
penaltyValue?: number;
stewardNotes: string;
}
const DEFAULT_PROTEST_REASON = 'Protest upheld';
export class ProtestDecisionCommandModel {
decision: 'uphold' | 'dismiss' | null = null;
penaltyType: PenaltyType = 'time_penalty';
penaltyType: string = 'time_penalty';
penaltyValue: number = 5;
stewardNotes: string = '';
@@ -39,27 +35,37 @@ export class ProtestDecisionCommandModel {
this.stewardNotes = '';
}
toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO {
const reason = this.decision === 'uphold'
? DEFAULT_PROTEST_REASON
: 'Protest dismissed';
toApplyPenaltyCommand(
raceId: string,
driverId: string,
stewardId: string,
protestId: string,
options?: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
},
): ApplyPenaltyCommandDTO {
const reason =
this.decision === 'uphold'
? (options?.defaultUpheldReason ?? 'Protest upheld')
: (options?.defaultDismissedReason ?? 'Protest dismissed');
return {
const base: ApplyPenaltyCommandDTO = {
raceId,
driverId,
stewardId,
enum: this.penaltyType, // Use penaltyType as enum
enum: this.penaltyType,
type: this.penaltyType,
value: this.getPenaltyValue(),
reason,
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;
if (options?.requiresValue) {
return { ...base, value: this.penaltyValue };
}
return base;
}
}

View File

@@ -1,37 +1,32 @@
type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
export type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
export interface LeagueRoleDisplayData {
text: string;
badgeClasses: string;
}
export const leagueRoleDisplay: Record<LeagueRole, LeagueRoleDisplayData> = {
owner: {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
},
admin: {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
steward: {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
member: {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
},
} as const;
// For backward compatibility, also export the class with static method
export class LeagueRoleDisplay {
/**
* Centralized display configuration for league membership roles.
*/
static getLeagueRoleDisplay(role: LeagueRole): LeagueRoleDisplayData {
switch (role) {
case 'owner':
return {
text: 'Owner',
badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30',
};
case 'admin':
return {
text: 'Admin',
badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
};
case 'steward':
return {
text: 'Steward',
badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
};
case 'member':
default:
return {
text: 'Member',
badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30',
};
}
static getLeagueRoleDisplay(role: LeagueRole) {
return leagueRoleDisplay[role];
}
}

View File

@@ -9,8 +9,9 @@ describe('PenaltyService', () => {
beforeEach(() => {
mockApiClient = {
getRacePenalties: vi.fn(),
getPenaltyTypesReference: vi.fn(),
applyPenalty: vi.fn(),
} as Mocked<PenaltiesApiClient>;
} as unknown as Mocked<PenaltiesApiClient>;
service = new PenaltyService(mockApiClient);
});
@@ -23,9 +24,10 @@ describe('PenaltyService', () => {
{ id: 'penalty-1', driverId: 'driver-1', type: 'time', value: 5, reason: 'Incident' },
{ id: 'penalty-2', driverId: 'driver-2', type: 'grid', value: 3, reason: 'Qualifying incident' },
],
driverMap: {},
};
mockApiClient.getRacePenalties.mockResolvedValue(mockDto);
mockApiClient.getRacePenalties.mockResolvedValue(mockDto as any);
const result = await service.findByRaceId(raceId);
@@ -36,9 +38,9 @@ describe('PenaltyService', () => {
it('should handle empty penalties array', async () => {
const raceId = 'race-123';
const mockDto = { penalties: [] };
const mockDto = { penalties: [], driverMap: {} };
mockApiClient.getRacePenalties.mockResolvedValue(mockDto);
mockApiClient.getRacePenalties.mockResolvedValue(mockDto as any);
const result = await service.findByRaceId(raceId);

View File

@@ -1,4 +1,5 @@
import { PenaltiesApiClient } from '../../api/penalties/PenaltiesApiClient';
import type { PenaltyTypesReferenceDTO } from '../../types/PenaltyTypesReferenceDTO';
/**
* Penalty Service
@@ -19,6 +20,13 @@ export class PenaltyService {
return dto.penalties;
}
/**
* Get allowed penalty types and semantics
*/
async getPenaltyTypesReference(): Promise<PenaltyTypesReferenceDTO> {
return this.apiClient.getPenaltyTypesReference();
}
/**
* Apply a penalty
*/

View File

@@ -19,17 +19,22 @@ describe('TeamJoinService', () => {
const mockDto = {
requests: [
{
id: 'request-1',
requestId: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2023-01-01T00:00:00Z',
message: 'Please accept me',
avatarUrl: 'https://example.com/avatar-1.jpg',
},
{
id: 'request-2',
requestId: 'request-2',
teamId: 'team-1',
driverId: 'driver-2',
driverName: 'Driver Two',
status: 'pending',
requestedAt: '2023-01-02T00:00:00Z',
avatarUrl: 'https://example.com/avatar-2.jpg',
},
],
};
@@ -40,20 +45,30 @@ describe('TeamJoinService', () => {
expect(mockApiClient.getJoinRequests).toHaveBeenCalledWith('team-1');
expect(result).toHaveLength(2);
expect(result[0].id).toBe('request-1');
expect(result[0].canApprove).toBe(true);
expect(result[1].id).toBe('request-2');
expect(result[1].canApprove).toBe(true);
const first = result[0];
const second = result[1];
expect(first).toBeDefined();
expect(second).toBeDefined();
expect(first!.id).toBe('request-1');
expect(first!.canApprove).toBe(true);
expect(second!.id).toBe('request-2');
expect(second!.canApprove).toBe(true);
});
it('should pass correct parameters to view model constructor', async () => {
const mockDto = {
requests: [
{
id: 'request-1',
requestId: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2023-01-01T00:00:00Z',
avatarUrl: 'https://example.com/avatar-1.jpg',
},
],
};
@@ -62,7 +77,8 @@ describe('TeamJoinService', () => {
const result = await service.getJoinRequests('team-1', 'user-1', false);
expect(result[0].canApprove).toBe(false);
expect(result[0]).toBeDefined();
expect(result[0]!.canApprove).toBe(false);
});
});

View File

@@ -34,7 +34,7 @@ export class TeamJoinService {
* a request requires a future management endpoint, so this method fails explicitly.
*/
async approveJoinRequest(): Promise<never> {
throw new Error('Approving team join requests is not supported in this build');
throw new Error('Not implemented: API endpoint for approving join requests');
}
/**
@@ -44,6 +44,6 @@ export class TeamJoinService {
* must treat this as an unsupported operation rather than a silent no-op.
*/
async rejectJoinRequest(): Promise<never> {
throw new Error('Rejecting team join requests is not supported in this build');
throw new Error('Not implemented: API endpoint for rejecting join requests');
}
}

View File

@@ -4,45 +4,111 @@
* Values are primarily sourced from environment variables so that
* deployments can provide real company details without hard-coding
* production data in the repository.
*
* Website must remain an API consumer (no adapter imports).
*/
const env = {
platformName: process.env.NEXT_PUBLIC_SITE_NAME,
platformUrl: process.env.NEXT_PUBLIC_SITE_URL,
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL,
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL,
legalCompanyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME,
legalVatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID,
legalRegisteredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY,
legalRegisteredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS,
} as const;
export const siteConfig = {
export interface SiteConfigData {
// Platform Information
platformName: env.platformName ?? 'GridPilot',
platformUrl: env.platformUrl ?? 'https://gridpilot.com',
platformName: string;
platformUrl: string;
// Contact Information
supportEmail: env.supportEmail ?? 'support@example.com',
sponsorEmail: env.sponsorEmail ?? 'sponsors@example.com',
supportEmail: string;
sponsorEmail: string;
// Legal & Business Information
legal: {
companyName: env.legalCompanyName ?? '',
vatId: env.legalVatId ?? '',
registeredCountry: env.legalRegisteredCountry ?? '',
registeredAddress: env.legalRegisteredAddress ?? '',
},
companyName: string;
vatId: string;
registeredCountry: string;
registeredAddress: string;
};
// Platform Fees
fees: {
platformFeePercent: 10, // 10% platform fee on sponsorships
description: 'Platform fee supports maintenance, analytics, and secure payment processing.',
},
platformFeePercent: number;
description: string;
};
// VAT Information
vat: {
euReverseChargeApplies: boolean;
nonEuVatExempt: boolean;
standardRate: number;
notice: string;
euBusinessNotice: string;
nonEuNotice: string;
};
// Sponsorship Types Available
sponsorshipTypes: {
leagues: {
enabled: boolean;
title: string;
description: string;
};
teams: {
enabled: boolean;
title: string;
description: string;
};
drivers: {
enabled: boolean;
title: string;
description: string;
};
races: {
enabled: boolean;
title: string;
description: string;
};
platform: {
enabled: boolean;
title: string;
description: string;
};
};
// Feature Flags for Sponsorship Features
features: {
liveryPlacement: boolean;
leaguePageBranding: boolean;
racePageBranding: boolean;
profileBadges: boolean;
socialMediaMentions: boolean;
newsletterInclusion: boolean;
homepageAds: boolean;
sidebarAds: boolean;
broadcastOverlays: boolean;
};
}
export const siteConfig: SiteConfigData = {
// Platform Information
platformName: process.env.NEXT_PUBLIC_SITE_NAME ?? 'GridPilot',
platformUrl: process.env.NEXT_PUBLIC_SITE_URL ?? 'https://gridpilot.com',
// Contact Information
supportEmail: process.env.NEXT_PUBLIC_SUPPORT_EMAIL ?? 'support@example.com',
sponsorEmail: process.env.NEXT_PUBLIC_SPONSOR_EMAIL ?? 'sponsors@example.com',
// Legal & Business Information
legal: {
companyName: process.env.NEXT_PUBLIC_LEGAL_COMPANY_NAME ?? '',
vatId: process.env.NEXT_PUBLIC_LEGAL_VAT_ID ?? '',
registeredCountry: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_COUNTRY ?? '',
registeredAddress: process.env.NEXT_PUBLIC_LEGAL_REGISTERED_ADDRESS ?? '',
},
// Platform Fees
fees: {
platformFeePercent: 10,
description: 'Platform fee supports maintenance, analytics, and secure payment processing.',
},
// VAT Information
vat: {
// Note: All prices displayed are exclusive of VAT
euReverseChargeApplies: true,
nonEuVatExempt: true,
standardRate: 20,
@@ -50,7 +116,7 @@ export const siteConfig = {
euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.',
nonEuNotice: 'Non-EU businesses are not charged VAT.',
},
// Sponsorship Types Available
sponsorshipTypes: {
leagues: {
@@ -79,10 +145,9 @@ export const siteConfig = {
description: 'Reach the entire GridPilot audience with strategic platform placements.',
},
},
// Feature Flags for Sponsorship Features
features: {
// What sponsors can actually get (no broadcast control)
liveryPlacement: true,
leaguePageBranding: true,
racePageBranding: true,
@@ -91,8 +156,7 @@ export const siteConfig = {
newsletterInclusion: true,
homepageAds: true,
sidebarAds: true,
// We don't control these
broadcastOverlays: false, // We don't control broadcast
broadcastOverlays: false,
},
} as const;

View File

@@ -0,0 +1,15 @@
export type PenaltyValueKindDTO = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export interface PenaltyTypeReferenceDTO {
type: string;
requiresValue: boolean;
valueKind: PenaltyValueKindDTO;
}
export interface PenaltyTypesReferenceDTO {
penaltyTypes: PenaltyTypeReferenceDTO[];
defaultReasons: {
upheld: string;
dismissed: string;
};
}

View File

@@ -12,4 +12,11 @@ export interface LeagueScoringPresetDTO {
sessionSummary: string;
bonusSummary: string;
dropPolicySummary: string;
defaultTimings: {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
};
}

View File

@@ -8,30 +8,8 @@ export type Timings = {
};
export class ScoringPresetApplier {
static applyToTimings(patternId: string, currentTimings: Timings): Timings {
// Website-local fallback mapping (UI convenience only).
// Authoritative preset/timing rules should live in the API.
switch (patternId) {
case 'sprint-main-driver':
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? 20,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? 30,
sprintRaceMinutes: currentTimings.sprintRaceMinutes ?? 20,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? 40,
sessionCount: 2,
};
case 'endurance-main-driver':
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? 30,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? 20,
sprintRaceMinutes: 0,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? 120,
sessionCount: 1,
};
default:
return currentTimings;
}
static applyToTimings(_patternId: string, currentTimings: Timings): Timings {
// Deprecated: timing defaults are provided by the API via scoring preset `defaultTimings`.
return currentTimings;
}
}

View File

@@ -1,6 +1,14 @@
import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
export const raceStatusConfig = {
export interface RaceStatusConfigData {
icon: any;
color: string;
bg: string;
border: string;
label: string;
}
export const raceStatusConfig: Record<string, RaceStatusConfigData> = {
scheduled: {
icon: Clock,
color: 'text-primary-blue',
@@ -29,4 +37,4 @@ export const raceStatusConfig = {
border: 'border-warning-amber/30',
label: 'Cancelled',
},
};
} as const;

View File

@@ -14,7 +14,7 @@ export class DriverLeaderboardItemViewModel {
avatarUrl: string;
position: number;
private previousRating?: number;
private previousRating: number | undefined;
constructor(dto: DriverLeaderboardItemDTO, position: number, previousRating?: number) {
this.id = dto.id;

View File

@@ -1,5 +1,13 @@
import { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export type LeagueScoringPresetTimingDefaultsViewModel = {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
};
/**
* LeagueScoringPresetViewModel
*
@@ -10,11 +18,13 @@ export class LeagueScoringPresetViewModel {
readonly name: string;
readonly sessionSummary: string;
readonly bonusSummary?: string;
readonly defaultTimings: LeagueScoringPresetTimingDefaultsViewModel;
constructor(dto: LeagueScoringPresetDTO) {
this.id = dto.id;
this.name = dto.name;
this.sessionSummary = dto.sessionSummary;
this.bonusSummary = dto.bonusSummary;
this.defaultTimings = dto.defaultTimings;
}
}

View File

@@ -32,4 +32,12 @@ export class RequestAvatarGenerationViewModel {
get firstAvatarUrl(): string | undefined {
return this.avatarUrls?.[0];
}
get avatarUrl(): string | undefined {
return this.firstAvatarUrl;
}
get error(): string | undefined {
return this.errorMessage;
}
}

View File

@@ -18,13 +18,13 @@ export class SponsorshipRequestViewModel {
this.id = dto.id;
this.sponsorId = dto.sponsorId;
this.sponsorName = dto.sponsorName;
this.sponsorLogo = dto.sponsorLogo;
if (dto.sponsorLogo !== undefined) this.sponsorLogo = dto.sponsorLogo;
// Backend currently returns tier as string; normalize to our supported tiers.
this.tier = dto.tier === 'main' ? 'main' : 'secondary';
this.offeredAmount = dto.offeredAmount;
this.currency = dto.currency;
this.formattedAmount = dto.formattedAmount;
this.message = dto.message;
if (dto.message !== undefined) this.message = dto.message;
this.createdAt = new Date(dto.createdAt);
this.platformFee = dto.platformFee;
this.netAmount = dto.netAmount;

View File

@@ -7,10 +7,10 @@ export class TeamDetailsViewModel {
description?: string;
ownerId!: string;
leagues!: string[];
createdAt?: string;
specialization?: string;
region?: string;
languages?: string[];
createdAt: string | undefined;
specialization: string | undefined;
region: string | undefined;
languages: string[] | undefined;
membership: { role: string; joinedAt: string; isActive: boolean } | null;
private _canManage: boolean;
private currentUserId: string;

View File

@@ -1,18 +1,21 @@
import { describe, it, expect } from 'vitest';
import { TeamJoinRequestViewModel, type TeamJoinRequestDTO } from './TeamJoinRequestViewModel';
import { TeamJoinRequestViewModel } from './TeamJoinRequestViewModel';
import type { TeamJoinRequestDTO } from '@/lib/types/generated/TeamJoinRequestDTO';
const createTeamJoinRequestDto = (overrides: Partial<TeamJoinRequestDTO> = {}): TeamJoinRequestDTO => ({
id: 'request-1',
requestId: 'request-1',
teamId: 'team-1',
driverId: 'driver-1',
driverName: 'Driver One',
status: 'pending',
requestedAt: '2024-01-01T12:00:00Z',
message: 'Please let me join',
avatarUrl: 'https://example.com/avatar.jpg',
...overrides,
});
describe('TeamJoinRequestViewModel', () => {
it('maps fields from DTO', () => {
const dto = createTeamJoinRequestDto({ id: 'req-123', driverId: 'driver-123' });
const dto = createTeamJoinRequestDto({ requestId: 'req-123', driverId: 'driver-123' });
const vm = new TeamJoinRequestViewModel(dto, 'current-user', true);
@@ -20,7 +23,6 @@ describe('TeamJoinRequestViewModel', () => {
expect(vm.teamId).toBe('team-1');
expect(vm.driverId).toBe('driver-123');
expect(vm.requestedAt).toBe('2024-01-01T12:00:00Z');
expect(vm.message).toBe('Please let me join');
});
it('allows approval only for owners', () => {
@@ -34,7 +36,7 @@ describe('TeamJoinRequestViewModel', () => {
});
it('exposes a pending status with yellow color', () => {
const dto = createTeamJoinRequestDto();
const dto = createTeamJoinRequestDto({ status: 'pending' });
const vm = new TeamJoinRequestViewModel(dto, 'owner-user', true);
expect(vm.status).toBe('Pending');

View File

@@ -24,6 +24,17 @@ export class TeamJoinRequestViewModel {
this.isOwner = isOwner;
}
get id(): string {
return this.requestId;
}
get status(): string {
if (this.requestStatus === 'pending') return 'Pending';
if (this.requestStatus === 'approved') return 'Approved';
if (this.requestStatus === 'rejected') return 'Rejected';
return this.requestStatus;
}
/** UI-specific: Whether current user can approve */
get canApprove(): boolean {
return this.isOwner;

View File

@@ -10,8 +10,8 @@ export class TeamSummaryViewModel {
totalRaces: number = 0;
performanceLevel: string = '';
isRecruiting: boolean = false;
specialization?: string;
region?: string;
specialization: string | undefined;
region: string | undefined;
languages: string[] = [];
leagues: string[] = [];