move static data
This commit is contained in:
29
adapters/bootstrap/ActivityConfig.ts
Normal file
29
adapters/bootstrap/ActivityConfig.ts
Normal 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;
|
||||
19
adapters/bootstrap/IncidentConfig.ts
Normal file
19
adapters/bootstrap/IncidentConfig.ts
Normal 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;
|
||||
31
adapters/bootstrap/LeagueRoleDisplay.ts
Normal file
31
adapters/bootstrap/LeagueRoleDisplay.ts
Normal 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;
|
||||
@@ -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'];
|
||||
|
||||
|
||||
35
adapters/bootstrap/LeagueTierConfig.ts
Normal file
35
adapters/bootstrap/LeagueTierConfig.ts
Normal 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;
|
||||
16
adapters/bootstrap/PenaltyConfig.ts
Normal file
16
adapters/bootstrap/PenaltyConfig.ts
Normal 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;
|
||||
46
adapters/bootstrap/RaceStatusConfig.ts
Normal file
46
adapters/bootstrap/RaceStatusConfig.ts
Normal 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;
|
||||
29
adapters/bootstrap/RenewalConfig.ts
Normal file
29
adapters/bootstrap/RenewalConfig.ts
Normal 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;
|
||||
28
adapters/bootstrap/RoleHierarchy.ts
Normal file
28
adapters/bootstrap/RoleHierarchy.ts
Normal 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;
|
||||
31
adapters/bootstrap/SeasonStatusConfig.ts
Normal file
31
adapters/bootstrap/SeasonStatusConfig.ts
Normal 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;
|
||||
162
adapters/bootstrap/SiteConfig.ts
Normal file
162
adapters/bootstrap/SiteConfig.ts
Normal 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;
|
||||
31
adapters/bootstrap/SkillLevelConfig.ts
Normal file
31
adapters/bootstrap/SkillLevelConfig.ts
Normal 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;
|
||||
52
adapters/bootstrap/SponsorshipConfig.ts
Normal file
52
adapters/bootstrap/SponsorshipConfig.ts
Normal 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;
|
||||
53
adapters/bootstrap/TimingConfig.ts
Normal file
53
adapters/bootstrap/TimingConfig.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
10
apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts
Normal file
10
apps/api/src/domain/league/dtos/LeagueScoringPresetsDTO.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 })
|
||||
|
||||
40
apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts
Normal file
40
apps/api/src/domain/race/dtos/PenaltyTypesReferenceDTO.ts
Normal 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;
|
||||
}
|
||||
@@ -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
|
||||
) => {
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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>
|
||||
|
||||
15
apps/website/hooks/usePenaltyTypesReference.ts
Normal file
15
apps/website/hooks/usePenaltyTypesReference.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
15
apps/website/lib/types/PenaltyTypesReferenceDTO.ts
Normal file
15
apps/website/lib/types/PenaltyTypesReferenceDTO.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -12,4 +12,11 @@ export interface LeagueScoringPresetDTO {
|
||||
sessionSummary: string;
|
||||
bonusSummary: string;
|
||||
dropPolicySummary: string;
|
||||
defaultTimings: {
|
||||
practiceMinutes: number;
|
||||
qualifyingMinutes: number;
|
||||
sprintRaceMinutes: number;
|
||||
mainRaceMinutes: number;
|
||||
sessionCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
|
||||
@@ -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)$/,
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"**/*.spec.tsx",
|
||||
"**/__tests__/**",
|
||||
"../../core/**",
|
||||
"../../adapters/**",
|
||||
"../../apps/api/**",
|
||||
"../../scripts/**",
|
||||
"../../testing/**",
|
||||
|
||||
@@ -37,6 +37,7 @@ export class ListLeagueScoringPresetsUseCase {
|
||||
sessionSummary: p.sessionSummary,
|
||||
bonusSummary: p.bonusSummary,
|
||||
dropPolicySummary: p.dropPolicySummary,
|
||||
defaultTimings: p.defaultTimings,
|
||||
}));
|
||||
|
||||
const result: ListLeagueScoringPresetsResult = { presets };
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
Reference in New Issue
Block a user