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

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/*"]
}
},