website cleanup

This commit is contained in:
2025-12-24 13:04:18 +01:00
parent 5e491d9724
commit a7aee42409
69 changed files with 1624 additions and 938 deletions

View File

@@ -271,6 +271,83 @@
}
]
}
},
{
"files": ["apps/website/**/*.tsx", "apps/website/**/*.ts"],
"rules": {
"no-restricted-syntax": [
"error",
{
"selector": "TSInterfaceDeclaration[id.name=/ViewModel$/], TSTypeAliasDeclaration[id.name=/ViewModel$/], TSClassDeclaration[id.name=/ViewModel$/]",
"message": "ViewModel types must be defined in apps/website/lib/view-models, not in components."
},
{
"selector": "TSInterfaceDeclaration[id.name=/DTO$/], TSTypeAliasDeclaration[id.name=/DTO$/], TSClassDeclaration[id.name=/DTO$/]",
"message": "DTO types are forbidden in website components. Use ViewModels instead."
}
],
"no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@core/racing",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/analytics",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/identity",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/media",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/notifications",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/payments",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/shared",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@core/social",
"message": "Imports from @core are forbidden in website components"
},
{
"name": "@adapters",
"message": "Imports from @adapters are forbidden in website components"
},
{
"name": "@api",
"message": "Imports from @api are forbidden in website components"
}
],
"patterns": [
{
"group": ["@core/*"],
"message": "Imports from @core are forbidden in website components"
},
{
"group": ["@adapters/*"],
"message": "Imports from @adapters are forbidden in website components"
},
{
"group": ["@api/*"],
"message": "Imports from @api are forbidden in website components"
}
]
}
]
}
}
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -19,5 +19,17 @@ export class LeagueStandingDTO {
@ApiProperty()
@IsNumber()
rank!: number;
position!: number;
@ApiProperty()
@IsNumber()
wins!: number;
@ApiProperty()
@IsNumber()
podiums!: number;
@ApiProperty()
@IsNumber()
races!: number;
}

View File

@@ -1,6 +1,6 @@
import { cookies } from 'next/headers';
import { NextResponse } from 'next/server';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
const STATE_COOKIE = 'gp_demo_auth_state';
@@ -22,7 +22,8 @@ export async function GET(request: Request) {
return NextResponse.redirect('/auth/iracing');
}
const authService = getAuthService();
const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001');
const authService = serviceFactory.createAuthService();
const loginInput = returnTo ? { code, state, returnTo } : { code, state };
await authService.loginWithIracingCallback(loginInput);

View File

@@ -14,7 +14,6 @@ import {
Trophy,
BarChart3,
CheckCircle2,
Loader2,
} from 'lucide-react';
import Card from '@/components/ui/Card';

View File

@@ -27,11 +27,11 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import { useServices } from '@/lib/services/ServiceProvider';
import type { DriverLeaderboardViewModel } from '@/lib/view-models';
import { useDriverLeaderboard } from '@/hooks/useDriverService';
import Image from 'next/image';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
// ============================================================================
// DEMO DATA
@@ -373,28 +373,13 @@ function RecentActivity({ drivers, onDriverClick }: RecentActivityProps) {
export default function DriversPage() {
const router = useRouter();
const { driverService } = useServices();
const [drivers, setDrivers] = useState<DriverLeaderboardItemViewModel[]>([]);
const [viewModel, setViewModel] = useState<DriverLeaderboardViewModel | null>(null);
const [loading, setLoading] = useState(true);
const { data: viewModel, isLoading: loading } = useDriverLeaderboard();
const [searchQuery, setSearchQuery] = useState('');
const [totalRaces, setTotalRaces] = useState(0);
const [totalWins, setTotalWins] = useState(0);
const [activeCount, setActiveCount] = useState(0);
useEffect(() => {
const load = async () => {
const vm = await driverService.getDriverLeaderboard();
setViewModel(vm);
setDrivers(vm.drivers);
setTotalRaces(vm.totalRaces);
setTotalWins(vm.totalWins);
setActiveCount(vm.activeCount);
setLoading(false);
};
void load();
}, [driverService]);
const drivers = viewModel?.drivers || [];
const totalRaces = viewModel?.totalRaces || 0;
const totalWins = viewModel?.totalWins || 0;
const activeCount = viewModel?.activeCount || 0;
const handleDriverClick = (driverId: string) => {
router.push(`/drivers/${driverId}`);

View File

@@ -4,7 +4,7 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
import LeagueHeader from '@/components/leagues/LeagueHeader';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel';
import { useParams, usePathname, useRouter } from 'next/navigation';
import React, { useEffect, useState } from 'react';
@@ -20,7 +20,7 @@ export default function LeagueLayout({
const currentDriverId = useEffectiveDriverId();
const { leagueService } = useServices();
const [leagueDetail, setLeagueDetail] = useState<LeagueDetailViewModel | null>(null);
const [leagueDetail, setLeagueDetail] = useState<LeaguePageDetailViewModel | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {

View File

@@ -3,7 +3,7 @@
import LeagueSchedule from '@/components/leagues/LeagueSchedule';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -21,7 +21,7 @@ export default function LeagueSchedulePage() {
async function checkAdmin() {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);

View File

@@ -4,9 +4,9 @@ import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo';
import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
import { AlertTriangle, Settings, UserCog } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
@@ -27,7 +27,7 @@ export default function LeagueSettingsPage() {
async function checkAdmin() {
const memberships = await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);

View File

@@ -3,7 +3,7 @@
import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshipsSection';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel';
import { AlertTriangle, Building } from 'lucide-react';
@@ -31,7 +31,7 @@ export default function LeagueSponsorshipsPage() {
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setLeague(leagueDetail);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
console.error('Failed to load league:', err);
} finally {

View File

@@ -4,12 +4,13 @@ import StandingsTable from '@/components/leagues/StandingsTable';
import LeagueChampionshipStats from '@/components/leagues/LeagueChampionshipStats';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import type { LeagueMembership, MembershipRole } from '@/lib/types';
import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider';
import { DriverViewModel } from '@/lib/view-models';
import type { LeagueStandingsViewModel } from '@/lib/view-models';
import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
import { LeagueStandingsViewModel } from '@/lib/view-models/LeagueStandingsViewModel';
import { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel';
import { useParams } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
@@ -37,7 +38,7 @@ export default function LeagueStandingsPage() {
// Check if current user is admin
const membership = vm.memberships.find(m => m.driverId === currentDriverId);
setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load standings');
} finally {
@@ -62,7 +63,7 @@ export default function LeagueStandingsPage() {
}
};
const handleUpdateRole = async (driverId: string, newRole: MembershipRole) => {
const handleUpdateRole = async (driverId: string, newRole: MembershipRoleDTO['value']) => {
try {
await leagueService.updateMemberRole(leagueId, currentDriverId, driverId, newRole);
await loadData();

View File

@@ -4,7 +4,7 @@ import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useServices } from '@/lib/services/ServiceProvider';
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import Link from 'next/link';
import { useEffect, useState } from 'react';

View File

@@ -11,7 +11,6 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useRaceDetail, useRegisterForRace, useWithdrawFromRace, useCancelRace, useCompleteRace, useReopenRace } from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
import type { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
import {
AlertTriangle,
ArrowLeft,

View File

@@ -11,7 +11,7 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useRaceResultsDetail, useRaceWithSOF } from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import type { RaceResultsDetailViewModel } from '@/lib/view-models';
import type { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
import { ArrowLeft, Calendar, Trophy, Users, Zap } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';

View File

@@ -304,18 +304,6 @@ export default function SponsorBillingPage() {
);
}
const handleSetDefault = (methodId: string) => {
setPaymentMethods(methods =>
methods.map(m => ({ ...m, isDefault: m.id === methodId }))
);
};
const handleRemoveMethod = (methodId: string) => {
if (confirm('Remove this payment method?')) {
setPaymentMethods(methods => methods.filter(m => m.id !== methodId));
}
};
const handleSetDefault = (methodId: string) => {
// In a real app, this would call an API
console.log('Setting default payment method:', methodId);

View File

@@ -89,7 +89,8 @@ export default function SponsorLeagueDetailPage() {
);
}
const config = data.league.tierConfig;
const league = data.league;
const config = league.tierConfig;
return (
<div className="max-w-7xl mx-auto py-8 px-4">
@@ -99,7 +100,7 @@ export default function SponsorLeagueDetailPage() {
<ChevronRight className="w-4 h-4" />
<Link href="/sponsor/leagues" className="hover:text-white transition-colors">Leagues</Link>
<ChevronRight className="w-4 h-4" />
<span className="text-white">{league.name}</span>
<span className="text-white">{data.league.name}</span>
</div>
{/* Header */}
@@ -107,7 +108,7 @@ export default function SponsorLeagueDetailPage() {
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<span className={`px-3 py-1 rounded-full text-sm font-medium capitalize ${config.bgColor} ${config.color} border ${config.border}`}>
{league.tier}
{data.league.tier}
</span>
<span className="px-3 py-1 rounded-full text-sm font-medium bg-performance-green/10 text-performance-green">
Active Season

View File

@@ -1,9 +1,9 @@
'use client';
import { Race } from '@core/racing/domain/entities/Race'; // TODO forbidden core import
import { useState } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import { Race } from '@core/racing/domain/entities/Race';
import Card from '../ui/Card';
interface CompanionInstructionsProps {
race: Race;

View File

@@ -1,7 +1,7 @@
import Card from '@/components/ui/Card';
import RankBadge from '@/components/drivers/RankBadge';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverCardProps {
id: string;
@@ -29,13 +29,11 @@ export default function DriverCard(props: DriverCardProps) {
onClick,
} = props;
const driver: DriverDTO = {
// Create a proper DriverViewModel instance
const driverViewModel = new DriverViewModel({
id,
iracingId: '',
name,
country: nationality,
joinedAt: '',
};
});
return (
<Card
@@ -47,7 +45,7 @@ export default function DriverCard(props: DriverCardProps) {
<RankBadge rank={rank} size="lg" />
<DriverIdentity
driver={driver}
driver={driverViewModel}
href={`/drivers/${id}`}
meta={`${nationality}${racesCompleted} races`}
size="md"

View File

@@ -1,9 +1,9 @@
import Link from 'next/link';
import Image from 'next/image';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import type { DriverViewModel } from '@/lib/view-models/DriverViewModel';
export interface DriverIdentityProps {
driver: DriverDTO;
driver: DriverViewModel;
href?: string;
contextLabel?: React.ReactNode;
meta?: React.ReactNode;
@@ -21,6 +21,9 @@ export default function DriverIdentity(props: DriverIdentityProps) {
const metaTextClasses = 'text-xs md:text-sm text-gray-400';
// Use provided avatar URL or fallback to default avatar path
const avatarUrl = driver.avatarUrl || `/api/media/avatar/${driver.id}`;
const content = (
<div className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<div
@@ -28,7 +31,7 @@ export default function DriverIdentity(props: DriverIdentityProps) {
style={{ width: avatarSize, height: avatarSize }}
>
<Image
src={getImageService().getDriverAvatar(driver.id)}
src={avatarUrl}
alt={driver.name}
width={avatarSize}
height={avatarSize}

View File

@@ -1,6 +1,6 @@
'use client';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import Card from '../ui/Card';
import ProfileHeader from '../profile/ProfileHeader';
import ProfileStats from './ProfileStats';
@@ -8,17 +8,14 @@ import CareerHighlights from './CareerHighlights';
import DriverRankings from './DriverRankings';
import PerformanceMetrics from './PerformanceMetrics';
import { useEffect, useState } from 'react';
import { DriverTeamPresenter } from '@/lib/presenters/DriverTeamPresenter';
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
import type { DriverTeamViewModel } from '@core/racing/application/presenters/IDriverTeamPresenter';
import { useServices } from '@/lib/services/ServiceProvider';
interface DriverProfileProps {
driver: DriverDTO;
isOwnProfile?: boolean;
onEditClick?: () => void;
}
interface DriverProfileStatsViewModel {
rating: number;
wins: number;
@@ -33,36 +30,49 @@ interface DriverProfileStatsViewModel {
overallRank?: number;
}
type DriverProfileOverviewViewModel = ProfileOverviewOutputPort | null;
interface DriverTeamViewModel {
team: {
name: string;
tag: string;
};
}
export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) {
const [profileData, setProfileData] = useState<DriverProfileOverviewViewModel>(null);
const { driverService } = useServices();
const [profileData, setProfileData] = useState<DriverProfileStatsViewModel | null>(null);
const [teamData, setTeamData] = useState<DriverTeamViewModel | null>(null);
useEffect(() => {
const load = async () => {
// Load profile data using GetProfileOverviewUseCase
const profileUseCase = getGetProfileOverviewUseCase();
const profileViewModel = await profileUseCase.execute({ driverId: driver.id });
setProfileData(profileViewModel);
// Load team data using caller-owned presenter
const teamUseCase = getGetDriverTeamUseCase();
const driverTeamPresenter = new DriverTeamPresenter();
await teamUseCase.execute({ driverId: driver.id }, driverTeamPresenter);
const teamResult = driverTeamPresenter.getViewModel();
setTeamData(teamResult ?? null);
try {
// Load driver profile
const profile = await driverService.getDriverProfile(driver.id);
// Extract stats from profile
if (profile.stats) {
setProfileData(profile.stats);
}
// Load team data if available
if (profile.teamMemberships && profile.teamMemberships.length > 0) {
const currentTeam = profile.teamMemberships.find(m => m.isCurrent) || profile.teamMemberships[0];
setTeamData({
team: {
name: currentTeam.teamName,
tag: currentTeam.teamTag ?? ''
}
});
}
} catch (error) {
console.error('Failed to load driver profile data:', error);
}
};
void load();
}, [driver.id]);
}, [driver.id, driverService]);
const driverStats = profileData?.stats || null;
const primaryLeagueId = getPrimaryLeagueIdForDriver(driver.id);
const leagueRank = primaryLeagueId
? getLeagueRankings(driver.id, primaryLeagueId)
: { rank: 0, totalDrivers: 0, percentile: 0 };
const globalRank = profileData?.driver?.globalRank ?? null;
const totalDrivers = profileData?.driver?.totalDrivers ?? 0;
const driverStats = profileData;
const globalRank = profileData?.overallRank ?? null;
const totalDrivers = 1000; // Placeholder
const performanceStats = driverStats ? {
winRate: driverStats.totalRaces > 0 ? (driverStats.wins / driverStats.totalRaces) * 100 : 0,
@@ -83,14 +93,6 @@ export default function DriverProfile({ driver, isOwnProfile = false, onEditClic
percentile: driverStats.percentile ?? 0,
rating: driverStats.rating ?? 0,
},
{
type: 'league' as const,
name: 'Primary League',
rank: leagueRank.rank,
totalDrivers: leagueRank.totalDrivers,
percentile: leagueRank.percentile,
rating: driverStats.rating ?? 0,
},
] : [];
return (

View File

@@ -1,6 +1,6 @@
import { Users, Trophy, ChevronRight } from 'lucide-react';
import Heading from '@/components/ui/Heading';
import Button from '@/components/ui/Button';
import { Trophy, Users } from 'lucide-react';
import Button from '../ui/Button';
interface HeroSectionProps {
icon?: React.ElementType;

View File

@@ -4,9 +4,7 @@ import { useState, useEffect } from 'react';
import Card from '../ui/Card';
import Button from '../ui/Button';
import RaceResultCard from '../races/RaceResultCard';
import { Race } from '@core/racing/domain/entities/Race';
import { Result } from '@core/racing/domain/entities/Result';
import { League } from '@core/racing/domain/entities/League';
import { useServices } from '@/lib/services/ServiceProvider';
interface RaceHistoryProps {
driverId: string;

View File

@@ -1,7 +1,7 @@
'use client';
import { useState } from 'react';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import Card from '../ui/Card';
import Button from '../ui/Button';
import Input from '../ui/Input';

View File

@@ -4,7 +4,6 @@ import Card from '../ui/Card';
import RankBadge from './RankBadge';
import { useState, useEffect } from 'react';
import { getPrimaryLeagueIdForDriver } from '@/lib/leagueMembership';
import type { ProfileOverviewOutputPort } from '@core/racing/application/ports/output/ProfileOverviewOutputPort';
interface ProfileStatsProps {
driverId?: string;

View File

@@ -4,7 +4,8 @@ import { useState, FormEvent } from 'react';
import { useRouter } from 'next/navigation';
import Input from '../ui/Input';
import Button from '../ui/Button';
import { League } from '@core/racing/domain/entities/League';
import { useServices } from '@/lib/services/ServiceProvider';
import { useAuth } from '@/lib/auth/AuthContext';
interface FormErrors {
name?: string;
@@ -49,6 +50,9 @@ export default function CreateLeagueForm() {
return Object.keys(newErrors).length === 0;
};
const { session } = useAuth();
const { driverService, leagueService } = useServices();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
@@ -56,12 +60,16 @@ export default function CreateLeagueForm() {
if (!validateForm()) return;
if (!session?.user.userId) {
setErrors({ submit: 'You must be logged in to create a league' });
return;
}
setLoading(true);
try {
const driverRepo = getDriverRepository();
const drivers = await driverRepo.findAll();
const currentDriver = drivers[0];
// Get current driver
const currentDriver = await driverService.getDriverProfile(session.user.userId);
if (!currentDriver) {
setErrors({ submit: 'No driver profile found. Please create a profile first.' });
@@ -69,22 +77,16 @@ export default function CreateLeagueForm() {
return;
}
const leagueRepo = getLeagueRepository();
const league = League.create({
id: crypto.randomUUID(),
// Create league using the league service
const input = {
name: formData.name.trim(),
description: formData.description.trim(),
ownerId: currentDriver.id,
settings: {
pointsSystem: formData.pointsSystem,
sessionDuration: formData.sessionDuration,
qualifyingFormat: 'open',
},
});
visibility: 'public' as const,
ownerId: session.user.userId,
};
await leagueRepo.create(league);
router.push(`/leagues/${league.id}`);
const result = await leagueService.createLeague(input);
router.push(`/leagues/${result.leagueId}`);
router.refresh();
} catch (error) {
setErrors({

View File

@@ -1,44 +1,46 @@
'use client';
import React, { useEffect, useState, FormEvent, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import Heading from '@/components/ui/Heading';
import Input from '@/components/ui/Input';
import { useAuth } from '@/lib/auth/AuthContext';
import {
FileText,
Users,
Calendar,
Trophy,
AlertCircle,
Award,
Calendar,
Check,
CheckCircle2,
ChevronLeft,
ChevronRight,
FileText,
Loader2,
AlertCircle,
Sparkles,
Check,
Scale,
Sparkles,
Trophy,
Users,
} from 'lucide-react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading';
import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary';
import Input from '@/components/ui/Input';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { LeagueWizardService } from '@/lib/services/leagues/LeagueWizardService';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@core/racing/application';
import { useCreateLeagueWizard } from '@/hooks/useLeagueWizardService';
import { useLeagueScoringPresets } from '@/hooks/useLeagueScoringPresets';
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
import { LeagueStructureSection } from './LeagueStructureSection';
import {
LeagueScoringSection,
ScoringPatternSection,
ChampionshipsSection,
} from './LeagueScoringSection';
import { LeagueDropSection } from './LeagueDropSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import {
ChampionshipsSection,
ScoringPatternSection
} from './LeagueScoringSection';
import { LeagueStewardingSection } from './LeagueStewardingSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
import type { Weekday } from '@/lib/types/Weekday';
import type { WizardErrors } from '@/lib/types/WizardErrors';
// ============================================================================
// LOCAL STORAGE PERSISTENCE
@@ -47,6 +49,7 @@ import { LeagueStewardingSection } from './LeagueStewardingSection';
const STORAGE_KEY = 'gridpilot_league_wizard_draft';
const STORAGE_HIGHEST_STEP_KEY = 'gridpilot_league_wizard_highest_step';
// TODO there is a better place for this
function saveFormToStorage(form: LeagueWizardFormModel): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
@@ -55,6 +58,7 @@ function saveFormToStorage(form: LeagueWizardFormModel): void {
}
}
// TODO there is a better place for this
function loadFormFromStorage(): LeagueWizardFormModel | null {
try {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -152,8 +156,6 @@ function stepToStepName(step: Step): StepName {
}
}
import { WizardErrors } from '@/lib/types/WizardErrors';
function getDefaultSeasonStartDate(): string {
// Default to next Saturday
const now = new Date();
@@ -214,9 +216,8 @@ function createDefaultForm(): LeagueWizardFormModel {
sessionCount: 2,
roundsPlanned: 8,
// Default to Saturday races, weekly, starting next week
weekdays: ['Sat'] as import('@gridpilot/racing/domain/types/Weekday').Weekday[],
weekdays: ['Sat'] as Weekday[],
recurrenceStrategy: 'weekly' as const,
raceStartTime: '20:00',
timezoneId: 'UTC',
seasonStartDate: defaultSeasonStartDate,
},
@@ -277,41 +278,93 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
}, [step, isHydrated]);
useEffect(() => {
async function loadPresets() {
try {
const query = getListLeagueScoringPresetsQuery();
const result = await query.execute();
setPresets(result);
const firstPreset = result[0];
if (firstPreset) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: prev.scoring.patternId || firstPreset.id,
customScoringEnabled: prev.scoring.customScoringEnabled ?? false,
},
}));
}
} catch (err) {
setErrors((prev) => ({
...prev,
submit:
err instanceof Error
? err.message
: 'Failed to load scoring presets',
}));
} finally {
setPresetsLoading(false);
}
}
// Use the react-query hook for scoring presets
const { data: queryPresets, error: presetsError } = useLeagueScoringPresets();
loadPresets();
}, []);
// Sync presets from query to local state
useEffect(() => {
if (queryPresets) {
setPresets(queryPresets);
const firstPreset = queryPresets[0];
if (firstPreset && !form.scoring?.patternId) {
setForm((prev) => ({
...prev,
scoring: {
...prev.scoring,
patternId: firstPreset.id,
customScoringEnabled: false,
},
}));
}
setPresetsLoading(false);
}
}, [queryPresets, form.scoring?.patternId]);
// Handle presets error
useEffect(() => {
if (presetsError) {
setErrors((prev) => ({
...prev,
submit: presetsError instanceof Error ? presetsError.message : 'Failed to load scoring presets',
}));
}
}, [presetsError]);
// Use the create league mutation
const createLeagueMutation = useCreateLeagueWizard();
const validateStep = (currentStep: Step): boolean => {
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(form, currentStep);
// Convert form to LeagueWizardFormData for validation
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
description: form.basics?.description || '',
visibility: (form.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: form.basics?.gameId || 'iracing',
},
structure: {
mode: (form.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: form.structure?.maxDrivers || 0,
maxTeams: form.structure?.maxTeams || 0,
driversPerTeam: form.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: form.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: form.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: form.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: form.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: form.scoring?.patternId || '',
customScoringEnabled: form.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (form.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: form.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: form.timings?.practiceMinutes || 0,
qualifyingMinutes: form.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: form.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: form.timings?.mainRaceMinutes || 0,
sessionCount: form.timings?.sessionCount || 0,
roundsPlanned: form.timings?.roundsPlanned || 0,
},
stewarding: {
decisionMode: (form.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: form.stewarding?.requiredVotes || 0,
requireDefense: form.stewarding?.requireDefense ?? false,
defenseTimeLimit: form.stewarding?.defenseTimeLimit || 0,
voteTimeLimit: form.stewarding?.voteTimeLimit || 0,
protestDeadlineHours: form.stewarding?.protestDeadlineHours || 0,
stewardingClosesHours: form.stewarding?.stewardingClosesHours || 0,
notifyAccusedOnProtest: form.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: form.stewarding?.notifyOnVoteRequired ?? true,
},
};
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep);
setErrors((prev) => ({
...prev,
...stepErrors,
@@ -354,7 +407,57 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
return;
}
const allErrors = LeagueWizardCommandModel.validateAllLeagueWizardSteps(form);
// Convert form to LeagueWizardFormData for validation
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
description: form.basics?.description || '',
visibility: (form.basics?.visibility as 'public' | 'private' | 'unlisted') || 'public',
gameId: form.basics?.gameId || 'iracing',
},
structure: {
mode: (form.structure?.mode as 'solo' | 'fixedTeams') || 'solo',
maxDrivers: form.structure?.maxDrivers || 0,
maxTeams: form.structure?.maxTeams || 0,
driversPerTeam: form.structure?.driversPerTeam || 0,
},
championships: {
enableDriverChampionship: form.championships?.enableDriverChampionship ?? true,
enableTeamChampionship: form.championships?.enableTeamChampionship ?? false,
enableNationsChampionship: form.championships?.enableNationsChampionship ?? false,
enableTrophyChampionship: form.championships?.enableTrophyChampionship ?? false,
},
scoring: {
patternId: form.scoring?.patternId || '',
customScoringEnabled: form.scoring?.customScoringEnabled ?? false,
},
dropPolicy: {
strategy: (form.dropPolicy?.strategy as 'none' | 'bestNResults' | 'dropWorstN') || 'bestNResults',
n: form.dropPolicy?.n || 6,
},
timings: {
practiceMinutes: form.timings?.practiceMinutes || 0,
qualifyingMinutes: form.timings?.qualifyingMinutes || 0,
sprintRaceMinutes: form.timings?.sprintRaceMinutes || 0,
mainRaceMinutes: form.timings?.mainRaceMinutes || 0,
sessionCount: form.timings?.sessionCount || 0,
roundsPlanned: form.timings?.roundsPlanned || 0,
},
stewarding: {
decisionMode: (form.stewarding?.decisionMode as 'owner_only' | 'admin_vote' | 'steward_panel') || 'admin_only',
requiredVotes: form.stewarding?.requiredVotes || 0,
requireDefense: form.stewarding?.requireDefense ?? false,
defenseTimeLimit: form.stewarding?.defenseTimeLimit || 0,
voteTimeLimit: form.stewarding?.voteTimeLimit || 0,
protestDeadlineHours: form.stewarding?.protestDeadlineHours || 0,
stewardingClosesHours: form.stewarding?.stewardingClosesHours || 0,
notifyAccusedOnProtest: form.stewarding?.notifyAccusedOnProtest ?? true,
notifyOnVoteRequired: form.stewarding?.notifyOnVoteRequired ?? true,
},
};
const allErrors = LeagueWizardCommandModel.validateAllLeagueWizardSteps(formData);
setErrors((prev) => ({
...prev,
...allErrors,
@@ -372,9 +475,13 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
});
try {
const result = await LeagueWizardService.createLeagueFromConfig(form, ownerId);
// Use the mutation to create the league
const result = await createLeagueMutation.mutateAsync({ form, ownerId });
// Clear the draft on successful creation
clearFormStorage();
// Navigate to the new league
router.push(`/leagues/${result.leagueId}`);
} catch (err) {
const message =
@@ -387,12 +494,79 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
};
const currentPreset =
presets.find((p) => p.id === form.scoring.patternId) ?? null;
// Handler for scoring preset selection - delegates to application-level config helper
const handleScoringPresetChange = (patternId: string) => {
setForm((prev) => LeagueWizardCommandModel.applyScoringPresetToConfig(prev, patternId));
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 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;
});
};
const steps = [
@@ -723,7 +897,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
</div>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring}
scoring={form.scoring || {}}
presets={presets}
readOnly={presetsLoading}
patternError={errors.scoring?.patternId ?? ''}
@@ -733,7 +907,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
...prev,
scoring: {
...prev.scoring,
customScoringEnabled: !prev.scoring.customScoringEnabled,
customScoringEnabled: !(prev.scoring?.customScoringEnabled),
},
}))
}
@@ -744,8 +918,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
{/* Championships & Drop Rules side by side on larger screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
<ChampionshipsSection form={form} onChange={setForm as any} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={setForm as any} readOnly={false} />
</div>
{errors.submit && (

View File

@@ -3,7 +3,7 @@
import React from 'react';
import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react';
import Input from '@/components/ui/Input';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
interface LeagueBasicsSectionProps {
form: LeagueConfigFormModel;

View File

@@ -12,8 +12,9 @@ import {
ChevronRight,
Sparkles,
} from 'lucide-react';
import type { LeagueSummaryViewModel } from '@core/racing/application/presenters/IAllLeaguesWithCapacityAndScoringPresenter';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { getLeagueCoverClasses } from '@/lib/leagueCovers';
import { useServices } from '@/lib/services/ServiceProvider';
interface LeagueCardProps {
league: LeagueSummaryViewModel;
@@ -71,9 +72,9 @@ function isNewLeague(createdAt: string | Date): boolean {
}
export default function LeagueCard({ league, onClick }: LeagueCardProps) {
const imageService = getImageService();
const coverUrl = imageService.getLeagueCover(league.id);
const logoUrl = imageService.getLeagueLogo(league.id);
const { mediaService } = useServices();
const coverUrl = mediaService.getLeagueCover(league.id);
const logoUrl = mediaService.getLeagueLogo(league.id);
const ChampionshipIcon = getChampionshipIcon(league.scoring?.primaryChampionshipType);
const championshipLabel = getChampionshipLabel(league.scoring?.primaryChampionshipType);

View File

@@ -3,7 +3,7 @@
import React, { useState, useRef, useEffect } from 'react';
import { TrendingDown, Check, HelpCircle, X, Zap } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// ============================================================================
// INFO FLYOUT (duplicated for self-contained component)
@@ -245,7 +245,7 @@ export function LeagueDropSection({
readOnly,
}: LeagueDropSectionProps) {
const disabled = readOnly || !onChange;
const dropPolicy = form.dropPolicy;
const dropPolicy = form.dropPolicy || { strategy: 'none' as const };
const [showDropFlyout, setShowDropFlyout] = useState(false);
const [activeDropRuleFlyout, setActiveDropRuleFlyout] = useState<DropStrategy | null>(null);
const dropInfoRef = useRef<HTMLButtonElement>(null);

View File

@@ -3,8 +3,8 @@
import DriverIdentity from '../drivers/DriverIdentity';
import { useEffectiveDriverId } from '../../hooks/useEffectiveDriverId';
import { useServices } from '../../lib/services/ServiceProvider';
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
import type { MembershipRole } from '@/lib/types/MembershipRole';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import { useCallback, useEffect, useState } from 'react';
@@ -66,10 +66,16 @@ export default function LeagueMembers({
};
const getRoleOrder = (role: MembershipRole): number => {
const order = { owner: 0, admin: 1, steward: 2, member: 3 };
const order: Record<MembershipRole, number> = { owner: 0, admin: 1, steward: 2, member: 3 };
return order[role];
};
const getDriverStats = (driverId: string): { rating: number; wins: number; overallRank: number } | null => {
// This would typically come from a driver stats service
// For now, return null as the original implementation was missing
return null;
};
const sortedMembers = [...members].sort((a, b) => {
switch (sortBy) {
case 'role':
@@ -105,6 +111,8 @@ export default function LeagueMembers({
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
case 'member':
return 'bg-primary-blue/10 text-primary-blue border-primary-blue/30';
default:
return 'bg-gray-500/10 text-gray-400 border-gray-500/30';
}
};

View File

@@ -3,8 +3,8 @@
import React, { useState, useRef, useEffect } from 'react';
import { Trophy, Award, Check, Zap, Settings, Globe, Medal, Plus, Minus, RotateCcw, HelpCircle, X } from 'lucide-react';
import { createPortal } from 'react-dom';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueScoringPresetDTO } from '@/hooks/useLeagueScoringPresets';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// ============================================================================
// INFO FLYOUT COMPONENT

View File

@@ -7,7 +7,7 @@ import Button from '../ui/Button';
import Input from '../ui/Input';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { PendingSponsorshipRequestsPresenter } from '@/lib/presenters/PendingSponsorshipRequestsPresenter';
import { useServices } from '@/lib/services/ServiceProvider';
interface SponsorshipSlot {
tier: 'main' | 'secondary';
@@ -29,6 +29,7 @@ export function LeagueSponsorshipsSection({
readOnly = false
}: LeagueSponsorshipsSectionProps) {
const currentDriverId = useEffectiveDriverId();
const { sponsorshipService } = useServices();
const [slots, setSlots] = useState<SponsorshipSlot[]>([
{ tier: 'main', price: 500, isOccupied: false },
{ tier: 'secondary', price: 200, isOccupied: false },

View File

@@ -4,8 +4,7 @@ import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react';
import { useState, useRef, useEffect, useMemo } from 'react';
import { createPortal } from 'react-dom';
import Input from '@/components/ui/Input';
import type { LeagueConfigFormModel } from '@core/racing/application';
import { GameConstraints } from '@core/racing/domain/value-objects/GameConstraints';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// ============================================================================
// INFO FLYOUT COMPONENT
@@ -233,8 +232,16 @@ export function LeagueStructureSection({
// Get game-specific constraints
const gameConstraints = useMemo(
() => GameConstraints.forGame(form.basics.gameId),
[form.basics.gameId]
() => ({
minDrivers: 1,
maxDrivers: 100,
defaultMaxDrivers: 24,
minTeams: 1,
maxTeams: 50,
minDriversPerTeam: 1,
maxDriversPerTeam: 10,
}),
[form.basics?.gameId]
);
return (

View File

@@ -17,13 +17,9 @@ import {
Globe,
MapPin,
Pencil,
Link2,
} from 'lucide-react';
import type {
LeagueConfigFormModel,
LeagueSchedulePreviewDTO,
} from '@core/racing/application';
import type { Weekday } from '@core/racing/domain/types/Weekday';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { Weekday } from '@/lib/types/Weekday';
import Input from '@/components/ui/Input';
import RangeField from '@/components/ui/RangeField';

View File

@@ -3,7 +3,7 @@
import { Trophy, Users, Check, HelpCircle, X } from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import type { LeagueConfigFormModel } from '@core/racing/application';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
// Minimum drivers for ranked leagues
const MIN_RANKED_DRIVERS = 10;
@@ -132,14 +132,14 @@ export function LeagueVisibilitySection({
const unrankedInfoRef = useRef<HTMLButtonElement>(null);
// Normalize visibility to new terminology
const isRanked = basics.visibility === 'ranked' || basics.visibility === 'public';
const isRanked = basics.visibility === 'public';
// Auto-update minDrivers when switching to ranked
const handleVisibilityChange = (visibility: 'ranked' | 'unranked') => {
const handleVisibilityChange = (visibility: 'public' | 'private' | 'unlisted') => {
if (!onChange) return;
// If switching to ranked and current maxDrivers is below minimum, update it
if (visibility === 'ranked' && form.structure.maxDrivers < MIN_RANKED_DRIVERS) {
// If switching to public and current maxDrivers is below minimum, update it
if (visibility === 'public' && (form.structure?.maxDrivers ?? 0) < MIN_RANKED_DRIVERS) {
onChange({
...form,
basics: { ...form.basics, visibility },
@@ -172,7 +172,7 @@ export function LeagueVisibilitySection({
<button
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('ranked')}
onClick={() => handleVisibilityChange('public')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
isRanked
? 'border-primary-blue bg-gradient-to-br from-primary-blue/15 to-primary-blue/5 shadow-[0_0_30px_rgba(25,140,255,0.25)]'
@@ -293,7 +293,7 @@ export function LeagueVisibilitySection({
<button
type="button"
disabled={disabled}
onClick={() => handleVisibilityChange('unranked')}
onClick={() => handleVisibilityChange('private')}
className={`w-full group relative flex flex-col gap-4 p-6 text-left rounded-xl border-2 transition-all duration-200 ${
!isRanked
? 'border-neon-aqua bg-gradient-to-br from-neon-aqua/15 to-neon-aqua/5 shadow-[0_0_30px_rgba(67,201,230,0.2)]'

View File

@@ -4,14 +4,8 @@ import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input';
import { useServices } from '@/lib/services/ServiceProvider';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO';
import {
loadTeamAdminViewModel,
approveTeamJoinRequestAndReload,
rejectTeamJoinRequestAndReload,
updateTeamDetails,
type TeamAdminJoinRequestViewModel,
} from '@/lib/presenters/TeamAdminPresenter';
interface TeamAdminProps {
team: {

View File

@@ -3,10 +3,7 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import DriverIdentity from '@/components/drivers/DriverIdentity';
import {
getTeamRosterViewModel,
type TeamRosterViewModel,
} from '@/lib/presenters/TeamRosterPresenter';
import { useServices } from '@/lib/services/ServiceProvider';
import type { TeamRole } from '@core/racing/domain/types/TeamMembership';
interface TeamMembershipSummary {
@@ -30,7 +27,8 @@ export default function TeamRoster({
onRemoveMember,
onChangeRole,
}: TeamRosterProps) {
const [viewModel, setViewModel] = useState<TeamRosterViewModel | null>(null);
const { teamService, driverService } = useServices();
const [teamMembers, setTeamMembers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [sortBy, setSortBy] = useState<'role' | 'rating' | 'name'>('rating');
@@ -38,22 +36,29 @@ export default function TeamRoster({
const load = async () => {
setLoading(true);
try {
const fullMemberships = memberships.map((m) => ({
teamId,
driverId: m.driverId,
role: m.role,
joinedAt: m.joinedAt,
status: 'active' as const,
}));
const vm = await getTeamRosterViewModel(fullMemberships);
setViewModel(vm);
// Get driver details for each membership
const membersWithDetails = await Promise.all(
memberships.map(async (m) => {
const driver = await driverService.findById(m.driverId);
return {
driver: driver || { id: m.driverId, name: 'Unknown Driver', country: 'Unknown', position: 'N/A', races: '0', impressions: '0', team: 'None' },
role: m.role,
joinedAt: m.joinedAt,
rating: null, // DriverDTO doesn't include rating
overallRank: null, // DriverDTO doesn't include overallRank
};
})
);
setTeamMembers(membersWithDetails);
} catch (error) {
console.error('Failed to load team roster:', error);
} finally {
setLoading(false);
}
};
void load();
}, [memberships]);
}, [memberships, teamService, driverService]);
const getRoleBadgeColor = (role: TeamRole) => {
switch (role) {
@@ -83,27 +88,27 @@ export default function TeamRoster({
}
}
const sortedMembers = viewModel
? [...viewModel.members].sort((a, b) => {
switch (sortBy) {
case 'rating': {
const ratingA = a.rating ?? 0;
const ratingB = b.rating ?? 0;
return ratingB - ratingA;
}
case 'role': {
return getRoleOrder(a.role) - getRoleOrder(b.role);
}
case 'name': {
return a.driver.name.localeCompare(b.driver.name);
}
default:
return 0;
}
})
: [];
const sortedMembers = [...teamMembers].sort((a, b) => {
switch (sortBy) {
case 'rating': {
const ratingA = a.rating ?? 0;
const ratingB = b.rating ?? 0;
return ratingB - ratingA;
}
case 'role': {
return getRoleOrder(a.role) - getRoleOrder(b.role);
}
case 'name': {
return a.driver.name.localeCompare(b.driver.name);
}
default:
return 0;
}
});
const teamAverageRating = viewModel?.averageRating ?? 0;
const teamAverageRating = teamMembers.length > 0
? teamMembers.reduce((sum, m) => sum + (m.rating || 0), 0) / teamMembers.length
: 0;
if (loading) {
return (

View File

@@ -2,10 +2,7 @@
import { useState, useEffect } from 'react';
import Card from '@/components/ui/Card';
import {
loadTeamStandings,
type TeamLeagueStandingViewModel,
} from '@/lib/presenters/TeamStandingsPresenter';
import { useServices } from '@/lib/services/ServiceProvider';
interface TeamStandingsProps {
teamId: string;
@@ -13,14 +10,25 @@ interface TeamStandingsProps {
}
export default function TeamStandings({ teamId, leagues }: TeamStandingsProps) {
const [standings, setStandings] = useState<TeamLeagueStandingViewModel[]>([]);
const { leagueService } = useServices();
const [standings, setStandings] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const load = async () => {
try {
const viewModel = await loadTeamStandings(teamId, leagues);
setStandings(viewModel.standings);
// For demo purposes, create mock standings
const mockStandings = leagues.map(leagueId => ({
leagueId,
leagueName: `League ${leagueId}`,
position: Math.floor(Math.random() * 10) + 1,
points: Math.floor(Math.random() * 100),
wins: Math.floor(Math.random() * 5),
racesCompleted: Math.floor(Math.random() * 10),
}));
setStandings(mockStandings);
} catch (error) {
console.error('Failed to load standings:', error);
} finally {
setLoading(false);
}

View File

@@ -0,0 +1,15 @@
import { useQuery } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
export function useLeagueScoringPresets() {
const { leagueService } = useServices();
return useQuery({
queryKey: ['leagueScoringPresets'],
queryFn: async () => {
const result = await leagueService.getScoringPresets();
return result as LeagueScoringPresetDTO[];
},
});
}

View File

@@ -0,0 +1,82 @@
import { useMutation } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider';
import { CreateLeagueInputDTO } from '@/lib/types/generated/CreateLeagueInputDTO';
import { CreateLeagueOutputDTO } from '@/lib/types/generated/CreateLeagueOutputDTO';
export interface LeagueWizardFormModel {
leagueId?: string;
basics?: {
name?: string;
description?: string;
visibility?: string;
gameId?: string;
};
structure?: {
mode?: string;
maxDrivers?: number;
maxTeams?: number;
driversPerTeam?: number;
multiClassEnabled?: boolean;
};
championships?: {
enableDriverChampionship?: boolean;
enableTeamChampionship?: boolean;
enableNationsChampionship?: boolean;
enableTrophyChampionship?: boolean;
};
scoring?: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy?: {
strategy?: string;
n?: number;
};
timings?: {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
weekdays?: string[];
recurrenceStrategy?: string;
timezoneId?: string;
seasonStartDate?: string;
};
stewarding?: {
decisionMode?: string;
requiredVotes?: number;
requireDefense?: boolean;
defenseTimeLimit?: number;
voteTimeLimit?: number;
protestDeadlineHours?: number;
stewardingClosesHours?: number;
notifyAccusedOnProtest?: boolean;
notifyOnVoteRequired?: boolean;
};
seasonName?: string;
}
export function useCreateLeagueWizard() {
const { leagueService } = useServices();
return useMutation({
mutationFn: async (params: { form: LeagueWizardFormModel; ownerId: string }): Promise<CreateLeagueOutputDTO> => {
// Convert form to CreateLeagueInputDTO
const input: CreateLeagueInputDTO = {
name: params.form.basics?.name?.trim() ?? '',
description: params.form.basics?.description?.trim() ?? '',
visibility: (params.form.basics?.visibility as 'public' | 'private') ?? 'public',
ownerId: params.ownerId,
};
// Use the league service to create the league
const result = await leagueService.createLeague(input);
return result;
},
});
}

View File

@@ -10,6 +10,8 @@ import { PaymentsApiClient } from './payments/PaymentsApiClient';
import { DashboardApiClient } from './dashboard/DashboardApiClient';
import { PenaltiesApiClient } from './penalties/PenaltiesApiClient';
import { ProtestsApiClient } from './protests/ProtestsApiClient';
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
/**
* Main API Client
@@ -31,18 +33,21 @@ export class ApiClient {
public readonly protests: ProtestsApiClient;
constructor(baseUrl: string) {
this.leagues = new LeaguesApiClient(baseUrl);
this.races = new RacesApiClient(baseUrl);
this.drivers = new DriversApiClient(baseUrl);
this.teams = new TeamsApiClient(baseUrl);
this.sponsors = new SponsorsApiClient(baseUrl);
this.media = new MediaApiClient(baseUrl);
this.analytics = new AnalyticsApiClient(baseUrl);
this.auth = new AuthApiClient(baseUrl);
this.payments = new PaymentsApiClient(baseUrl);
this.dashboard = new DashboardApiClient(baseUrl);
this.penalties = new PenaltiesApiClient(baseUrl);
this.protests = new ProtestsApiClient(baseUrl);
const logger = new ConsoleLogger();
const errorReporter = new ConsoleErrorReporter();
this.leagues = new LeaguesApiClient(baseUrl, errorReporter, logger);
this.races = new RacesApiClient(baseUrl, errorReporter, logger);
this.drivers = new DriversApiClient(baseUrl, errorReporter, logger);
this.teams = new TeamsApiClient(baseUrl, errorReporter, logger);
this.sponsors = new SponsorsApiClient(baseUrl, errorReporter, logger);
this.media = new MediaApiClient(baseUrl, errorReporter, logger);
this.analytics = new AnalyticsApiClient(baseUrl, errorReporter, logger);
this.auth = new AuthApiClient(baseUrl, errorReporter, logger);
this.payments = new PaymentsApiClient(baseUrl, errorReporter, logger);
this.dashboard = new DashboardApiClient(baseUrl, errorReporter, logger);
this.penalties = new PenaltiesApiClient(baseUrl, errorReporter, logger);
this.protests = new ProtestsApiClient(baseUrl, errorReporter, logger);
}
}

View File

@@ -0,0 +1,24 @@
import { LeagueMembershipService } from './services/leagues/LeagueMembershipService';
/**
* Get membership for a driver in a league
*/
export function getMembership(leagueId: string, driverId: string) {
return LeagueMembershipService.getMembership(leagueId, driverId);
}
/**
* Get all members of a league
*/
export function getLeagueMembers(leagueId: string) {
return LeagueMembershipService.getLeagueMembers(leagueId);
}
/**
* Get primary league ID for a driver (first league they joined)
*/
export function getPrimaryLeagueIdForDriver(driverId: string): string | null {
const memberships = LeagueMembershipService.getAllMembershipsForDriver(driverId);
if (memberships.length === 0) return null;
return memberships[0].leagueId;
}

View File

@@ -0,0 +1,8 @@
// Re-export from utilities for backward compatibility
export { LeagueRoleUtility } from './utilities/LeagueRoleUtility';
export { LeagueMembershipUtility } from './utilities/LeagueMembershipUtility';
// Direct function export for convenience
export const isLeagueAdminOrHigherRole = (role: string): boolean => {
return role === 'owner' || role === 'admin' || role === 'steward';
};

View File

@@ -2,6 +2,7 @@ import { AuthApiClient } from '../../api/auth/AuthApiClient';
import { SessionViewModel } from '../../view-models/SessionViewModel';
import type { LoginParams } from '../../types/generated/LoginParams';
import type { SignupParams } from '../../types/generated/SignupParams';
import type { LoginWithIracingCallbackParams } from '../../types/generated/LoginWithIracingCallbackParams';
/**
* Auth Service
@@ -55,4 +56,16 @@ export class AuthService {
getIracingAuthUrl(returnTo?: string): string {
return this.apiClient.getIracingAuthUrl(returnTo);
}
/**
* Login with iRacing callback
*/
async loginWithIracingCallback(params: LoginWithIracingCallbackParams): Promise<SessionViewModel> {
try {
const dto = await this.apiClient.loginWithIracingCallback(params);
return new SessionViewModel(dto.user);
} catch (error) {
throw error;
}
}
}

View File

@@ -1,5 +1,5 @@
import type { DriversApiClient } from '../../api/drivers/DriversApiClient';
import { DriverRegistrationStatusViewModel } from '../../view-models';
import { DriverRegistrationStatusViewModel } from '../../view-models/DriverRegistrationStatusViewModel';
/**
* Driver Registration Service

View File

@@ -1,7 +1,5 @@
import { apiClient } from '@/lib/apiClient';
import type { LeagueMembership } from '@core/racing/domain/entities/LeagueMembership';
import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole';
import type { MembershipStatus } from '@core/racing/domain/entities/MembershipStatus';
import type { LeagueMembership } from '@/lib/types/LeagueMembership';
export class LeagueMembershipService {
// In-memory cache for memberships (populated via API calls)
@@ -33,12 +31,12 @@ export class LeagueMembershipService {
static async fetchLeagueMemberships(leagueId: string): Promise<LeagueMembership[]> {
try {
const result = await apiClient.leagues.getMemberships(leagueId);
const memberships: LeagueMembership[] = result.members.map(member => ({
const memberships: LeagueMembership[] = result.members.map((member: any) => ({
id: `${member.driverId}-${leagueId}`, // Generate ID since API doesn't provide it
leagueId,
driverId: member.driverId,
role: member.role as MembershipRole,
status: 'active' as MembershipStatus, // Assume active since API returns current members
role: member.role,
status: 'active', // Assume active since API returns current members
joinedAt: member.joinedAt,
}));
this.setLeagueMemberships(leagueId, memberships);
@@ -70,6 +68,20 @@ export class LeagueMembershipService {
return this.leagueMemberships.entries();
}
/**
* Get all memberships for a specific driver across all leagues.
*/
static getAllMembershipsForDriver(driverId: string): LeagueMembership[] {
const allMemberships: LeagueMembership[] = [];
for (const [leagueId, memberships] of this.leagueMemberships.entries()) {
const driverMembership = memberships.find(m => m.driverId === driverId);
if (driverMembership) {
allMemberships.push(driverMembership);
}
}
return allMemberships;
}
// Instance methods that delegate to static methods for consistency with service pattern
/**

View File

@@ -3,6 +3,7 @@ import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
import { CreateLeagueInputDTO } from "@/lib/types/generated/CreateLeagueInputDTO";
import { CreateLeagueOutputDTO } from "@/lib/types/generated/CreateLeagueOutputDTO";
import { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO";
import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel";
import { LeagueMembershipsViewModel } from "@/lib/view-models/LeagueMembershipsViewModel";
@@ -11,14 +12,12 @@ import { LeagueStandingsViewModel } from "@/lib/view-models/LeagueStandingsViewM
import { LeagueStatsViewModel } from "@/lib/view-models/LeagueStatsViewModel";
import { LeagueSummaryViewModel } from "@/lib/view-models/LeagueSummaryViewModel";
import { RemoveMemberViewModel } from "@/lib/view-models/RemoveMemberViewModel";
import { LeagueDetailViewModel } from "@/lib/view-models/LeagueDetailViewModel";
import { LeaguePageDetailViewModel } from "@/lib/view-models/LeaguePageDetailViewModel";
import { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
import { RaceDTO } from "@/lib/types/generated/RaceDTO";
import { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
import { LeagueMembershipsDTO } from "@/lib/types/generated/LeagueMembershipsDTO";
/**
@@ -43,7 +42,17 @@ export class LeagueService {
*/
async getAllLeagues(): Promise<LeagueSummaryViewModel[]> {
const dto = await this.apiClient.getAllWithCapacity();
return dto.leagues.map((league: LeagueWithCapacityDTO) => new LeagueSummaryViewModel(league));
return dto.leagues.map((league: LeagueWithCapacityDTO) => ({
id: league.id,
name: league.name,
description: league.description ?? '',
ownerId: league.ownerId,
createdAt: '', // Not provided by API
maxDrivers: league.maxMembers,
usedDriverSlots: league.memberCount,
structureSummary: 'TBD',
timingSummary: 'TBD'
}));
}
/**
@@ -57,8 +66,8 @@ export class LeagueService {
const membershipsDto = await this.apiClient.getMemberships(leagueId);
// Resolve unique drivers that appear in standings
const driverIds = Array.from(new Set(dto.standings.map(entry => entry.driverId)));
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const driverIds: string[] = Array.from(new Set(dto.standings.map((entry: any) => entry.driverId)));
const driverDtos = await Promise.all(driverIds.map((id: string) => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
const dtoWithExtras = {
@@ -95,15 +104,17 @@ export class LeagueService {
}
/**
* Create a new league
*/
async createLeague(input: CreateLeagueInputDTO): Promise<void> {
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) return;
* Create a new league
*/
async createLeague(input: CreateLeagueInputDTO): Promise<CreateLeagueOutputDTO> {
if (!this.submitBlocker.canExecute() || !this.throttle.canExecute()) {
throw new Error('Cannot execute at this time');
}
this.submitBlocker.block();
this.throttle.block();
try {
await this.apiClient.create(input);
return await this.apiClient.create(input);
} finally {
this.submitBlocker.release();
}
@@ -127,12 +138,12 @@ export class LeagueService {
/**
* Get league detail with owner, membership, and sponsor info
*/
async getLeagueDetail(leagueId: string, currentDriverId: string): Promise<LeagueDetailViewModel | null> {
async getLeagueDetail(leagueId: string, currentDriverId: string): Promise<LeaguePageDetailViewModel | null> {
// For now, assume league data comes from getAllWithCapacity or a new endpoint
// Since API may not have detailed league, we'll mock or assume
// In real implementation, add getLeagueDetail to API
const allLeagues = await this.apiClient.getAllWithCapacity();
const leagueDto = allLeagues.leagues.find(l => l.id === leagueId);
const leagueDto = allLeagues.leagues.find((l: any) => l.id === leagueId);
if (!leagueDto) return null;
// LeagueWithCapacityDTO already carries core fields; fall back to placeholder description/owner when not provided
@@ -149,7 +160,7 @@ export class LeagueService {
// Get membership
const membershipsDto = await this.apiClient.getMemberships(leagueId);
const membership = membershipsDto.members.find(m => m.driverId === currentDriverId);
const membership = membershipsDto.members.find((m: any) => m.driverId === currentDriverId);
const isAdmin = membership ? ['admin', 'owner'].includes(membership.role) : false;
// Get main sponsor
@@ -175,15 +186,31 @@ export class LeagueService {
console.warn('Failed to load main sponsor:', error);
}
return new LeagueDetailViewModel(
league.id,
league.name,
league.description,
league.ownerId,
ownerName,
mainSponsor,
isAdmin
);
return new LeaguePageDetailViewModel({
league: {
id: league.id,
name: league.name,
game: 'iRacing',
tier: 'standard',
season: 'Season 1',
description: league.description,
drivers: 0,
races: 0,
completedRaces: 0,
totalImpressions: 0,
avgViewsPerRace: 0,
engagement: 0,
rating: 0,
seasonStatus: 'active',
seasonDates: { start: new Date().toISOString(), end: new Date().toISOString() },
sponsorSlots: {
main: { available: true, price: 800, benefits: [] },
secondary: { available: 2, total: 2, price: 250, benefits: [] }
}
},
drivers: [],
races: []
});
}
/**
@@ -193,7 +220,7 @@ export class LeagueService {
try {
// Get league basic info
const allLeagues = await this.apiClient.getAllWithCapacity();
const league = allLeagues.leagues.find(l => l.id === leagueId);
const league = allLeagues.leagues.find((l: any) => l.id === leagueId);
if (!league) return null;
// Get owner
@@ -206,7 +233,7 @@ export class LeagueService {
const memberships = await this.apiClient.getMemberships(leagueId);
const driverIds = memberships.members.map(m => m.driverId);
const driverDtos = await Promise.all(driverIds.map(id => this.driversApiClient.getDriver(id)));
const drivers = driverDtos.filter((d): d is NonNullable<typeof d> => d !== null);
const drivers = driverDtos.filter((d: any): d is NonNullable<typeof d> => d !== null);
// Get all races for this league via the leagues API helper
const leagueRaces = await this.apiClient.getRaces(leagueId);
@@ -276,4 +303,12 @@ export class LeagueService {
return [];
}
}
/**
* Get league scoring presets
*/
async getScoringPresets(): Promise<any[]> {
const result = await this.apiClient.getScoringPresets();
return result.presets;
}
}

View File

@@ -54,4 +54,18 @@ export class MediaService {
getDriverAvatar(driverId: string): string {
return `/api/media/avatar/${driverId}`;
}
/**
* Get league cover URL
*/
getLeagueCover(leagueId: string): string {
return `/api/media/leagues/${leagueId}/cover`;
}
/**
* Get league logo URL
*/
getLeagueLogo(leagueId: string): string {
return `/api/media/leagues/${leagueId}/logo`;
}
}

View File

@@ -1,10 +1,8 @@
import type { SponsorsApiClient } from '../../api/sponsors/SponsorsApiClient';
import type { GetEntitySponsorshipPricingResultDto } from '../../api/sponsors/SponsorsApiClient';
import {
SponsorshipPricingViewModel,
SponsorSponsorshipsViewModel,
SponsorshipRequestViewModel
} from '../../view-models';
import { SponsorshipPricingViewModel } from '../../view-models/SponsorshipPricingViewModel';
import { SponsorSponsorshipsViewModel } from '../../view-models/SponsorSponsorshipsViewModel';
import { SponsorshipRequestViewModel } from '../../view-models/SponsorshipRequestViewModel';
import type { SponsorSponsorshipsDTO } from '../../types/generated';
/**

View File

@@ -0,0 +1,10 @@
export interface League {
id: string;
name: string;
description: string;
ownerId: string;
isPublic: boolean;
maxMembers: number;
createdAt: string;
updatedAt: string;
}

View File

@@ -0,0 +1,54 @@
export interface LeagueConfigFormModel {
leagueId?: string;
basics?: {
name: string;
description?: string;
visibility: 'public' | 'private' | 'unlisted';
gameId: string;
};
structure?: {
mode: 'solo' | 'fixedTeams';
maxDrivers?: number;
maxTeams?: number;
driversPerTeam?: number;
};
championships?: {
enableDriverChampionship: boolean;
enableTeamChampionship: boolean;
enableNationsChampionship: boolean;
enableTrophyChampionship: boolean;
};
scoring?: {
patternId?: string;
customScoringEnabled?: boolean;
};
dropPolicy?: {
strategy: 'none' | 'bestNResults' | 'dropWorstN';
n?: number;
};
timings?: {
practiceMinutes?: number;
qualifyingMinutes?: number;
sprintRaceMinutes?: number;
mainRaceMinutes?: number;
sessionCount?: number;
roundsPlanned?: number;
raceDayOfWeek?: number;
raceTimeUtc?: string;
weekdays?: string[];
recurrenceStrategy?: string;
timezoneId?: string;
seasonStartDate?: string;
};
stewarding?: {
decisionMode: 'owner_only' | 'admin_vote' | 'steward_panel';
requiredVotes?: number;
requireDefense: boolean;
defenseTimeLimit: number;
voteTimeLimit: number;
protestDeadlineHours: number;
stewardingClosesHours: number;
notifyAccusedOnProtest: boolean;
notifyOnVoteRequired: boolean;
};
}

View File

@@ -0,0 +1,7 @@
export interface LeagueMembership {
driverId: string;
leagueId: string;
role: 'owner' | 'admin' | 'steward' | 'member';
joinedAt: string;
status: 'active' | 'pending' | 'banned';
}

View File

@@ -0,0 +1,5 @@
export interface LeagueScoringConfigDTO {
patternId: string;
customScoringEnabled: boolean;
points: Record<string, number>;
}

View File

@@ -0,0 +1 @@
export type MembershipRole = 'owner' | 'admin' | 'steward' | 'member';

View File

@@ -0,0 +1 @@
export type Weekday = 'Mon' | 'Tue' | 'Wed' | 'Thu' | 'Fri' | 'Sat' | 'Sun';

View File

@@ -0,0 +1,21 @@
export interface WizardErrors {
basics?: {
name?: string;
description?: string;
visibility?: string;
};
structure?: {
maxDrivers?: string;
maxTeams?: string;
driversPerTeam?: string;
};
timings?: {
qualifyingMinutes?: string;
mainRaceMinutes?: string;
roundsPlanned?: string;
};
scoring?: {
patternId?: string;
};
submit?: string;
}

View File

@@ -4,6 +4,14 @@
* Do not edit manually - regenerate using: npm run api:sync-types
*/
import type { DriverDTO } from './DriverDTO';
export interface LeagueStandingDTO {
driverId: string;
driver: DriverDTO;
points: number;
position: number;
wins: number;
podiums: number;
races: number;
}

View File

@@ -1,9 +0,0 @@
/**
* Auto-generated DTO from OpenAPI spec
* This file is generated by scripts/generate-api-types.ts
* Do not edit manually - regenerate using: npm run api:sync-types
*/
export interface PaymentDTO {
id: string;
}

View File

@@ -1,5 +1,5 @@
import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole';
type LeagueRole = MembershipRole;
import type { MembershipRoleDTO } from '@/lib/types/generated/MembershipRoleDTO';
type LeagueRole = MembershipRoleDTO['value'];
export class LeagueRoleUtility {
static isLeagueOwnerRole(role: LeagueRole): boolean {

View File

@@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, expect, it } from 'vitest';
import { AvailableLeaguesViewModel, AvailableLeagueViewModel } from './AvailableLeaguesViewModel';
describe('AvailableLeaguesViewModel', () => {
@@ -22,9 +22,9 @@ describe('AvailableLeaguesViewModel', () => {
expect(vm.leagues).toHaveLength(1);
expect(vm.leagues[0]).toBeInstanceOf(AvailableLeagueViewModel);
expect(vm.leagues[0].id).toBe(baseLeague.id);
expect(vm.leagues[0].name).toBe(baseLeague.name);
expect(vm.leagues[0].avgViewsPerRace).toBe(baseLeague.avgViewsPerRace);
expect(vm.leagues[0]?.id).toBe(baseLeague.id);
expect(vm.leagues[0]?.name).toBe(baseLeague.name);
expect(vm.leagues[0]?.avgViewsPerRace).toBe(baseLeague.avgViewsPerRace);
});
it('exposes formatted average views and CPM for main sponsor slot', () => {

View File

@@ -90,7 +90,7 @@ export interface DriverProfileExtendedProfileViewModel {
openToRequests: boolean;
}
export interface DriverProfileViewModel {
export interface DriverProfileViewModelData {
currentDriver: DriverProfileDriverSummaryViewModel | null;
stats: DriverProfileStatsViewModel | null;
finishDistribution: DriverProfileFinishDistributionViewModel | null;
@@ -106,7 +106,7 @@ export interface DriverProfileViewModel {
* Transforms API DTOs into UI-ready data structures.
*/
export class DriverProfileViewModel {
constructor(private readonly dto: DriverProfileViewModel) {}
constructor(private readonly dto: DriverProfileViewModelData) {}
get currentDriver(): DriverProfileDriverSummaryViewModel | null {
return this.dto.currentDriver;
@@ -135,7 +135,7 @@ export class DriverProfileViewModel {
/**
* Get the raw DTO for serialization or further processing
*/
toDTO(): DriverProfileViewModel {
toDTO(): DriverProfileViewModelData {
return this.dto;
}
}

View File

@@ -43,11 +43,11 @@ export class LeagueDetailPageViewModel {
settings: {
maxDrivers?: number;
};
socialLinks?: {
socialLinks: {
discordUrl?: string;
youtubeUrl?: string;
websiteUrl?: string;
};
} | undefined;
// Owner info
owner: GetDriverOutputDTO | null;
@@ -103,13 +103,13 @@ export class LeagueDetailPageViewModel {
) {
this.id = league.id;
this.name = league.name;
this.description = league.description;
this.description = league.description ?? '';
this.ownerId = league.ownerId;
this.createdAt = league.createdAt;
this.createdAt = ''; // Not provided by API
this.settings = {
maxDrivers: league.maxDrivers,
maxDrivers: league.maxMembers,
};
this.socialLinks = league.socialLinks;
this.socialLinks = undefined;
this.owner = owner;
this.scoringConfig = scoringConfig;

View File

@@ -0,0 +1,24 @@
/**
* League Page Detail View Model
*
* View model for league page details.
*/
export class LeaguePageDetailViewModel {
id: string;
name: string;
description: string;
ownerId: string;
ownerName: string;
isAdmin: boolean;
mainSponsor: { name: string; logoUrl: string; websiteUrl: string } | null;
constructor(data: any) {
this.id = data.id;
this.name = data.name;
this.description = data.description;
this.ownerId = data.ownerId;
this.ownerName = data.ownerName;
this.isAdmin = data.isAdmin;
this.mainSponsor = data.mainSponsor;
}
}

View File

@@ -1,4 +1,4 @@
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider';
import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
/**
* View Model for league scoring presets

View File

@@ -1,4 +1,4 @@
import type { MembershipFeeDto } from '../types/generated';
import type { MembershipFeeDTO } from '../types/generated/MembershipFeeDTO';
export class MembershipFeeViewModel {
id: string;
@@ -10,7 +10,7 @@ export class MembershipFeeViewModel {
createdAt: Date;
updatedAt: Date;
constructor(dto: MembershipFeeDto) {
constructor(dto: MembershipFeeDTO) {
Object.assign(this, dto);
}

View File

@@ -1,4 +1,4 @@
import type { PaymentDto } from '../types/generated';
import type { PaymentDTO } from '../types/generated/PaymentDto';
export class PaymentViewModel {
id: string;

View File

@@ -13,7 +13,7 @@ export class StandingEntryViewModel {
private currentUserId: string;
private previousPosition?: number;
constructor(dto: LeagueStandingDTO & { position: number; points: number; wins?: number; podiums?: number; races?: number }, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
constructor(dto: LeagueStandingDTO, leaderPoints: number, nextPoints: number, currentUserId: string, previousPosition?: number) {
this.driverId = dto.driverId;
this.position = dto.position;
this.points = dto.points;

View File

@@ -19,8 +19,6 @@
],
"paths": {
"@/*": ["./*"],
"@core/*": ["../../core/*"],
"@adapters/*": ["../../adapters/*"],
"@testing/*": ["../../testing/*"]
}
},

203
package-lock.json generated
View File

@@ -823,7 +823,7 @@
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
@@ -836,7 +836,7 @@
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
@@ -1554,7 +1554,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1587,7 +1586,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -1620,7 +1618,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -2966,7 +2963,7 @@
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz",
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.57.0"
@@ -3507,6 +3504,27 @@
"node": ">=14"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
@@ -3606,28 +3624,28 @@
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/@tybys/wasm-util": {
@@ -3641,6 +3659,14 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -4776,7 +4802,7 @@
"version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
@@ -4799,7 +4825,7 @@
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz",
"integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
@@ -4918,7 +4944,7 @@
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/argparse": {
@@ -6090,7 +6116,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/cross-spawn": {
@@ -6501,7 +6527,7 @@
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.2.tgz",
"integrity": "sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==",
"dev": true,
"devOptional": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
@@ -6540,6 +6566,14 @@
"node": ">=6.0.0"
}
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dom-serializer": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
@@ -8447,7 +8481,7 @@
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.0.tgz",
"integrity": "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
@@ -9760,7 +9794,7 @@
"version": "1.21.7",
"resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz",
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true,
"devOptional": true,
"license": "MIT",
"bin": {
"jiti": "bin/jiti.js"
@@ -10158,6 +10192,17 @@
"node": ">=12"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -10199,7 +10244,7 @@
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"devOptional": true,
"license": "ISC"
},
"node_modules/matcher": {
@@ -11478,7 +11523,7 @@
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
@@ -11497,7 +11542,7 @@
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
@@ -11791,6 +11836,47 @@
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -12228,6 +12314,44 @@
"node": ">= 0.8"
}
},
"node_modules/react": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
"peerDependencies": {
"react": "^19.2.3"
}
},
"node_modules/react-dom/node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT",
"peer": true
},
"node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -12505,7 +12629,7 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
@@ -14129,7 +14253,7 @@
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
@@ -14296,7 +14420,7 @@
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.27.0",
@@ -14319,7 +14443,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14336,7 +14459,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14353,7 +14475,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14370,7 +14491,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14387,7 +14507,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14404,7 +14523,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14421,7 +14539,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14438,7 +14555,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14455,7 +14571,6 @@
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14472,7 +14587,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14489,7 +14603,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14506,7 +14619,6 @@
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14523,7 +14635,6 @@
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14540,7 +14651,6 @@
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14557,7 +14667,6 @@
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14574,7 +14683,6 @@
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14591,7 +14699,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14608,7 +14715,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14625,7 +14731,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14642,7 +14747,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14659,7 +14763,6 @@
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14676,7 +14779,6 @@
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14693,7 +14795,6 @@
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
@@ -14707,7 +14808,7 @@
"version": "0.27.1",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz",
"integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==",
"dev": true,
"devOptional": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
@@ -15100,7 +15201,7 @@
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -15355,7 +15456,7 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/validate-npm-package-license": {
@@ -15928,7 +16029,7 @@
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
@@ -15988,7 +16089,7 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=6"