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

@@ -0,0 +1,29 @@
/**
* Activity Configuration
*
* UI display configuration for activity feed items
*/
export type ActivityType = 'race' | 'league' | 'team' | 'driver' | 'platform';
export interface ActivityConfigData {
color: string;
}
export const activityConfig: Record<ActivityType, ActivityConfigData> = {
race: {
color: 'bg-warning-amber',
},
league: {
color: 'bg-primary-blue',
},
team: {
color: 'bg-purple-400',
},
driver: {
color: 'bg-performance-green',
},
platform: {
color: 'bg-racing-red',
},
} as const;

View File

@@ -0,0 +1,19 @@
/**
* Incident Configuration
*
* UI display configuration for incident badges
*/
export interface IncidentConfigData {
color: string;
}
export const getIncidentBadgeColor = (incidents: number): string => {
if (incidents === 0) return 'green';
if (incidents <= 2) return 'yellow';
return 'red';
};
export const incidentConfig = {
getIncidentBadgeColor,
} as const;

View File

@@ -0,0 +1,31 @@
/**
* League Role Display Configuration
*
* UI display configuration for league membership roles
*/
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;

View File

@@ -12,6 +12,14 @@ export type LeagueScoringPresetPrimaryChampionshipType =
| 'nations'
| 'trophy';
export interface LeagueScoringPresetTimingDefaults {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export interface LeagueScoringPreset {
id: string;
name: string;
@@ -20,6 +28,7 @@ export interface LeagueScoringPreset {
dropPolicySummary: string;
sessionSummary: string;
bonusSummary: string;
defaultTimings: LeagueScoringPresetTimingDefaults;
createConfig: (options: { seasonId: string }) => LeagueScoringConfig;
}
@@ -82,6 +91,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'Best 6 results of 8 count towards the championship.',
sessionSummary: 'Sprint + Main',
bonusSummary: 'Fastest lap +1 point in main race if finishing P10 or better.',
defaultTimings: {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
createConfig: ({ seasonId }) => {
const fastestLapBonus: BonusRule = {
id: 'fastest-lap-main',
@@ -146,6 +162,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'All race results count, no drop scores.',
sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.',
defaultTimings: {
practiceMinutes: 20,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 40,
sessionCount: 1,
},
createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main'];
@@ -190,6 +213,13 @@ export const leagueScoringPresets: LeagueScoringPreset[] = [
dropPolicySummary: 'Best 4 results of 6 count towards the championship.',
sessionSummary: 'Main race only',
bonusSummary: 'No bonus points.',
defaultTimings: {
practiceMinutes: 30,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 120,
sessionCount: 1,
},
createConfig: ({ seasonId }) => {
const sessionTypes: SessionType[] = ['main'];

View File

@@ -0,0 +1,35 @@
/**
* League Tier Configuration
*
* UI display configuration for league tiers
*/
export type LeagueTier = 'premium' | 'standard' | 'starter';
export interface LeagueTierConfigData {
color: string;
bgColor: string;
border: string;
icon: string;
}
export const leagueTierConfig: Record<LeagueTier, LeagueTierConfigData> = {
premium: {
color: 'text-yellow-400',
bgColor: 'bg-yellow-500/10',
border: 'border-yellow-500/30',
icon: '⭐',
},
standard: {
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
icon: '🏆',
},
starter: {
color: 'text-gray-400',
bgColor: 'bg-gray-500/10',
border: 'border-gray-500/30',
icon: '🚀',
},
} as const;

View File

@@ -0,0 +1,16 @@
/**
* Penalty Configuration
*
* Business logic configuration for protest decisions and penalties
*/
export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points';
export const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning'];
export const defaultProtestReason = 'Protest upheld';
export const penaltyConfig = {
penaltiesWithoutValue,
defaultProtestReason,
} as const;

View File

@@ -0,0 +1,46 @@
/**
* Race Status Configuration
*
* UI display configuration for race status states
*/
import { Clock, PlayCircle, CheckCircle2, XCircle } from 'lucide-react';
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',
bg: 'bg-primary-blue/10',
border: 'border-primary-blue/30',
label: 'Scheduled',
},
running: {
icon: PlayCircle,
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30',
label: 'LIVE',
},
completed: {
icon: CheckCircle2,
color: 'text-gray-400',
bg: 'bg-gray-500/10',
border: 'border-gray-500/30',
label: 'Completed',
},
cancelled: {
icon: XCircle,
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30',
label: 'Cancelled',
},
} as const;

View File

@@ -0,0 +1,29 @@
/**
* Renewal Alert Configuration
*
* UI display configuration for renewal alerts
*/
export type RenewalType = 'league' | 'team' | 'driver' | 'race' | 'platform';
export interface RenewalConfigData {
icon: string;
}
export const renewalConfig: Record<RenewalType, RenewalConfigData> = {
league: {
icon: 'Trophy',
},
team: {
icon: 'Users',
},
driver: {
icon: 'Car',
},
race: {
icon: 'Flag',
},
platform: {
icon: 'Megaphone',
},
} as const;

View File

@@ -0,0 +1,28 @@
/**
* Role Hierarchy Configuration
*
* Business logic configuration for league role ordering and permissions
*/
export type LeagueMembershipRole = 'owner' | 'admin' | 'steward' | 'member';
export const roleOrder: Record<LeagueMembershipRole, number> = {
owner: 0,
admin: 1,
steward: 2,
member: 3,
};
export const getRoleOrder = (role: LeagueMembershipRole): number => {
return roleOrder[role] ?? 99;
};
export const isLeagueAdminOrHigherRole = (role: LeagueMembershipRole): boolean => {
return role === 'owner' || role === 'admin' || role === 'steward';
};
export const roleHierarchy = {
roleOrder,
getRoleOrder,
isLeagueAdminOrHigherRole,
} as const;

View File

@@ -0,0 +1,31 @@
/**
* Season Status Configuration
*
* UI display configuration for season states
*/
export type SeasonStatus = 'active' | 'upcoming' | 'completed';
export interface SeasonStatusConfigData {
color: string;
bg: string;
label: string;
}
export const seasonStatusConfig: Record<SeasonStatus, SeasonStatusConfigData> = {
active: {
color: 'text-performance-green',
bg: 'bg-performance-green/10',
label: 'Active Season',
},
upcoming: {
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
label: 'Starting Soon',
},
completed: {
color: 'text-gray-400',
bg: 'bg-gray-400/10',
label: 'Season Ended',
},
} as const;

View File

@@ -0,0 +1,162 @@
/**
* Site Configuration
*
* Platform-wide configuration and settings
*/
export interface SiteConfigData {
// Platform Information
platformName: string;
platformUrl: string;
// Contact Information
supportEmail: string;
sponsorEmail: string;
// Legal & Business Information
legal: {
companyName: string;
vatId: string;
registeredCountry: string;
registeredAddress: string;
};
// Platform Fees
fees: {
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, // 10% platform fee on sponsorships
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,
notice: 'All prices shown are exclusive of VAT. Applicable taxes will be calculated at checkout.',
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: {
enabled: true,
title: 'League Sponsorship',
description: 'Sponsor entire racing leagues and get your brand in front of all participants.',
},
teams: {
enabled: true,
title: 'Team Sponsorship',
description: 'Partner with competitive racing teams for long-term brand association.',
},
drivers: {
enabled: true,
title: 'Driver Sponsorship',
description: 'Support individual drivers and grow with rising sim racing talent.',
},
races: {
enabled: true,
title: 'Race Sponsorship',
description: 'Sponsor individual race events for targeted, high-impact exposure.',
},
platform: {
enabled: true,
title: 'Platform Advertising',
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,
profileBadges: true,
socialMediaMentions: true,
newsletterInclusion: true,
homepageAds: true,
sidebarAds: true,
// We don't control these
broadcastOverlays: false, // We don't control broadcast
},
} as const;
export type SiteConfig = typeof siteConfig;

View File

@@ -0,0 +1,31 @@
/**
* Skill Level Configuration
*
* UI display configuration for driver skill levels
*/
export type SkillLevel = 'beginner' | 'intermediate' | 'advanced' | 'expert';
export interface SkillLevelConfigData {
color: string;
icon: string;
}
export const skillLevelConfig: Record<SkillLevel, SkillLevelConfigData> = {
beginner: {
color: 'green',
icon: '🥉',
},
intermediate: {
color: 'yellow',
icon: '🥈',
},
advanced: {
color: 'orange',
icon: '🥇',
},
expert: {
color: 'red',
icon: '👑',
},
} as const;

View File

@@ -0,0 +1,52 @@
/**
* Sponsorship Configuration
*
* UI display configuration for sponsorship types and statuses
*/
export type SponsorshipType = 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
export type SponsorshipStatus = 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
export interface SponsorshipTypeConfigData {
label: string;
}
export interface SponsorshipStatusConfigData {
label: string;
}
export const sponsorshipTypeConfig: Record<SponsorshipType, SponsorshipTypeConfigData> = {
leagues: {
label: 'League',
},
teams: {
label: 'Team',
},
drivers: {
label: 'Driver',
},
races: {
label: 'Race',
},
platform: {
label: 'Platform',
},
} as const;
export const sponsorshipStatusConfig: Record<SponsorshipStatus, SponsorshipStatusConfigData> = {
active: {
label: 'Active',
},
pending_approval: {
label: 'Awaiting Approval',
},
approved: {
label: 'Approved',
},
rejected: {
label: 'Declined',
},
expired: {
label: 'Expired',
},
} as const;

View File

@@ -0,0 +1,53 @@
/**
* Timing Configuration
*
* Business logic configuration for scoring preset timings
*/
export interface Timings {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
[key: string]: unknown;
}
export interface TimingConfigData {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export const timingConfig: Record<string, TimingConfigData> = {
'sprint-main-driver': {
practiceMinutes: 20,
qualifyingMinutes: 30,
sprintRaceMinutes: 20,
mainRaceMinutes: 40,
sessionCount: 2,
},
'endurance-main-driver': {
practiceMinutes: 30,
qualifyingMinutes: 20,
sprintRaceMinutes: 0,
mainRaceMinutes: 120,
sessionCount: 1,
},
} as const;
export const applyTimingConfig = (patternId: string, currentTimings: Timings): Timings => {
const config = timingConfig[patternId];
if (!config) return currentTimings;
return {
...currentTimings,
practiceMinutes: currentTimings.practiceMinutes ?? config.practiceMinutes,
qualifyingMinutes: currentTimings.qualifyingMinutes ?? config.qualifyingMinutes,
sprintRaceMinutes: currentTimings.sprintRaceMinutes ?? config.sprintRaceMinutes,
mainRaceMinutes: currentTimings.mainRaceMinutes ?? config.mainRaceMinutes,
sessionCount: config.sessionCount,
};
};

View File

@@ -2,4 +2,19 @@ export * from './EnsureInitialData';
export * from './LeagueConstraints';
export * from './LeagueScoringPresets';
export * from './PointsSystems';
export * from './ScoringDemoSetup';
export * from './ScoringDemoSetup';
// New configuration exports
export * from './SiteConfig';
export * from './RaceStatusConfig';
export * from './LeagueRoleDisplay';
export * from './ActivityConfig';
export * from './RenewalConfig';
export * from './LeagueTierConfig';
export * from './SeasonStatusConfig';
export * from './SponsorshipConfig';
export * from './SkillLevelConfig';
export * from './IncidentConfig';
export * from './PenaltyConfig';
export * from './RoleHierarchy';
export * from './TimingConfig';

View File

@@ -33,6 +33,7 @@ import { GetLeagueWalletOutputDTO } from './dtos/GetLeagueWalletOutputDTO';
import { TotalLeaguesDTO } from './dtos/TotalLeaguesDTO';
import { WithdrawFromLeagueWalletInputDTO } from './dtos/WithdrawFromLeagueWalletInputDTO';
import { WithdrawFromLeagueWalletOutputDTO } from './dtos/WithdrawFromLeagueWalletOutputDTO';
import { LeagueScoringPresetsDTO } from './dtos/LeagueScoringPresetsDTO';
@ApiTags('leagues')
@Controller('leagues')
@@ -239,8 +240,8 @@ export class LeagueController {
@Get('scoring-presets')
@ApiOperation({ summary: 'Get league scoring presets' })
@ApiResponse({ status: 200, description: 'List of scoring presets' })
async getLeagueScoringPresets() {
@ApiResponse({ status: 200, description: 'List of scoring presets', type: LeagueScoringPresetsDTO })
async getLeagueScoringPresets(): Promise<LeagueScoringPresetsDTO> {
return this.leagueService.listLeagueScoringPresets();
}

View File

@@ -1,5 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsEnum } from 'class-validator';
import { IsString, IsEnum, IsNumber, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class LeagueScoringPresetTimingDefaultsDTO {
@ApiProperty()
@IsNumber()
practiceMinutes!: number;
@ApiProperty()
@IsNumber()
qualifyingMinutes!: number;
@ApiProperty()
@IsNumber()
sprintRaceMinutes!: number;
@ApiProperty()
@IsNumber()
mainRaceMinutes!: number;
@ApiProperty()
@IsNumber()
sessionCount!: number;
}
export class LeagueScoringPresetDTO {
@ApiProperty()
@@ -29,4 +52,9 @@ export class LeagueScoringPresetDTO {
@ApiProperty()
@IsString()
dropPolicySummary!: string;
@ApiProperty({ type: LeagueScoringPresetTimingDefaultsDTO })
@ValidateNested()
@Type(() => LeagueScoringPresetTimingDefaultsDTO)
defaultTimings!: LeagueScoringPresetTimingDefaultsDTO;
}

View File

@@ -0,0 +1,10 @@
import { ApiProperty } from '@nestjs/swagger';
import { LeagueScoringPresetDTO } from './LeagueScoringPresetDTO';
export class LeagueScoringPresetsDTO {
@ApiProperty({ type: [LeagueScoringPresetDTO] })
presets!: LeagueScoringPresetDTO[];
@ApiProperty()
totalCount!: number;
}

View File

@@ -1,10 +1,8 @@
import type { UseCaseOutputPort } from '@core/shared/application/UseCaseOutputPort';
import type { ListLeagueScoringPresetsResult, LeagueScoringPreset } from '@core/racing/application/use-cases/ListLeagueScoringPresetsUseCase';
import type { LeagueScoringPresetsDTO } from '../dtos/LeagueScoringPresetsDTO';
export interface LeagueScoringPresetsViewModel {
presets: LeagueScoringPreset[];
totalCount: number;
}
export type LeagueScoringPresetsViewModel = LeagueScoringPresetsDTO;
export class LeagueScoringPresetsPresenter implements UseCaseOutputPort<ListLeagueScoringPresetsResult> {
private viewModel: LeagueScoringPresetsViewModel | null = null;

View File

@@ -17,6 +17,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO';
import { ApplyPenaltyCommandDTO } from './dtos/ApplyPenaltyCommandDTO';
import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO';
import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
@ApiTags('races')
@Controller('races')
@@ -96,6 +97,13 @@ export class RaceController {
return presenter.viewModel;
}
@Get('reference/penalty-types')
@ApiOperation({ summary: 'Get allowed penalty types and semantics' })
@ApiResponse({ status: 200, description: 'Penalty types reference', type: PenaltyTypesReferenceDTO })
getPenaltyTypesReference(): PenaltyTypesReferenceDTO {
return this.raceService.getPenaltyTypesReference();
}
@Get(':raceId/penalties')
@ApiOperation({ summary: 'Get race penalties' })
@ApiParam({ name: 'raceId', description: 'Race ID' })

View File

@@ -50,6 +50,7 @@ import { FileProtestCommandDTO } from './dtos/FileProtestCommandDTO';
import { QuickPenaltyCommandDTO } from './dtos/QuickPenaltyCommandDTO';
import { RequestProtestDefenseCommandDTO } from './dtos/RequestProtestDefenseCommandDTO';
import { ReviewProtestCommandDTO } from './dtos/ReviewProtestCommandDTO';
import { PenaltyTypesReferenceDTO } from './dtos/PenaltyTypesReferenceDTO';
// Tokens
import { ApplyPenaltyUseCase } from '@core/racing/application/use-cases/ApplyPenaltyUseCase';
@@ -68,6 +69,13 @@ import {
RACES_PAGE_DATA_PRESENTER_TOKEN
} from './RaceTokens';
import {
PENALTY_TYPE_VALUES,
getPenaltyValueKind,
type PenaltyTypeValue,
} from '@core/racing/domain/entities/penalty/PenaltyType';
import { penaltyTypeRequiresValue } from '@core/racing/domain/entities/penalty/Penalty';
@Injectable()
export class RaceService {
constructor(
@@ -160,6 +168,22 @@ export class RaceService {
return this.raceProtestsPresenter;
}
getPenaltyTypesReference(): PenaltyTypesReferenceDTO {
const items = PENALTY_TYPE_VALUES.map((type: PenaltyTypeValue) => ({
type,
requiresValue: penaltyTypeRequiresValue(type),
valueKind: getPenaltyValueKind(type),
}));
return {
penaltyTypes: items,
defaultReasons: {
upheld: 'Protest upheld',
dismissed: 'Protest dismissed',
},
};
}
async getRacePenalties(raceId: string): Promise<RacePenaltiesPresenter> {
this.logger.debug('[RaceService] Fetching race penalties:', { raceId });
await this.getRacePenaltiesUseCase.execute({ raceId });

View File

@@ -1,5 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsString, IsNotEmpty, IsOptional, IsNumber, IsEnum } from 'class-validator';
import { PENALTY_TYPE_VALUES } from '@core/racing/domain/entities/penalty/PenaltyType';
export class ApplyPenaltyCommandDTO {
@ApiProperty()
@@ -17,30 +18,8 @@ export class ApplyPenaltyCommandDTO {
@IsNotEmpty()
stewardId!: string;
@ApiProperty({
enum: [
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
],
})
@IsEnum([
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
])
@ApiProperty({ enum: PENALTY_TYPE_VALUES })
@IsEnum(PENALTY_TYPE_VALUES)
type!: string;
@ApiProperty({ required: false })

View File

@@ -0,0 +1,40 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsBoolean, IsEnum, IsString } from 'class-validator';
import {
PENALTY_TYPE_VALUES,
type PenaltyTypeValue,
} from '@core/racing/domain/entities/penalty/PenaltyType';
export type PenaltyValueKindDTO = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export class PenaltyTypeReferenceDTO {
@ApiProperty({ enum: PENALTY_TYPE_VALUES })
@IsEnum(PENALTY_TYPE_VALUES)
type!: PenaltyTypeValue;
@ApiProperty()
@IsBoolean()
requiresValue!: boolean;
@ApiProperty({ enum: ['seconds', 'grid_positions', 'points', 'races', 'none'] })
@IsString()
valueKind!: PenaltyValueKindDTO;
}
export class PenaltyDefaultReasonsDTO {
@ApiProperty()
@IsString()
upheld!: string;
@ApiProperty()
@IsString()
dismissed!: string;
}
export class PenaltyTypesReferenceDTO {
@ApiProperty({ type: [PenaltyTypeReferenceDTO] })
penaltyTypes!: PenaltyTypeReferenceDTO[];
@ApiProperty({ type: PenaltyDefaultReasonsDTO })
defaultReasons!: PenaltyDefaultReasonsDTO;
}

View File

@@ -24,9 +24,6 @@ import Link from 'next/link';
import { useParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
// Local type definitions to replace core imports
type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points' | 'probation' | 'fine' | 'race_ban';
export default function LeagueStewardingPage() {
const params = useParams();
const leagueId = params.id as string;
@@ -81,7 +78,7 @@ export default function LeagueStewardingPage() {
const handleAcceptProtest = async (
protestId: string,
penaltyType: PenaltyType,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {

View File

@@ -8,7 +8,8 @@ import { useServices } from '@/lib/services/ServiceProvider';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel, type PenaltyType } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import type { PenaltyTypesReferenceDTO, PenaltyValueKindDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
import {
AlertCircle,
AlertTriangle,
@@ -45,68 +46,88 @@ interface TimelineEvent {
metadata?: Record<string, unknown>;
}
const PENALTY_TYPES = [
{
type: 'time_penalty' as PenaltyType,
type PenaltyUiConfig = {
label: string;
description: string;
icon: typeof Gavel;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
requiresValue: true,
valueLabel: 'seconds',
defaultValue: 5
defaultValue: 5,
},
{
type: 'grid_penalty' as PenaltyType,
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
requiresValue: true,
valueLabel: 'positions',
defaultValue: 3
defaultValue: 3,
},
{
type: 'points_deduction' as PenaltyType,
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
requiresValue: true,
valueLabel: 'points',
defaultValue: 5
defaultValue: 5,
},
{
type: 'disqualification' as PenaltyType,
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
requiresValue: false,
valueLabel: '',
defaultValue: 0
defaultValue: 0,
},
{
type: 'warning' as PenaltyType,
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
requiresValue: false,
valueLabel: '',
defaultValue: 0
defaultValue: 0,
},
{
type: 'license_points' as PenaltyType,
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
requiresValue: true,
valueLabel: 'points',
defaultValue: 2
defaultValue: 2,
},
];
};
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
switch (valueKind) {
case 'seconds':
return 'seconds';
case 'grid_positions':
return 'positions';
case 'points':
return 'points';
case 'races':
return 'races';
case 'none':
return '';
}
}
function getFallbackDefaultValue(valueKind: PenaltyValueKindDTO): number {
switch (valueKind) {
case 'seconds':
return 5;
case 'grid_positions':
return 3;
case 'points':
return 5;
case 'races':
return 1;
case 'none':
return 0;
}
}
export default function ProtestReviewPage() {
const params = useParams();
@@ -114,7 +135,7 @@ export default function ProtestReviewPage() {
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const { protestService, leagueMembershipService } = useServices();
const { protestService, leagueMembershipService, penaltyService } = useServices();
const [protest, setProtest] = useState<ProtestViewModel | null>(null);
const [race, setRace] = useState<RaceViewModel | null>(null);
@@ -122,14 +143,41 @@ export default function ProtestReviewPage() {
const [accusedDriver, setAccusedDriver] = useState<ProtestDriverViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [penaltyTypesReference, setPenaltyTypesReference] = useState<PenaltyTypesReferenceDTO | null>(null);
const [penaltyTypesLoading, setPenaltyTypesLoading] = useState(false);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<PenaltyType>('time_penalty');
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const penaltyTypes = useMemo(() => {
const referenceItems = penaltyTypesReference?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
label: ref.type.replaceAll('_', ' '),
description: '',
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
defaultValue: getFallbackDefaultValue(ref.valueKind),
};
return {
...ref,
...ui,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: ui.defaultValue ?? getFallbackDefaultValue(ref.valueKind),
};
});
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
// Comment state
const [newComment, setNewComment] = useState('');
@@ -168,15 +216,47 @@ export default function ProtestReviewPage() {
if (isAdmin) {
loadProtest();
}
}, [protestId, leagueId, isAdmin, router]);
}, [protestId, leagueId, isAdmin, router, protestService]);
useEffect(() => {
async function loadPenaltyTypes() {
if (!isAdmin) return;
if (penaltyTypesReference) return;
setPenaltyTypesLoading(true);
try {
const ref = await penaltyService.getPenaltyTypesReference();
setPenaltyTypesReference(ref);
const hasSelected = ref.penaltyTypes.some((p) => p.type === penaltyType);
const [first] = ref.penaltyTypes;
if (!hasSelected && first) {
setPenaltyType(first.type);
setPenaltyValue(PENALTY_UI[first.type]?.defaultValue ?? getFallbackDefaultValue(first.valueKind));
}
} catch (err) {
console.error('Failed to load penalty types reference:', err);
} finally {
setPenaltyTypesLoading(false);
}
}
loadPenaltyTypes();
}, [isAdmin, penaltyService, penaltyTypesReference, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return;
if (penaltyTypesLoading) return;
setSubmitting(true);
try {
const defaultUpheldReason = penaltyTypesReference?.defaultReasons?.upheld;
const defaultDismissedReason = penaltyTypesReference?.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
@@ -184,17 +264,32 @@ export default function ProtestReviewPage() {
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
} else {
// For dismiss, we might need a separate endpoint
// For now, just apply a warning penalty with 0 value or create a separate endpoint
const warningRef = penaltyTypesReference?.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
@@ -202,15 +297,27 @@ export default function ProtestReviewPage() {
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id
protest.id,
options,
);
penaltyCommand.reason = stewardNotes || 'Protest dismissed';
await protestService.applyPenalty(penaltyCommand);
}
@@ -601,46 +708,55 @@ export default function ProtestReviewPage() {
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
<div className="grid grid-cols-2 gap-1.5">
{PENALTY_TYPES.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{PENALTY_TYPES.find(p => p.type === penaltyType)?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({PENALTY_TYPES.find(p => p.type === penaltyType)?.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)}
</div>
)}

View File

@@ -2,6 +2,7 @@ import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
@@ -67,6 +68,17 @@ vi.mock('@/lib/utilities/LeagueMembershipUtility', () => ({
},
}));
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
const createViewModel = (status: string) => {
return new RaceDetailViewModel({
race: {
@@ -119,7 +131,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
const reopenButton = await screen.findByText('Re-open Race');
expect(reopenButton).toBeInTheDocument();
@@ -145,7 +157,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('completed');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();
@@ -159,7 +171,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => {
const viewModel = createViewModel('scheduled');
mockGetRaceDetail.mockResolvedValue(viewModel);
render(<RaceDetailPage />);
renderWithQueryClient(<RaceDetailPage />);
await waitFor(() => {
expect(mockGetRaceDetail).toHaveBeenCalled();

View File

@@ -33,7 +33,7 @@ import {
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import React, { useEffect, useState } from 'react';
export default function RaceDetailPage() {
const router = useRouter();

View File

@@ -22,9 +22,10 @@ import {
Users,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService';
import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets';
import { LeagueBasicsSection } from './LeagueBasicsSection';
@@ -494,78 +495,29 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
};
// Handler for scoring preset selection - delegates to application-level config helper
// Handler for scoring preset selection (timings default from API)
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => {
// Convert to LeagueWizardFormData for the command model
const formData: any = {
leagueId: prev.leagueId || '',
basics: {
name: prev.basics?.name || '',
description: prev.basics?.description || '',
visibility: (prev.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: prev.basics?.gameId || 'iracing',
},
structure: {
mode: (prev.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: prev.structure?.maxDrivers || 24,
maxTeams: prev.structure?.maxTeams || 0,
driversPerTeam: prev.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: prev.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: prev.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: prev.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: prev.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: prev.scoring?.patternId,
customScoringEnabled: prev.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (prev.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: prev.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: prev.timings?.practiceMinutes || 0,
qualifyingMinutes: prev.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: prev.timings?.mainRaceMinutes || 0,
sessionCount: prev.timings?.sessionCount || 0,
roundsPlanned: prev.timings?.roundsPlanned || 0,
raceDayOfWeek: prev.timings?.raceDayOfWeek || 0,
raceTimeUtc: prev.timings?.raceTimeUtc || '',
weekdays: (prev.timings?.weekdays as Weekday[]) || [],
recurrenceStrategy: prev.timings?.recurrenceStrategy || '',
timezoneId: prev.timings?.timezoneId || '',
seasonStartDate: prev.timings?.seasonStartDate || '',
},
stewarding: {
decisionMode: (prev.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: prev.stewarding?.requiredVotes || 2,
requireDefense: prev.stewarding?.requireDefense ?? false,
defenseTimeLimit: prev.stewarding?.defenseTimeLimit || 48,
voteTimeLimit: prev.stewarding?.voteTimeLimit || 72,
protestDeadlineHours: prev.stewarding?.protestDeadlineHours || 48,
stewardingClosesHours: prev.stewarding?.stewardingClosesHours || 168,
notifyAccusedOnProtest: prev.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: prev.stewarding?.notifyOnVoteRequired ?? true,
},
};
const selectedPreset = presets.find((p) => p.id === patternId);
const updated = LeagueWizardCommandModel.applyScoringPresetToConfig(formData, patternId);
// Convert back to LeagueWizardFormModel
return {
basics: updated.basics,
structure: updated.structure,
championships: updated.championships,
scoring: updated.scoring,
dropPolicy: updated.dropPolicy,
timings: updated.timings,
stewarding: updated.stewarding,
seasonName: prev.seasonName,
} as LeagueWizardFormModel;
...prev,
scoring: {
...prev.scoring,
patternId,
customScoringEnabled: false,
},
timings: selectedPreset
? {
...prev.timings,
practiceMinutes: prev.timings?.practiceMinutes ?? selectedPreset.defaultTimings.practiceMinutes,
qualifyingMinutes: prev.timings?.qualifyingMinutes ?? selectedPreset.defaultTimings.qualifyingMinutes,
sprintRaceMinutes: prev.timings?.sprintRaceMinutes ?? selectedPreset.defaultTimings.sprintRaceMinutes,
mainRaceMinutes: prev.timings?.mainRaceMinutes ?? selectedPreset.defaultTimings.mainRaceMinutes,
sessionCount: selectedPreset.defaultTimings.sessionCount,
}
: prev.timings,
};
});
};

View File

@@ -8,8 +8,6 @@ import Card from "../ui/Card";
import Button from "../ui/Button";
import { Clock, Grid3x3, TrendingDown, AlertCircle, Filter, Flag } from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points";
interface PenaltyHistoryListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;

View File

@@ -1,6 +1,8 @@
"use client";
import { useState } from "react";
import { useMemo, useState } from "react";
import { usePenaltyTypesReference } from "@/hooks/usePenaltyTypesReference";
import type { PenaltyValueKindDTO } from "@/lib/types/PenaltyTypesReferenceDTO";
import { ProtestViewModel } from "../../lib/view-models/ProtestViewModel";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
@@ -21,7 +23,7 @@ import {
FileWarning,
} from "lucide-react";
type PenaltyType = "time_penalty" | "grid_penalty" | "points_deduction" | "disqualification" | "warning" | "license_points" | "probation" | "fine" | "race_ban";
type PenaltyType = string;
interface ReviewProtestModalProps {
protest: ProtestViewModel | null;
@@ -94,25 +96,63 @@ export function ReviewProtestModal({
}
};
const getPenaltyLabel = (type: PenaltyType) => {
const getPenaltyName = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
return "seconds";
return "Time Penalty";
case "grid_penalty":
return "grid positions";
return "Grid Penalty";
case "points_deduction":
return "points";
return "Points Deduction";
case "disqualification":
return "Disqualification";
case "warning":
return "Warning";
case "license_points":
return "points";
return "License Points";
case "probation":
return "Probation";
case "fine":
return "points";
return "Fine";
case "race_ban":
return "races";
return "Race Ban";
default:
return type.replaceAll("_", " ");
}
};
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
switch (valueKind) {
case "seconds":
return "seconds";
case "grid_positions":
return "grid positions";
case "points":
return "points";
case "races":
return "races";
case "none":
return "";
}
};
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
if (type === "license_points") return 2;
if (type === "race_ban") return 1;
switch (valueKind) {
case "seconds":
return 5;
case "grid_positions":
return 3;
case "points":
return 5;
case "races":
return 1;
case "none":
return 0;
}
};
const getPenaltyColor = (type: PenaltyType) => {
switch (type) {
case "time_penalty":
@@ -138,6 +178,25 @@ export function ReviewProtestModal({
}
};
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
return refs.map((ref) => ({
type: ref.type as PenaltyType,
name: getPenaltyName(ref.type),
requiresValue: ref.requiresValue,
valueLabel: getPenaltyValueLabel(ref.valueKind),
defaultValue: getPenaltyDefaultValue(ref.type, ref.valueKind),
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
const selectedPenalty = useMemo(() => {
return penaltyOptions.find((p) => p.type === penaltyType);
}, [penaltyOptions, penaltyType]);
if (showConfirmation) {
return (
<Modal title="Confirm Decision" isOpen={true} onOpenChange={() => setShowConfirmation(false)}>
@@ -160,7 +219,9 @@ export function ReviewProtestModal({
<h3 className="text-xl font-bold text-white">Confirm Decision</h3>
<p className="text-gray-400 mt-2">
{decision === "accept"
? `Issue ${penaltyValue} ${getPenaltyLabel(penaltyType)} penalty?`
? (selectedPenalty?.requiresValue
? `Issue ${penaltyValue} ${selectedPenalty.valueLabel} penalty?`
: `Issue ${selectedPenalty?.name ?? penaltyType} penalty?`)
: "Reject this protest?"}
</p>
</div>
@@ -300,43 +361,39 @@ export function ReviewProtestModal({
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Type
</label>
<div className="grid grid-cols-3 gap-2">
{[
{ type: "time_penalty" as PenaltyType, label: "Time Penalty" },
{ type: "grid_penalty" as PenaltyType, label: "Grid Penalty" },
{ type: "points_deduction" as PenaltyType, label: "Points Deduction" },
{ type: "disqualification" as PenaltyType, label: "Disqualification" },
{ type: "warning" as PenaltyType, label: "Warning" },
{ type: "license_points" as PenaltyType, label: "License Points" },
{ type: "probation" as PenaltyType, label: "Probation" },
{ type: "fine" as PenaltyType, label: "Fine" },
{ type: "race_ban" as PenaltyType, label: "Race Ban" },
].map(({ type, label }) => {
const Icon = getPenaltyIcon(type);
const colorClass = getPenaltyColor(type);
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => setPenaltyType(type)}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? '' : 'text-gray-400'}`} />
<p className={`text-xs font-medium ${isSelected ? '' : 'text-gray-400'}`}>{label}</p>
</button>
);
})}
</div>
{penaltyTypesLoading ? (
<div className="text-sm text-gray-500">Loading penalty types</div>
) : (
<div className="grid grid-cols-3 gap-2">
{penaltyOptions.map(({ type, name, Icon, colorClass, defaultValue }) => {
const isSelected = penaltyType === type;
return (
<button
key={type}
onClick={() => {
setPenaltyType(type);
setPenaltyValue(defaultValue);
}}
className={`p-3 rounded-lg border transition-all ${
isSelected
? `${colorClass} border-2`
: "border-charcoal-outline hover:border-gray-600 bg-iron-gray/50"
}`}
>
<Icon className={`h-5 w-5 mx-auto mb-1 ${isSelected ? "" : "text-gray-400"}`} />
<p className={`text-xs font-medium ${isSelected ? "" : "text-gray-400"}`}>{name}</p>
</button>
);
})}
</div>
)}
</div>
{['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(penaltyType) && (
{selectedPenalty?.requiresValue && (
<div>
<label className="text-sm font-medium text-gray-400 mb-2 block">
Penalty Value ({getPenaltyLabel(penaltyType)})
Penalty Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"

View File

@@ -1,5 +1,6 @@
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';

View File

@@ -4,7 +4,7 @@ import { useAuth } from '@/lib/auth/AuthContext';
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion';
import { BarChart3, Building2, ChevronDown, CreditCard, Handshake, LogOut, Megaphone, Paintbrush, Settings, TrendingUp, Trophy } from 'lucide-react';
import Link from 'next/link';
import { useEffect, useMemo, useState } from 'react';
import React, { useEffect, useMemo, useState } from 'react';
import DriverSummaryPill from '@/components/profile/DriverSummaryPill';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';

View File

@@ -0,0 +1,22 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`UserPill > renders auth links when there is no session 1`] = `
<div>
<div
class="flex items-center gap-2"
>
<a
class="inline-flex items-center gap-2 rounded-full bg-iron-gray border border-charcoal-outline px-4 py-1.5 text-xs font-medium text-gray-300 hover:text-white hover:border-gray-500 transition-all"
href="/auth/login"
>
Sign In
</a>
<a
class="inline-flex items-center gap-2 rounded-full bg-primary-blue px-4 py-1.5 text-xs font-semibold text-white shadow-[0_0_12px_rgba(25,140,255,0.5)] hover:bg-primary-blue/90 hover:shadow-[0_0_18px_rgba(25,140,255,0.8)] transition-all"
href="/auth/signup"
>
Get Started
</a>
</div>
</div>
`;

View File

@@ -172,22 +172,26 @@ export default function SponsorWorkflowMockup() {
transition={{ duration: 0.3 }}
className="mt-8 pt-6 border-t border-charcoal-outline"
>
<div className="flex items-center justify-center gap-3">
<div className={`w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center`}>
{(() => {
const Icon = WORKFLOW_STEPS[activeStep].icon;
return <Icon className={`w-4 h-4 ${WORKFLOW_STEPS[activeStep].color}`} />;
})()}
</div>
<div className="text-left">
<p className="text-sm text-gray-400">
Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</p>
<p className="text-white font-medium">
{WORKFLOW_STEPS[activeStep].title}
</p>
</div>
</div>
{(() => {
const currentStep = WORKFLOW_STEPS[activeStep] ?? WORKFLOW_STEPS[0];
if (!currentStep) return null;
const Icon = currentStep.icon;
return (
<div className="flex items-center justify-center gap-3">
<div className="w-8 h-8 rounded-lg bg-iron-gray flex items-center justify-center">
<Icon className={`w-4 h-4 ${currentStep.color}`} />
</div>
<div className="text-left">
<p className="text-sm text-gray-400">
Step {activeStep + 1} of {WORKFLOW_STEPS.length}
</p>
<p className="text-white font-medium">{currentStep.title}</p>
</div>
</div>
);
})()}
</motion.div>
</AnimatePresence>
</div>

View File

@@ -0,0 +1,15 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
import type { PenaltyTypesReferenceDTO } from '@/lib/types/PenaltyTypesReferenceDTO';
export function usePenaltyTypesReference() {
const { penaltyService } = useServices();
return useQuery<PenaltyTypesReferenceDTO>({
queryKey: ['penaltyTypesReference'],
queryFn: () => penaltyService.getPenaltyTypesReference(),
staleTime: 5 * 60 * 1000,
});
}

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[] = [];

View File

@@ -27,11 +27,6 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
transpilePackages: [
'@core/racing',
'@core/identity',
'@core/social'
],
webpack: (config) => {
config.module.rules.push({
test: /\.(mp4|webm)$/,

View File

@@ -84,7 +84,6 @@
"**/*.spec.tsx",
"**/__tests__/**",
"../../core/**",
"../../adapters/**",
"../../apps/api/**",
"../../scripts/**",
"../../testing/**",

View File

@@ -37,6 +37,7 @@ export class ListLeagueScoringPresetsUseCase {
sessionSummary: p.sessionSummary,
bonusSummary: p.bonusSummary,
dropPolicySummary: p.dropPolicySummary,
defaultTimings: p.defaultTimings,
}));
const result: ListLeagueScoringPresetsResult = { presets };

View File

@@ -11,7 +11,7 @@ import { PenaltyId } from './PenaltyId';
import { LeagueId } from '../LeagueId';
import { RaceId } from '../RaceId';
import { DriverId } from '../DriverId';
import { PenaltyType } from './PenaltyType';
import { PenaltyType, type PenaltyTypeValue } from './PenaltyType';
import { PenaltyValue } from './PenaltyValue';
import { PenaltyReason } from './PenaltyReason';
import { ProtestId } from '../ProtestId';
@@ -47,6 +47,19 @@ export interface PenaltyProps {
notes?: PenaltyNotes;
}
export const PENALTY_TYPES_REQUIRING_VALUE: PenaltyTypeValue[] = [
'time_penalty',
'grid_penalty',
'points_deduction',
'license_points',
'fine',
'race_ban',
];
export function penaltyTypeRequiresValue(type: PenaltyTypeValue): boolean {
return PENALTY_TYPES_REQUIRING_VALUE.includes(type);
}
export class Penalty implements IEntity<string> {
private constructor(private readonly props: PenaltyProps) {}
@@ -73,10 +86,12 @@ export class Penalty implements IEntity<string> {
if (!props.reason?.trim()) throw new RacingDomainValidationError('Penalty reason is required');
if (!props.issuedBy) throw new RacingDomainValidationError('Penalty must be issued by a steward');
const penaltyType = PenaltyType.create(props.type);
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
if (penaltyTypeRequiresValue(penaltyType.toString())) {
if (props.value === undefined || props.value <= 0) {
throw new RacingDomainValidationError(`${props.type} requires a positive value`);
throw new RacingDomainValidationError(`${penaltyType.toString()} requires a positive value`);
}
}
@@ -85,7 +100,7 @@ export class Penalty implements IEntity<string> {
leagueId: LeagueId.create(props.leagueId),
raceId: RaceId.create(props.raceId),
driverId: DriverId.create(props.driverId),
type: PenaltyType.create(props.type),
type: penaltyType,
reason: PenaltyReason.create(props.reason),
issuedBy: StewardId.create(props.issuedBy),
status: PenaltyStatus.create(props.status || 'pending'),

View File

@@ -11,23 +11,44 @@ export type PenaltyTypeValue =
| 'fine'
| 'race_ban';
export const PENALTY_TYPE_VALUES: PenaltyTypeValue[] = [
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
];
export type PenaltyValueKind = 'seconds' | 'grid_positions' | 'points' | 'races' | 'none';
export function getPenaltyValueKind(type: PenaltyTypeValue): PenaltyValueKind {
switch (type) {
case 'time_penalty':
return 'seconds';
case 'grid_penalty':
return 'grid_positions';
case 'points_deduction':
case 'license_points':
case 'fine':
return 'points';
case 'race_ban':
return 'races';
case 'disqualification':
case 'warning':
case 'probation':
return 'none';
}
}
export class PenaltyType {
private constructor(private readonly value: PenaltyTypeValue) {}
static create(value: string): PenaltyType {
const validTypes: PenaltyTypeValue[] = [
'time_penalty',
'grid_penalty',
'points_deduction',
'disqualification',
'warning',
'license_points',
'probation',
'fine',
'race_ban',
];
if (!validTypes.includes(value as PenaltyTypeValue)) {
if (!PENALTY_TYPE_VALUES.includes(value as PenaltyTypeValue)) {
throw new RacingDomainValidationError(`Invalid penalty type: ${value}`);
}

View File

@@ -10,6 +10,14 @@ export type LeagueScoringPresetPrimaryChampionshipType =
| 'nations'
| 'trophy';
export interface LeagueScoringPresetTimingDefaults {
practiceMinutes: number;
qualifyingMinutes: number;
sprintRaceMinutes: number;
mainRaceMinutes: number;
sessionCount: number;
}
export interface LeagueScoringPreset {
id: string;
name: string;
@@ -18,4 +26,5 @@ export interface LeagueScoringPreset {
dropPolicySummary: string;
sessionSummary: string;
bonusSummary: string;
defaultTimings: LeagueScoringPresetTimingDefaults;
}

View File

@@ -608,13 +608,33 @@ Adapters decide **how** it runs.
---
### Reference Data (registries exposed via API)
Some “data” is neither **domain behavior** nor **UI config**. It is **authoritative reference data** that outer layers need (website, API DTOs), but it is not a domain entity itself.
Examples:
* Scoring preset registries (preset identifiers, point systems, drop-week policies, default timing presets)
* Catalog-like lists needed by the UI to render forms safely without duplicating domain semantics
Rules:
* The website (presentation) must never own or hardcode domain catalogs.
* Core owns invariants, enumerations, and validation rules. If a rule is an invariant, it belongs in Core.
* Adapters may own **reference registries** when they are not domain entities, and when they represent curated “known lists” used for configuration and defaults.
* The API must expose reference registries via HTTP so the website remains a pure API consumer (no importing adapters or core).
* UI-only concerns (labels, icons, colors, layout defaults) remain in the website.
---
### Hard separation
| Concern | Purpose | Location |
| --------- | ------------------- | ------------------------------- |
| Migration | Schema | adapters/persistence/migrations |
| Bootstrap | Required start data | adapters/bootstrap |
| Factory | Test data | testing |
| Concern | Purpose | Location |
| -------------- | ---------------------------------------------- | ------------------------------- |
| Migration | Schema | adapters/persistence/migrations |
| Bootstrap | Required start data | adapters/bootstrap |
| Reference Data | Authoritative registries consumed via API | adapters/bootstrap + apps/api |
| Factory | Test data | testing |
---