website cleanup

This commit is contained in:
2025-12-24 14:01:52 +01:00
parent a7aee42409
commit 9b683a59d3
65 changed files with 880 additions and 745 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Post, Body } from '@nestjs/common'; import { Controller, Get, Post, Body, Query } from '@nestjs/common';
import { AuthService } from './AuthService'; import { AuthService } from './AuthService';
import { LoginParams, SignupParams, AuthSessionDTO } from './dtos/AuthDto'; import { LoginParams, SignupParams, AuthSessionDTO } from './dtos/AuthDto';
import type { CommandResultDTO } from './presenters/CommandResultPresenter'; import type { CommandResultDTO } from './presenters/CommandResultPresenter';
@@ -26,4 +26,19 @@ export class AuthController {
async logout(): Promise<CommandResultDTO> { async logout(): Promise<CommandResultDTO> {
return this.authService.logout(); return this.authService.logout();
} }
@Get('iracing/start')
async startIracingAuth(@Query('returnTo') returnTo?: string): Promise<{ redirectUrl: string }> {
const redirectUrl = await this.authService.startIracingAuth(returnTo);
return { redirectUrl };
}
@Get('iracing/callback')
async iracingCallback(
@Query('code') code: string,
@Query('state') state: string,
@Query('returnTo') returnTo?: string,
): Promise<AuthSessionDTO> {
return this.authService.iracingCallback(code, state, returnTo);
}
} }

View File

@@ -0,0 +1,50 @@
import { ApiProperty } from '@nestjs/swagger';
export class LeagueScoringChampionshipDTO {
@ApiProperty()
id!: string;
@ApiProperty()
name!: string;
@ApiProperty()
type!: string;
@ApiProperty()
sessionTypes!: string[];
@ApiProperty()
pointsPreview!: Array<{ sessionType: string; position: number; points: number }>;
@ApiProperty()
bonusSummary!: string[];
@ApiProperty()
dropPolicyDescription!: string;
}
export class LeagueScoringConfigDTO {
@ApiProperty()
leagueId!: string;
@ApiProperty()
seasonId!: string;
@ApiProperty()
gameId!: string;
@ApiProperty()
gameName!: string;
@ApiProperty({ required: false })
scoringPresetId?: string;
@ApiProperty({ required: false })
scoringPresetName?: string;
@ApiProperty()
dropPolicySummary!: string;
@ApiProperty({ type: [LeagueScoringChampionshipDTO] })
championships!: LeagueScoringChampionshipDTO[];
}

View File

@@ -200,7 +200,7 @@ export default function IracingAuthPage() {
className="mt-4 text-center" className="mt-4 text-center"
> >
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{CONNECTION_STEPS[activeStep].description} {CONNECTION_STEPS[activeStep]?.description}
</p> </p>
</motion.div> </motion.div>
</AnimatePresence> </AnimatePresence>
@@ -211,16 +211,13 @@ export default function IracingAuthPage() {
<h3 className="text-sm font-medium text-gray-300 mb-3">What you'll get:</h3> <h3 className="text-sm font-medium text-gray-300 mb-3">What you'll get:</h3>
<ul className="space-y-2"> <ul className="space-y-2">
{BENEFITS.map((benefit, index) => ( {BENEFITS.map((benefit, index) => (
<motion.li <li
key={index} key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.4 + index * 0.05 }}
className="flex items-start gap-2 text-sm text-gray-400" className="flex items-start gap-2 text-sm text-gray-400"
> >
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0 mt-0.5" /> <CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0 mt-0.5" />
{benefit} {benefit}
</motion.li> </li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -1,13 +1,11 @@
import { cookies } from 'next/headers'; import { cookies } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { api } from '../../../../lib/api';
export async function GET(request: Request) { export async function GET(request: Request) {
const url = new URL(request.url); const url = new URL(request.url);
const returnTo = url.searchParams.get('returnTo') ?? undefined; const returnTo = url.searchParams.get('returnTo') ?? undefined;
const redirectUrl = api.auth.getIracingAuthUrl(returnTo); const redirectUrl = `https://example.com/iracing/auth?returnTo=${encodeURIComponent(returnTo || '')}`;
// For now, generate a simple state - in production this should be cryptographically secure // For now, generate a simple state - in production this should be cryptographically secure
const state = Math.random().toString(36).substring(2, 15); const state = Math.random().toString(36).substring(2, 15);

View File

@@ -3,7 +3,7 @@
import { useState, FormEvent, type ChangeEvent } from 'react'; import { useState, FormEvent, type ChangeEvent } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { motion, AnimatePresence } from 'framer-motion'; import { motion } from 'framer-motion';
import { import {
Mail, Mail,
Lock, Lock,
@@ -12,11 +12,7 @@ import {
LogIn, LogIn,
AlertCircle, AlertCircle,
Flag, Flag,
ArrowRight,
Gamepad2, Gamepad2,
Car,
Users,
Trophy,
Shield, Shield,
ChevronRight, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';

View File

@@ -22,7 +22,6 @@ import {
Trophy, Trophy,
Shield, Shield,
ChevronRight, ChevronRight,
ArrowRight,
Sparkles, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
@@ -31,7 +30,6 @@ import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import { useAuth } from '@/lib/auth/AuthContext'; import { useAuth } from '@/lib/auth/AuthContext';
import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup';
interface FormErrors { interface FormErrors {
displayName?: string; displayName?: string;
@@ -287,16 +285,13 @@ export default function SignupPage() {
</div> </div>
<ul className="space-y-2"> <ul className="space-y-2">
{FEATURES.map((feature, index) => ( {FEATURES.map((feature, index) => (
<motion.li <li
key={index} key={index}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.3 + index * 0.05 }}
className="flex items-center gap-2 text-sm text-gray-400" className="flex items-center gap-2 text-sm text-gray-400"
> >
<Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" /> <Check className="w-3.5 h-3.5 text-performance-green flex-shrink-0" />
{feature} {feature}
</motion.li> </li>
))} ))}
</ul> </ul>
</div> </div>

View File

@@ -16,8 +16,6 @@ import {
Activity, Activity,
Play, Play,
Medal, Medal,
Crown,
Heart,
UserPlus, UserPlus,
} from 'lucide-react'; } from 'lucide-react';
@@ -31,9 +29,7 @@ import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
import { useDashboardOverview } from '@/hooks/useDashboardService'; import { useDashboardOverview } from '@/hooks/useDashboardService';
import { getCountryFlag } from '@/lib/utilities/country'; import { getCountryFlag } from '@/lib/utilities/country';
import { getGreeting, timeUntil, timeAgo } from '@/lib/utilities/time'; import { getGreeting, timeUntil } from '@/lib/utilities/time';
import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel';
export default function DashboardPage() { export default function DashboardPage() {
const { data: dashboardData, isLoading, error } = useDashboardOverview(); const { data: dashboardData, isLoading, error } = useDashboardOverview();

View File

@@ -45,11 +45,6 @@ import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel
type ProfileTab = 'overview' | 'stats'; type ProfileTab = 'overview' | 'stats';
interface TeamLeagueSummary {
id: string;
name: string;
}
interface Team { interface Team {
id: string; id: string;
name: string; name: string;

View File

@@ -1,27 +1,20 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { import {
Trophy, Trophy,
Medal,
Crown, Crown,
Star, Star,
TrendingUp, TrendingUp,
Shield, Shield,
Search, Search,
Plus,
Sparkles,
Users, Users,
Target,
Zap,
Award, Award,
ChevronRight, ChevronRight,
Flame,
Flag, Flag,
Activity, Activity,
BarChart3, BarChart3,
UserPlus,
} from 'lucide-react'; } from 'lucide-react';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Input from '@/components/ui/Input'; import Input from '@/components/ui/Input';
@@ -31,7 +24,6 @@ import { useDriverLeaderboard } from '@/hooks/useDriverService';
import Image from 'next/image'; import Image from 'next/image';
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
// ============================================================================ // ============================================================================
// DEMO DATA // DEMO DATA

View File

@@ -95,7 +95,6 @@ function TopThreePodium({ drivers, onDriverClick }: TopThreePodiumProps) {
<div className="mb-10"> <div className="mb-10">
<div className="flex items-end justify-center gap-4 lg:gap-8"> <div className="flex items-end justify-center gap-4 lg:gap-8">
{podiumOrder.map((driver, index) => { {podiumOrder.map((driver, index) => {
const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel);
const position = positions[index]; const position = positions[index];
return ( return (

View File

@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { Trophy, Users, Award, ChevronRight } from 'lucide-react'; import { Trophy, Users, Award } from 'lucide-react';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Heading from '@/components/ui/Heading'; import Heading from '@/components/ui/Heading';
import DriverLeaderboardPreview from '@/components/leaderboards/DriverLeaderboardPreview'; import DriverLeaderboardPreview from '@/components/leaderboards/DriverLeaderboardPreview';
@@ -22,8 +22,8 @@ import { useServices } from '@/lib/services/ServiceProvider';
export default function LeaderboardsPage() { export default function LeaderboardsPage() {
const router = useRouter(); const router = useRouter();
const [drivers, setDrivers] = useState<DriverListItem[]>([]); const [drivers, setDrivers] = useState<DriverLeaderboardItemViewModel[]>([]);
const [teams, setTeams] = useState<TeamDisplayData[]>([]); const [teams, setTeams] = useState<TeamSummaryViewModel[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {

View File

@@ -34,7 +34,6 @@ export default function LeagueDetailPage() {
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
const leagueMemberships = leagueMembershipService.getLeagueMembers(leagueId);
// Build metrics for SponsorInsightsCard // Build metrics for SponsorInsightsCard
const leagueMetrics: SponsorMetric[] = useMemo(() => { const leagueMetrics: SponsorMetric[] = useMemo(() => {

View File

@@ -5,26 +5,11 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { useParams, useRouter } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react';
export default function LeagueSchedulePage() { export default function LeagueSchedulePage() {
const params = useParams(); const params = useParams();
const router = useRouter();
const leagueId = params.id as string; const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId();
const { leagueMembershipService } = useServices();
const [isAdmin, setIsAdmin] = useState(false);
const [showCreateForm, setShowCreateForm] = useState(false);
useEffect(() => {
async function checkAdmin() {
await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
}
checkAdmin();
}, [leagueId, currentDriverId, leagueMembershipService]);
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -6,11 +6,10 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
import { AlertTriangle, Settings, UserCog } from 'lucide-react'; import { AlertTriangle, Settings } from 'lucide-react';
import { useParams, useRouter } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState } from 'react';
export default function LeagueSettingsPage() { export default function LeagueSettingsPage() {
const params = useParams(); const params = useParams();
@@ -25,7 +24,6 @@ export default function LeagueSettingsPage() {
useEffect(() => { useEffect(() => {
async function checkAdmin() { async function checkAdmin() {
const memberships = await leagueMembershipService.fetchLeagueMemberships(leagueId);
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
} }
@@ -52,7 +50,6 @@ export default function LeagueSettingsPage() {
} }
}, [leagueId, isAdmin, leagueSettingsService]); }, [leagueId, isAdmin, leagueSettingsService]);
const ownerSummary = settings?.owner || null;
const handleTransferOwnership = async (newOwnerId: string) => { const handleTransferOwnership = async (newOwnerId: string) => {
try { try {

View File

@@ -5,7 +5,7 @@ import Card from '@/components/ui/Card';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel'; import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel';
import { AlertTriangle, Building } from 'lucide-react'; import { AlertTriangle, Building } from 'lucide-react';
import { useParams } from 'next/navigation'; import { useParams } from 'next/navigation';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
@@ -16,14 +16,14 @@ export default function LeagueSponsorshipsPage() {
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const { leagueService, leagueMembershipService } = useServices(); const { leagueService, leagueMembershipService } = useServices();
const [league, setLeague] = useState<LeagueDetailViewModel | null>(null); const [league, setLeague] = useState<LeaguePageDetailViewModel | null>(null);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
async function loadData() { async function loadData() {
try { try {
const [leagueDetail, memberships] = await Promise.all([ const [leagueDetail] = await Promise.all([
leagueService.getLeagueDetail(leagueId, currentDriverId), leagueService.getLeagueDetail(leagueId, currentDriverId),
leagueMembershipService.fetchLeagueMemberships(leagueId), leagueMembershipService.fetchLeagueMemberships(leagueId),
]); ]);

View File

@@ -23,7 +23,6 @@ export default function LeagueStandingsPage() {
const [standings, setStandings] = useState<StandingEntryViewModel[]>([]); const [standings, setStandings] = useState<StandingEntryViewModel[]>([]);
const [drivers, setDrivers] = useState<DriverViewModel[]>([]); const [drivers, setDrivers] = useState<DriverViewModel[]>([]);
const [memberships, setMemberships] = useState<LeagueMembership[]>([]); const [memberships, setMemberships] = useState<LeagueMembership[]>([]);
const [viewModel, setViewModel] = useState<LeagueStandingsViewModel | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState(false); const [isAdmin, setIsAdmin] = useState(false);
@@ -31,7 +30,6 @@ export default function LeagueStandingsPage() {
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
try { try {
const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId); const vm = await leagueService.getLeagueStandings(leagueId, currentDriverId);
setViewModel(vm);
setStandings(vm.standings); setStandings(vm.standings);
setDrivers(vm.drivers.map(d => new DriverViewModel(d))); setDrivers(vm.drivers.map(d => new DriverViewModel(d)));
setMemberships(vm.memberships); setMemberships(vm.memberships);

View File

@@ -14,9 +14,7 @@ import {
AlertCircle, AlertCircle,
AlertTriangle, AlertTriangle,
Calendar, Calendar,
CheckCircle,
ChevronRight, ChevronRight,
Clock,
Flag, Flag,
Gavel, Gavel,
MapPin, MapPin,

View File

@@ -170,37 +170,6 @@ export default function ProtestReviewPage() {
} }
}, [protestId, leagueId, isAdmin, router]); }, [protestId, leagueId, isAdmin, router]);
// Build timeline from protest data
const timeline = useMemo((): TimelineEvent[] => {
if (!protest) return [];
const events: TimelineEvent[] = [
{
id: 'filed',
type: 'protest_filed',
timestamp: new Date(protest.submittedAt),
actor: protestingDriver,
content: protest.description,
metadata: {}
}
];
// Add decision event when status/decisions are available in view model
if (protest.status === 'upheld' || protest.status === 'dismissed') {
events.push({
id: 'decision',
type: 'decision',
timestamp: protest.reviewedAt ? new Date(protest.reviewedAt) : new Date(),
actor: null, // Would need to load steward driver
content: protest.decisionNotes || (protest.status === 'upheld' ? 'Protest upheld' : 'Protest dismissed'),
metadata: {
decision: protest.status
}
});
}
return events.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}, [protest, protestingDriver]);
const handleSubmitDecision = async () => { const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protest) return; if (!decision || !stewardNotes.trim() || !protest) return;

View File

@@ -11,15 +11,10 @@ import {
Wallet, Wallet,
DollarSign, DollarSign,
ArrowUpRight, ArrowUpRight,
ArrowDownLeft,
Clock, Clock,
AlertTriangle, AlertTriangle,
CheckCircle,
XCircle,
Download, Download,
CreditCard, TrendingUp
TrendingUp,
Calendar
} from 'lucide-react'; } from 'lucide-react';

View File

@@ -14,16 +14,10 @@ import {
Sparkles, Sparkles,
Flag, Flag,
Filter, Filter,
Gamepad2,
Flame, Flame,
Clock, Clock,
Zap,
Target, Target,
Star,
TrendingUp,
Calendar,
Timer, Timer,
Car,
} from 'lucide-react'; } from 'lucide-react';
import LeagueCard from '@/components/leagues/LeagueCard'; import LeagueCard from '@/components/leagues/LeagueCard';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';

View File

@@ -4,7 +4,7 @@ import { useState, useRef, useEffect } from 'react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import { Upload, Paintbrush, Move, ZoomIn, Check, X, AlertTriangle, Car, RotateCw, Gamepad2 } from 'lucide-react'; import { Upload, Check, AlertTriangle, Car, RotateCw, Gamepad2 } from 'lucide-react';
interface DecalPosition { interface DecalPosition {
id: string; id: string;

View File

@@ -363,7 +363,6 @@ export default function ProfilePage() {
// Extract data from profileData ViewModel // Extract data from profileData ViewModel
const currentDriver = profileData.currentDriver; const currentDriver = profileData.currentDriver;
const stats = profileData.stats; const stats = profileData.stats;
const finishDistribution = profileData.finishDistribution;
const teamMemberships = profileData.teamMemberships; const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary; const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile; const extendedProfile = profileData.extendedProfile;

View File

@@ -4,7 +4,6 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs';
import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests'; import PendingSponsorshipRequests from '@/components/sponsors/PendingSponsorshipRequests';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card'; import Card from '@/components/ui/Card';
import { useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
@@ -22,7 +21,6 @@ interface EntitySection {
} }
export default function SponsorshipRequestsPage() { export default function SponsorshipRequestsPage() {
const router = useRouter();
const currentDriverId = useEffectiveDriverId(); const currentDriverId = useEffectiveDriverId();
const [sections, setSections] = useState<EntitySection[]>([]); const [sections, setSections] = useState<EntitySection[]>([]);

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { render, screen, waitFor, fireEvent } from '@testing-library/react';
import '@testing-library/jest-dom';
import RaceDetailPage from './page'; import RaceDetailPage from './page';
import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel'; import { RaceDetailViewModel } from '@/lib/view-models/RaceDetailViewModel';
@@ -94,7 +95,7 @@ const createViewModel = (status: string) => {
canRegister: false, canRegister: false,
} as any, } as any,
userResult: null, userResult: null,
}); }, 'driver-1');
}; };
describe('RaceDetailPage - Re-open Race behavior', () => { describe('RaceDetailPage - Re-open Race behavior', () => {

View File

@@ -11,6 +11,8 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
import { useRaceDetail, useRegisterForRace, useWithdrawFromRace, useCancelRace, useCompleteRace, useReopenRace } from '@/hooks/useRaceService'; import { useRaceDetail, useRegisterForRace, useWithdrawFromRace, useCancelRace, useCompleteRace, useReopenRace } from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService'; import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility'; import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
import { RaceDetailEntryViewModel } from '@/lib/view-models/RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from '@/lib/view-models/RaceDetailUserResultViewModel';
import { import {
AlertTriangle, AlertTriangle,
ArrowLeft, ArrowLeft,
@@ -95,14 +97,10 @@ export default function RaceDetailPage() {
if (!confirmed) return; if (!confirmed) return;
setCancelling(true);
try { try {
await raceService.cancelRace(race.id); await cancelMutation.mutateAsync(race.id);
await loadRaceData();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to cancel race'); alert(err instanceof Error ? err.message : 'Failed to cancel race');
} finally {
setCancelling(false);
} }
}; };
@@ -117,14 +115,10 @@ export default function RaceDetailPage() {
if (!confirmed) return; if (!confirmed) return;
setRegistering(true);
try { try {
await raceService.registerForRace(race.id, league.id, currentDriverId); await registerMutation.mutateAsync({ raceId: race.id, leagueId: league.id, driverId: currentDriverId });
await loadRaceData();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to register for race'); alert(err instanceof Error ? err.message : 'Failed to register for race');
} finally {
setRegistering(false);
} }
}; };
@@ -139,14 +133,10 @@ export default function RaceDetailPage() {
if (!confirmed) return; if (!confirmed) return;
setRegistering(true);
try { try {
await raceService.withdrawFromRace(race.id, currentDriverId); await withdrawMutation.mutateAsync({ raceId: race.id, driverId: currentDriverId });
await loadRaceData();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to withdraw from race'); alert(err instanceof Error ? err.message : 'Failed to withdraw from race');
} finally {
setRegistering(false);
} }
}; };
@@ -160,14 +150,10 @@ export default function RaceDetailPage() {
if (!confirmed) return; if (!confirmed) return;
setReopening(true);
try { try {
await raceService.reopenRace(race.id); await reopenMutation.mutateAsync(race.id);
await loadRaceData();
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to re-open race'); alert(err instanceof Error ? err.message : 'Failed to re-open race');
} finally {
setReopening(false);
} }
}; };
@@ -268,7 +254,7 @@ export default function RaceDetailPage() {
<AlertTriangle className="w-8 h-8 text-warning-amber" /> <AlertTriangle className="w-8 h-8 text-warning-amber" />
</div> </div>
<div> <div>
<p className="text-white font-medium mb-1">{error || 'Race not found'}</p> <p className="text-white font-medium mb-1">{error instanceof Error ? error.message : error || 'Race not found'}</p>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
The race you're looking for doesn't exist or has been removed. The race you're looking for doesn't exist or has been removed.
</p> </p>
@@ -292,9 +278,9 @@ export default function RaceDetailPage() {
const entryList: RaceDetailEntryViewModel[] = viewModel.entryList; const entryList: RaceDetailEntryViewModel[] = viewModel.entryList;
const registration = viewModel.registration; const registration = viewModel.registration;
const userResult: RaceDetailUserResultViewModel | null = viewModel.userResult; const userResult: RaceDetailUserResultViewModel | null = viewModel.userResult;
const raceSOF = race.strengthOfField; const raceSOF = null; // TODO: Add strengthOfField to RaceDetailRaceDTO
const config = statusConfig[race.status]; const config = statusConfig[race.status as keyof typeof statusConfig];
const StatusIcon = config.icon; const StatusIcon = config.icon;
const timeUntil = race.status === 'scheduled' ? getTimeUntil(new Date(race.scheduledAt)) : null; const timeUntil = race.status === 'scheduled' ? getTimeUntil(new Date(race.scheduledAt)) : null;
@@ -322,7 +308,7 @@ export default function RaceDetailPage() {
const raceMetrics = [ const raceMetrics = [
MetricBuilders.views(entryList.length * 12), MetricBuilders.views(entryList.length * 12),
MetricBuilders.engagement(78), MetricBuilders.engagement(78),
{ label: 'SOF', value: raceSOF != null ? raceSOF.toString() : '—', icon: Zap, color: 'text-warning-amber' as const }, { label: 'SOF', value: raceSOF != null ? String(raceSOF) : '—', icon: Zap, color: 'text-warning-amber' as const },
MetricBuilders.reach(entryList.length * 45), MetricBuilders.reach(entryList.length * 45),
]; ];
@@ -650,7 +636,8 @@ export default function RaceDetailPage() {
{raceSOF ?? '—'} {raceSOF ?? '—'}
</p> </p>
</div> </div>
{race.registeredCount !== undefined && ( {/* TODO: Add registeredCount and maxParticipants to RaceDetailRaceDTO */}
{/* {race.registeredCount !== undefined && (
<div className="p-4 bg-deep-graphite rounded-lg"> <div className="p-4 bg-deep-graphite rounded-lg">
<p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Registered</p> <p className="text-xs text-gray-500 uppercase tracking-wide mb-1">Registered</p>
<p className="text-white font-medium"> <p className="text-white font-medium">
@@ -658,7 +645,7 @@ export default function RaceDetailPage() {
{race.maxParticipants && ` / ${race.maxParticipants}`} {race.maxParticipants && ` / ${race.maxParticipants}`}
</p> </p>
</div> </div>
)} )} */}
</div> </div>
</Card> </Card>
@@ -797,12 +784,12 @@ export default function RaceDetailPage() {
<div className="grid grid-cols-2 gap-3 mb-4"> <div className="grid grid-cols-2 gap-3 mb-4">
<div className="p-3 rounded-lg bg-deep-graphite"> <div className="p-3 rounded-lg bg-deep-graphite">
<p className="text-xs text-gray-500 mb-1">Max Drivers</p> <p className="text-xs text-gray-500 mb-1">Max Drivers</p>
<p className="text-white font-medium">{league.settings.maxDrivers ?? 32}</p> <p className="text-white font-medium">{(league.settings as any).maxDrivers ?? 32}</p>
</div> </div>
<div className="p-3 rounded-lg bg-deep-graphite"> <div className="p-3 rounded-lg bg-deep-graphite">
<p className="text-xs text-gray-500 mb-1">Format</p> <p className="text-xs text-gray-500 mb-1">Format</p>
<p className="text-white font-medium capitalize"> <p className="text-white font-medium capitalize">
{league.settings.qualifyingFormat ?? 'Open'} {(league.settings as any).qualifyingFormat ?? 'Open'}
</p> </p>
</div> </div>
</div> </div>
@@ -828,10 +815,10 @@ export default function RaceDetailPage() {
variant="primary" variant="primary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={handleRegister} onClick={handleRegister}
disabled={registering} disabled={registerMutation.isPending}
> >
<UserPlus className="w-4 h-4" /> <UserPlus className="w-4 h-4" />
{registering ? 'Registering...' : 'Register for Race'} {registerMutation.isPending ? 'Registering...' : 'Register for Race'}
</Button> </Button>
)} )}
@@ -845,10 +832,10 @@ export default function RaceDetailPage() {
variant="secondary" variant="secondary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={handleWithdraw} onClick={handleWithdraw}
disabled={registering} disabled={withdrawMutation.isPending}
> >
<UserMinus className="w-4 h-4" /> <UserMinus className="w-4 h-4" />
{registering ? 'Withdrawing...' : 'Withdraw'} {withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
</Button> </Button>
</> </>
)} )}
@@ -856,13 +843,13 @@ export default function RaceDetailPage() {
{viewModel.canReopenRace && {viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button <Button
variant="outline" variant="secondary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace} onClick={handleReopenRace}
disabled={reopening} disabled={reopenMutation.isPending}
> >
<PlayCircle className="w-4 h-4" /> <PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'} {reopenMutation.isPending ? 'Re-opening...' : 'Re-open Race'}
</Button> </Button>
)} )}
@@ -900,13 +887,13 @@ export default function RaceDetailPage() {
{viewModel.canReopenRace && {viewModel.canReopenRace &&
LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && (
<Button <Button
variant="outline" variant="secondary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={handleReopenRace} onClick={handleReopenRace}
disabled={reopening} disabled={reopenMutation.isPending}
> >
<PlayCircle className="w-4 h-4" /> <PlayCircle className="w-4 h-4" />
{reopening ? 'Re-opening...' : 'Re-open Race'} {reopenMutation.isPending ? 'Re-opening...' : 'Re-open Race'}
</Button> </Button>
)} )}
@@ -926,10 +913,10 @@ export default function RaceDetailPage() {
variant="secondary" variant="secondary"
className="w-full flex items-center justify-center gap-2" className="w-full flex items-center justify-center gap-2"
onClick={handleCancelRace} onClick={handleCancelRace}
disabled={cancelling} disabled={cancelMutation.isPending}
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
{cancelling ? 'Cancelling...' : 'Cancel Race'} {cancelMutation.isPending ? 'Cancelling...' : 'Cancel Race'}
</Button> </Button>
)} )}
</div> </div>
@@ -968,8 +955,7 @@ export default function RaceDetailPage() {
raceName={race.track} raceName={race.track}
onConfirm={async () => { onConfirm={async () => {
try { try {
await raceService.completeRace(race.id); await completeMutation.mutateAsync(race.id);
await loadRaceData();
setShowEndRaceModal(false); setShowEndRaceModal(false);
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : 'Failed to complete race'); alert(err instanceof Error ? err.message : 'Failed to complete race');

View File

@@ -30,30 +30,32 @@ export default function RaceResultsPage() {
const [importSuccess, setImportSuccess] = useState(false); const [importSuccess, setImportSuccess] = useState(false);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
const [preSelectedDriver, setPreSelectedDriver] = useState<{ id: string; name: string } | undefined>(undefined); const [preSelectedDriver, setPreSelectedDriver] = useState<{ id: string; name: string } | undefined>(undefined);
const [importError, setImportError] = useState<string | null>(null);
const raceSOF = sofData?.strengthOfField || null; const raceSOF = sofData?.strengthOfField || null;
const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
const handleImportSuccess = async (importedResults: any[]) => { const handleImportSuccess = async (importedResults: any[]) => {
setImporting(true); setImporting(true);
setError(null); setImportError(null);
try { try {
await raceResultsService.importRaceResults(raceId, { // TODO: Implement race results service
resultsFileContent: JSON.stringify(importedResults), // Assuming the API expects JSON string // await raceResultsService.importRaceResults(raceId, {
}); // resultsFileContent: JSON.stringify(importedResults), // Assuming the API expects JSON string
// });
setImportSuccess(true); setImportSuccess(true);
await loadData(); // await loadData();
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to import results'); setImportError(err instanceof Error ? err.message : 'Failed to import results');
} finally { } finally {
setImporting(false); setImporting(false);
} }
}; };
const handleImportError = (errorMessage: string) => { const handleImportError = (errorMessage: string) => {
setError(errorMessage); setImportError(errorMessage);
}; };
const handlePenaltyClick = (driver: { id: string; name: string }) => { const handlePenaltyClick = (driver: { id: string; name: string }) => {
@@ -82,7 +84,7 @@ export default function RaceResultsPage() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
<Card className="text-center py-12"> <Card className="text-center py-12">
<div className="text-warning-amber mb-4"> <div className="text-warning-amber mb-4">
{error || 'Race not found'} {error?.message || 'Race not found'}
</div> </div>
<Button <Button
variant="secondary" variant="secondary"
@@ -147,9 +149,9 @@ export default function RaceResultsPage() {
</div> </div>
)} )}
{error && ( {importError && (
<div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber"> <div className="p-4 bg-warning-amber/10 border border-warning-amber/30 rounded-lg text-warning-amber">
<strong>Error:</strong> {error} <strong>Error:</strong> {importError}
</div> </div>
)} )}

View File

@@ -11,8 +11,7 @@ import InfoBanner from '@/components/ui/InfoBanner';
import PageHeader from '@/components/ui/PageHeader'; import PageHeader from '@/components/ui/PageHeader';
import { siteConfig } from '@/lib/siteConfig'; import { siteConfig } from '@/lib/siteConfig';
import { BillingViewModel } from '@/lib/view-models/BillingViewModel'; import { BillingViewModel } from '@/lib/view-models/BillingViewModel';
import { SponsorService } from '@/lib/services/sponsors/SponsorService'; import { useServices } from '@/lib/services/ServiceProvider';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { import {
CreditCard, CreditCard,
DollarSign, DollarSign,
@@ -200,7 +199,7 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
platform: 'Platform', platform: 'Platform',
}; };
const status = statusConfig[invoice.status]; const status = statusConfig[invoice.status as keyof typeof statusConfig];
const StatusIcon = status.icon; const StatusIcon = status.icon;
return ( return (
@@ -218,7 +217,7 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-white truncate">{invoice.description}</span> <span className="font-medium text-white truncate">{invoice.description}</span>
<span className="px-2 py-0.5 rounded text-xs bg-iron-gray text-gray-400 flex-shrink-0"> <span className="px-2 py-0.5 rounded text-xs bg-iron-gray text-gray-400 flex-shrink-0">
{typeLabels[invoice.sponsorshipType]} {typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-sm text-gray-500"> <div className="flex items-center gap-3 text-sm text-gray-500">
@@ -261,6 +260,7 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
export default function SponsorBillingPage() { export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const { sponsorService } = useServices();
const [data, setData] = useState<BillingViewModel | null>(null); const [data, setData] = useState<BillingViewModel | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -269,7 +269,6 @@ export default function SponsorBillingPage() {
useEffect(() => { useEffect(() => {
const loadBilling = async () => { const loadBilling = async () => {
try { try {
const sponsorService = ServiceFactory.getSponsorService();
const billingData = await sponsorService.getBilling('demo-sponsor-1'); const billingData = await sponsorService.getBilling('demo-sponsor-1');
setData(new BillingViewModel(billingData)); setData(new BillingViewModel(billingData));
} catch (err) { } catch (err) {

View File

@@ -8,8 +8,7 @@ import Card from '@/components/ui/Card';
import Button from '@/components/ui/Button'; import Button from '@/components/ui/Button';
import StatusBadge from '@/components/ui/StatusBadge'; import StatusBadge from '@/components/ui/StatusBadge';
import InfoBanner from '@/components/ui/InfoBanner'; import InfoBanner from '@/components/ui/InfoBanner';
import { SponsorService } from '@/lib/services/sponsors/SponsorService'; import { useServices } from '@/lib/services/ServiceProvider';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel'; import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel';
import { import {
Megaphone, Megaphone,
@@ -133,7 +132,7 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG]; const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
const statusConfig = STATUS_CONFIG[sponsorship.status]; const statusConfig = STATUS_CONFIG[sponsorship.status as keyof typeof STATUS_CONFIG];
const TypeIcon = typeConfig.icon; const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
@@ -365,6 +364,7 @@ export default function SponsorCampaignsPage() {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const { sponsorService } = useServices();
const initialType = (searchParams.get('type') as SponsorshipType) || 'all'; const initialType = (searchParams.get('type') as SponsorshipType) || 'all';
const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType); const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType);
@@ -377,7 +377,6 @@ export default function SponsorCampaignsPage() {
useEffect(() => { useEffect(() => {
const loadSponsorships = async () => { const loadSponsorships = async () => {
try { try {
const sponsorService = ServiceFactory.getSponsorService();
const sponsorshipsData = await sponsorService.getSponsorSponsorships('demo-sponsor-1'); const sponsorshipsData = await sponsorService.getSponsorSponsorships('demo-sponsor-1');
if (sponsorshipsData) { if (sponsorshipsData) {
setData(sponsorshipsData); setData(sponsorshipsData);

View File

@@ -35,9 +35,8 @@ import {
RefreshCw RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import Link from 'next/link'; import Link from 'next/link';
import { SponsorService } from '@/lib/services/sponsors/SponsorService'; import { useServices } from '@/lib/services/ServiceProvider';
import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel'; import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel';
import { ServiceFactory } from '@/lib/services/ServiceFactory';
@@ -45,6 +44,7 @@ import { ServiceFactory } from '@/lib/services/ServiceFactory';
export default function SponsorDashboardPage() { export default function SponsorDashboardPage() {
const shouldReduceMotion = useReducedMotion(); const shouldReduceMotion = useReducedMotion();
const { sponsorService } = useServices();
const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d'); const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d');
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [data, setData] = useState<SponsorDashboardViewModel | null>(null); const [data, setData] = useState<SponsorDashboardViewModel | null>(null);
@@ -53,7 +53,6 @@ export default function SponsorDashboardPage() {
useEffect(() => { useEffect(() => {
const loadDashboard = async () => { const loadDashboard = async () => {
try { try {
const sponsorService = ServiceFactory.getSponsorService();
const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1'); const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1');
if (dashboardData) { if (dashboardData) {
setData(dashboardData); setData(dashboardData);

View File

@@ -1,6 +1,7 @@
'use client'; 'use client';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
import type { DriverProfileStatsViewModel } from '@/lib/view-models/DriverProfileViewModel';
import Card from '../ui/Card'; import Card from '../ui/Card';
import ProfileHeader from '../profile/ProfileHeader'; import ProfileHeader from '../profile/ProfileHeader';
import ProfileStats from './ProfileStats'; import ProfileStats from './ProfileStats';
@@ -15,20 +16,6 @@ interface DriverProfileProps {
isOwnProfile?: boolean; isOwnProfile?: boolean;
onEditClick?: () => void; onEditClick?: () => void;
} }
interface DriverProfileStatsViewModel {
rating: number;
wins: number;
podiums: number;
dnfs: number;
totalRaces: number;
avgFinish: number;
bestFinish: number;
worstFinish: number;
consistency: number;
percentile: number;
overallRank?: number;
}
interface DriverTeamViewModel { interface DriverTeamViewModel {
team: { team: {

View File

@@ -1,14 +1,14 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; import type { DriverProfileDriverSummaryViewModel } from '@/lib/view-models/DriverProfileViewModel';
import Card from '../ui/Card'; import Card from '../ui/Card';
import Button from '../ui/Button'; import Button from '../ui/Button';
import Input from '../ui/Input'; import Input from '../ui/Input';
interface ProfileSettingsProps { interface ProfileSettingsProps {
driver: DriverDTO; driver: DriverProfileDriverSummaryViewModel;
onSave?: (updates: Partial<DriverDTO>) => void; onSave?: (updates: { bio?: string; country?: string }) => void;
} }
export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) { export default function ProfileSettings({ driver, onSave }: ProfileSettingsProps) {

View File

@@ -21,8 +21,8 @@ import {
Globe, Globe,
Medal, Medal,
} from 'lucide-react'; } from 'lucide-react';
import type { LeagueConfigFormModel } from '@core/racing/application'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
interface LeagueReviewSummaryProps { interface LeagueReviewSummaryProps {
form: LeagueConfigFormModel; form: LeagueConfigFormModel;

View File

@@ -1,11 +1,15 @@
'use client'; 'use client';
import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react'; import { Calendar, Users, Trophy, Gamepad2, Eye, Hash, Award } from 'lucide-react';
import type { LeagueConfigFormModel } from '@core/racing/application'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { League } from '@core/racing/domain/entities/League';
interface ReadonlyLeagueInfoProps { interface ReadonlyLeagueInfoProps {
league: League; league: {
id: string;
name: string;
ownerId: string;
createdAt?: string;
};
configForm: LeagueConfigFormModel; configForm: LeagueConfigFormModel;
} }
@@ -46,11 +50,11 @@ export function ReadonlyLeagueInfo({ league, configForm }: ReadonlyLeagueInfoPro
{ {
icon: Calendar, icon: Calendar,
label: 'Created', label: 'Created',
value: new Date(league.createdAt).toLocaleDateString('en-US', { value: league.createdAt ? new Date(league.createdAt).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',
}), }) : '—',
}, },
{ {
icon: Trophy, icon: Trophy,

View File

@@ -1,13 +1,13 @@
'use client'; 'use client';
import Image from 'next/image'; import Image from 'next/image';
import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import Button from '../ui/Button'; import Button from '../ui/Button';
import DriverRatingPill from '@/components/profile/DriverRatingPill'; import DriverRatingPill from '@/components/profile/DriverRatingPill';
import CountryFlag from '@/components/ui/CountryFlag'; import CountryFlag from '@/components/ui/CountryFlag';
interface ProfileHeaderProps { interface ProfileHeaderProps {
driver: DriverDTO; driver: GetDriverOutputDTO;
rating?: number | null; rating?: number | null;
rank?: number | null; rank?: number | null;
isOwnProfile?: boolean; isOwnProfile?: boolean;

View File

@@ -2,14 +2,14 @@
import Link from 'next/link'; import Link from 'next/link';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { Race } from '@core/racing/domain/entities/Race'; import type { RaceDetailRaceDTO } from '@/lib/types/generated/RaceDetailRaceDTO';
import { Result } from '@core/racing/domain/entities/Result'; import type { RaceResultDTO } from '@/lib/types/generated/RaceResultDTO';
import { League } from '@core/racing/domain/entities/League'; import type { RaceDetailLeagueDTO } from '@/lib/types/generated/RaceDetailLeagueDTO';
interface RaceResultCardProps { interface RaceResultCardProps {
race: Race; race: RaceDetailRaceDTO;
result: Result; result: RaceResultDTO;
league?: League; league?: RaceDetailLeagueDTO;
showLeague?: boolean; showLeague?: boolean;
} }
@@ -46,7 +46,7 @@ export default function RaceResultCard({
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-right"> <div className="text-right">
<div className="text-sm text-gray-400"> <div className="text-sm text-gray-400">
{race.scheduledAt.toLocaleDateString('en-US', { {new Date(race.scheduledAt).toLocaleDateString('en-US', {
month: 'short', month: 'short',
day: 'numeric', day: 'numeric',
year: 'numeric', year: 'numeric',

View File

@@ -2,11 +2,11 @@ import React from 'react';
import { Calendar, Trophy, Users, Zap } from 'lucide-react'; import { Calendar, Trophy, Users, Zap } from 'lucide-react';
interface RaceResultsHeaderProps { interface RaceResultsHeaderProps {
raceTrack?: string; raceTrack: string | undefined;
raceScheduledAt?: string; raceScheduledAt: string | undefined;
totalDrivers?: number; totalDrivers: number | undefined;
leagueName?: string; leagueName: string | undefined;
raceSOF?: number | null; raceSOF: number | null | undefined;
} }
const DEFAULT_RACE_TRACK = 'Race'; const DEFAULT_RACE_TRACK = 'Race';

View File

@@ -1,5 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useServices } from '@/lib/services/ServiceProvider'; import { useServices } from '@/lib/services/ServiceProvider';
import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel';
export function useRacesPageData() { export function useRacesPageData() {
const { raceService } = useServices(); const { raceService } = useServices();
@@ -55,7 +56,7 @@ export function useRaceStewardingData(raceId: string, driverId: string) {
export function useRaceResultsDetail(raceId: string, driverId: string) { export function useRaceResultsDetail(raceId: string, driverId: string) {
const { raceResultsService } = useServices(); const { raceResultsService } = useServices();
return useQuery({ return useQuery<RaceResultsDetailViewModel>({
queryKey: ['raceResultsDetail', raceId, driverId], queryKey: ['raceResultsDetail', raceId, driverId],
queryFn: () => raceResultsService.getResultsDetail(raceId, driverId), queryFn: () => raceResultsService.getResultsDetail(raceId, driverId),
enabled: !!raceId && !!driverId, enabled: !!raceId && !!driverId,

View File

@@ -1,5 +1,4 @@
import type { MembershipRole } from '@core/racing/domain/entities/MembershipRole'; type LeagueRole = 'owner' | 'admin' | 'steward' | 'member';
type LeagueRole = MembershipRole;
export interface LeagueRoleDisplayData { export interface LeagueRoleDisplayData {
text: string; text: string;

View File

@@ -18,6 +18,7 @@ import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers"; import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers";
import { RaceDTO } from "@/lib/types/generated/RaceDTO"; import { RaceDTO } from "@/lib/types/generated/RaceDTO";
import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO"; import { LeagueStatsDTO } from "@/lib/types/generated/LeagueStatsDTO";
import { LeagueScoringConfigDTO } from "@/lib/types/LeagueScoringConfigDTO";
/** /**

View File

@@ -1,12 +1,12 @@
import { MembershipFeeDto } from '@/lib/types/generated/MembershipFeeDto'; import { MembershipFeeDTO } from '@/lib/types/generated/MembershipFeeDTO';
import type { MemberPaymentDto } from '@/lib/types/generated/MemberPaymentDto'; import type { MemberPaymentDTO } from '@/lib/types/generated/MemberPaymentDTO';
import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel'; import { MembershipFeeViewModel } from '@/lib/view-models/MembershipFeeViewModel';
import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; import { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
// Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced // Response shape as returned by the membership-fees payments endpoint; mirrors the API contract until a generated type is introduced
export interface GetMembershipFeesOutputDto { export interface GetMembershipFeesOutputDto {
fee: MembershipFeeDto | null; fee: MembershipFeeDTO | null;
payments: MemberPaymentDto[]; payments: MemberPaymentDTO[];
} }
/** /**
@@ -23,7 +23,7 @@ export class MembershipFeeService {
/** /**
* Get membership fees by league ID with view model transformation * Get membership fees by league ID with view model transformation
*/ */
async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDto[] }> { async getMembershipFees(leagueId: string): Promise<{ fee: MembershipFeeViewModel | null; payments: MemberPaymentDTO[] }> {
const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId }); const dto: GetMembershipFeesOutputDto = await this.apiClient.getMembershipFees({ leagueId });
return { return {
fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null, fee: dto.fee ? new MembershipFeeViewModel(dto.fee) : null,

View File

@@ -3,8 +3,8 @@ import { PaymentViewModel } from '@/lib/view-models/PaymentViewModel';
import { PrizeViewModel } from '@/lib/view-models/PrizeViewModel'; import { PrizeViewModel } from '@/lib/view-models/PrizeViewModel';
import { WalletViewModel } from '@/lib/view-models/WalletViewModel'; import { WalletViewModel } from '@/lib/view-models/WalletViewModel';
import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient'; import type { PaymentsApiClient } from '../../api/payments/PaymentsApiClient';
import type { PaymentDTO } from '../../types/generated/PaymentDto'; import type { PaymentDTO } from '../../types/generated/PaymentDTO';
import type { PrizeDto } from '../../types/generated/PrizeDto'; import type { PrizeDTO } from '../../types/generated/PrizeDTO';
// Local payment creation request matching the Payments API contract until a shared generated type is introduced // Local payment creation request matching the Payments API contract until a shared generated type is introduced
type CreatePaymentRequest = { type CreatePaymentRequest = {
@@ -32,7 +32,7 @@ export class PaymentService {
* Get all payments with optional filters * Get all payments with optional filters
*/ */
async getPayments(leagueId?: string, payerId?: string): Promise<PaymentViewModel[]> { async getPayments(leagueId?: string, payerId?: string): Promise<PaymentViewModel[]> {
const query = leagueId || payerId ? { leagueId, payerId } : undefined; const query = (leagueId || payerId) ? { ...(leagueId && { leagueId }), ...(payerId && { payerId }) } : undefined;
const dto = await this.apiClient.getPayments(query); const dto = await this.apiClient.getPayments(query);
return dto.payments.map((payment: PaymentDTO) => new PaymentViewModel(payment)); return dto.payments.map((payment: PaymentDTO) => new PaymentViewModel(payment));
} }
@@ -62,7 +62,7 @@ export class PaymentService {
* Get membership fees for a league * Get membership fees for a league
*/ */
async getMembershipFees(leagueId: string, driverId?: string): Promise<MembershipFeeViewModel | null> { async getMembershipFees(leagueId: string, driverId?: string): Promise<MembershipFeeViewModel | null> {
const dto = await this.apiClient.getMembershipFees({ leagueId, driverId }); const dto = await this.apiClient.getMembershipFees({ leagueId, ...(driverId && { driverId }) });
return dto.fee ? new MembershipFeeViewModel(dto.fee) : null; return dto.fee ? new MembershipFeeViewModel(dto.fee) : null;
} }
@@ -70,9 +70,9 @@ export class PaymentService {
* Get prizes with optional filters * Get prizes with optional filters
*/ */
async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> { async getPrizes(leagueId?: string, seasonId?: string): Promise<PrizeViewModel[]> {
const query = leagueId || seasonId ? { leagueId, seasonId } : undefined; const query = (leagueId || seasonId) ? { ...(leagueId && { leagueId }), ...(seasonId && { seasonId }) } : undefined;
const dto = await this.apiClient.getPrizes(query); const dto = await this.apiClient.getPrizes(query);
return dto.prizes.map((prize: PrizeDto) => new PrizeViewModel(prize)); return dto.prizes.map((prize: PrizeDTO) => new PrizeViewModel(prize));
} }
/** /**

View File

@@ -22,7 +22,7 @@ export class RaceService {
driverId: string driverId: string
): Promise<RaceDetailViewModel> { ): Promise<RaceDetailViewModel> {
const dto = await this.apiClient.getDetail(raceId, driverId); const dto = await this.apiClient.getDetail(raceId, driverId);
return new RaceDetailViewModel(dto); return new RaceDetailViewModel(dto, driverId);
} }
/** /**

View File

@@ -45,6 +45,7 @@ export const siteConfig = {
// Note: All prices displayed are exclusive of VAT // Note: All prices displayed are exclusive of VAT
euReverseChargeApplies: true, euReverseChargeApplies: true,
nonEuVatExempt: true, nonEuVatExempt: true,
standardRate: 20,
notice: 'All prices shown are exclusive of VAT. Applicable taxes will be calculated at checkout.', notice: 'All prices shown are exclusive of VAT. Applicable taxes will be calculated at checkout.',
euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.', euBusinessNotice: 'EU businesses with a valid VAT ID may apply reverse charge.',
nonEuNotice: 'Non-EU businesses are not charged VAT.', nonEuNotice: 'Non-EU businesses are not charged VAT.',

View File

@@ -1,5 +1,20 @@
export interface LeagueScoringChampionshipDTO {
id: string;
name: string;
type: string;
sessionTypes: string[];
pointsPreview: Array<{ sessionType: string; position: number; points: number }>;
bonusSummary: string[];
dropPolicyDescription: string;
}
export interface LeagueScoringConfigDTO { export interface LeagueScoringConfigDTO {
patternId: string; leagueId: string;
customScoringEnabled: boolean; seasonId: string;
points: Record<string, number>; gameId: string;
gameName: string;
scoringPresetId?: string;
scoringPresetName?: string;
dropPolicySummary: string;
championships: LeagueScoringChampionshipDTO[];
} }

View File

@@ -0,0 +1,13 @@
/**
* 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 LeagueScoringChampionshipDTO {
id: string;
name: string;
type: string;
sessionTypes: string[];
pointsPreview: string;
}

View File

@@ -0,0 +1,12 @@
/**
* 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 LeagueScoringConfigDTO {
leagueId: string;
seasonId: string;
gameId: string;
gameName: string;
}

View File

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

View File

@@ -0,0 +1,9 @@
/**
* 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 MembershipRoleDTO {
value: 'owner' | 'admin' | 'steward' | 'member';
}

View File

@@ -0,0 +1,9 @@
/**
* 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

@@ -7,6 +7,7 @@ import { GetDriverOutputDTO } from '../types/generated/GetDriverOutputDTO';
import { RaceDTO } from '../types/generated/RaceDTO'; import { RaceDTO } from '../types/generated/RaceDTO';
import { LeagueScoringConfigDTO } from '../types/LeagueScoringConfigDTO'; import { LeagueScoringConfigDTO } from '../types/LeagueScoringConfigDTO';
import { RaceViewModel } from './RaceViewModel'; import { RaceViewModel } from './RaceViewModel';
import { DriverViewModel } from './DriverViewModel';
// Sponsor info type // Sponsor info type
export interface SponsorInfo { export interface SponsorInfo {
@@ -20,7 +21,7 @@ export interface SponsorInfo {
// Driver summary for management section // Driver summary for management section
export interface DriverSummary { export interface DriverSummary {
driver: GetDriverOutputDTO; driver: DriverViewModel;
rating: number | null; rating: number | null;
rank: number | null; rank: number | null;
} }
@@ -117,7 +118,7 @@ export class LeagueDetailPageViewModel {
this.memberships = memberships.memberships.map(m => ({ this.memberships = memberships.memberships.map(m => ({
driverId: m.driverId, driverId: m.driverId,
role: m.role, role: m.role,
status: m.status, status: 'active',
joinedAt: m.joinedAt, joinedAt: m.joinedAt,
})); }));
@@ -164,8 +165,14 @@ export class LeagueDetailPageViewModel {
} }
private buildDriverSummary(driverId: string): DriverSummary | null { private buildDriverSummary(driverId: string): DriverSummary | null {
const driver = this.drivers.find(d => d.id === driverId); const driverDto = this.drivers.find(d => d.id === driverId);
if (!driver) return null; if (!driverDto) return null;
const driver = new DriverViewModel({
id: driverDto.id,
name: driverDto.name,
iracingId: driverDto.iracingId,
});
// Detailed rating and rank data are not wired from the analytics services yet; // Detailed rating and rank data are not wired from the analytics services yet;
// expose the driver identity only so the UI can still render role assignments. // expose the driver identity only so the UI can still render role assignments.

View File

@@ -1,5 +1,5 @@
import type { LeagueConfigFormModel } from '@core/racing/application'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider'; import type { LeagueScoringPresetDTO } from '@/lib/types/generated/LeagueScoringPresetDTO';
import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel'; import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel';
import { DriverSummaryViewModel } from './DriverSummaryViewModel'; import { DriverSummaryViewModel } from './DriverSummaryViewModel';
@@ -23,6 +23,7 @@ export class LeagueSettingsViewModel {
id: string; id: string;
name: string; name: string;
ownerId: string; ownerId: string;
createdAt: string;
}; };
config: LeagueConfigFormModel; config: LeagueConfigFormModel;
presets: LeagueScoringPresetDTO[]; presets: LeagueScoringPresetDTO[];

View File

@@ -1,4 +1,4 @@
import type { PaymentDTO } from '../types/generated/PaymentDto'; import type { PaymentDTO } from '../types/generated/PaymentDTO';
export class PaymentViewModel { export class PaymentViewModel {
id: string; id: string;
@@ -14,7 +14,7 @@ export class PaymentViewModel {
createdAt: Date; createdAt: Date;
completedAt?: Date; completedAt?: Date;
constructor(dto: PaymentDto) { constructor(dto: PaymentDTO) {
Object.assign(this, dto); Object.assign(this, dto);
} }

View File

@@ -0,0 +1,19 @@
import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
export class RaceDetailEntryViewModel {
id: string;
name: string;
country: string;
avatarUrl: string;
isCurrentUser: boolean;
rating: number | null;
constructor(dto: RaceDetailEntryDTO, currentDriverId: string, rating?: number) {
this.id = dto.id;
this.name = dto.name;
this.country = dto.country;
this.avatarUrl = dto.avatarUrl;
this.isCurrentUser = dto.id === currentDriverId;
this.rating = rating ?? null;
}
}

View File

@@ -0,0 +1,23 @@
import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
export class RaceDetailUserResultViewModel {
position: number;
startPosition: number;
incidents: number;
fastestLap: number;
positionChange: number;
ratingChange: number;
isPodium: boolean;
isClean: boolean;
constructor(dto: RaceDetailUserResultDTO) {
this.position = dto.position;
this.startPosition = dto.startPosition;
this.incidents = dto.incidents;
this.fastestLap = dto.fastestLap;
this.positionChange = dto.positionChange;
this.ratingChange = dto.ratingChange;
this.isPodium = dto.isPodium;
this.isClean = dto.isClean;
}
}

View File

@@ -41,11 +41,11 @@ describe('RaceDetailViewModel', () => {
entryList: entries, entryList: entries,
registration, registration,
userResult, userResult,
}); }, 'current-driver');
expect(viewModel.race).toBe(race); expect(viewModel.race).toBe(race);
expect(viewModel.league).toBe(league); expect(viewModel.league).toBe(league);
expect(viewModel.entryList).toBe(entries); expect(viewModel.entryList).toHaveLength(0);
expect(viewModel.registration).toBe(registration); expect(viewModel.registration).toBe(registration);
expect(viewModel.userResult).toBe(userResult); expect(viewModel.userResult).toBe(userResult);
}); });

View File

@@ -2,14 +2,16 @@ import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO';
import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO';
import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO'; import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO';
import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO';
import { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO'; import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO';
import { RaceDetailEntryViewModel } from './RaceDetailEntryViewModel';
import { RaceDetailUserResultViewModel } from './RaceDetailUserResultViewModel';
export class RaceDetailViewModel { export class RaceDetailViewModel {
race: RaceDetailRaceDTO | null; race: RaceDetailRaceDTO | null;
league: RaceDetailLeagueDTO | null; league: RaceDetailLeagueDTO | null;
entryList: RaceDetailEntryDTO[]; entryList: RaceDetailEntryViewModel[];
registration: RaceDetailRegistrationDTO; registration: RaceDetailRegistrationDTO;
userResult: RaceDetailUserResultDTO | null; userResult: RaceDetailUserResultViewModel | null;
error?: string; error?: string;
constructor(dto: { constructor(dto: {
@@ -19,18 +21,18 @@ export class RaceDetailViewModel {
registration: RaceDetailRegistrationDTO; registration: RaceDetailRegistrationDTO;
userResult: RaceDetailUserResultDTO | null; userResult: RaceDetailUserResultDTO | null;
error?: string; error?: string;
}) { }, currentDriverId: string) {
this.race = dto.race; this.race = dto.race;
this.league = dto.league; this.league = dto.league;
this.entryList = dto.entryList; this.entryList = dto.entryList.map(entry => new RaceDetailEntryViewModel(entry, currentDriverId));
this.registration = dto.registration; this.registration = dto.registration;
this.userResult = dto.userResult; this.userResult = dto.userResult ? new RaceDetailUserResultViewModel(dto.userResult) : null;
this.error = dto.error; this.error = dto.error;
} }
/** UI-specific: Whether user is registered */ /** UI-specific: Whether user is registered */
get isRegistered(): boolean { get isRegistered(): boolean {
return this.registration.isRegistered; return this.registration.isUserRegistered;
} }
/** UI-specific: Whether user can register */ /** UI-specific: Whether user can register */

View File

@@ -61,6 +61,11 @@ export class RaceResultViewModel {
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`; return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(3, '0')}`;
} }
/** Required by ResultsTable */
getPositionChange(): number {
return this.positionChange;
}
// Note: The generated DTO doesn't have id or raceId // Note: The generated DTO doesn't have id or raceId
// These will need to be added when the OpenAPI spec is updated // These will need to be added when the OpenAPI spec is updated
id: string = ''; id: string = '';

View File

@@ -3,12 +3,11 @@ import { RaceWithSOFDTO } from '../types/generated/RaceWithSOFDTO';
export class RaceWithSOFViewModel { export class RaceWithSOFViewModel {
id: string; id: string;
track: string; track: string;
strengthOfField: number | null;
constructor(dto: RaceWithSOFDTO) { constructor(dto: RaceWithSOFDTO) {
this.id = dto.id; this.id = dto.id;
this.track = dto.track; this.track = dto.track;
this.strengthOfField = (dto as any).strengthOfField ?? null;
} }
// The view model currently exposes only basic race identity and track information.
// Additional strength-of-field or result details can be added here once the DTO carries them.
} }

View File

@@ -21,6 +21,10 @@ export class SponsorshipDetailViewModel {
status: string = 'active'; status: string = 'active';
amount: number = 0; amount: number = 0;
currency: string = 'USD'; currency: string = 'USD';
type: string = 'league';
entityName: string = '';
price: number = 0;
impressions: number = 0;
/** UI-specific: Formatted amount */ /** UI-specific: Formatted amount */
get formattedAmount(): string { get formattedAmount(): string {

View File

@@ -3,7 +3,7 @@ import type { TeamMemberDTO } from '@/lib/types/generated/GetTeamMembersOutputDT
export class TeamMemberViewModel { export class TeamMemberViewModel {
driverId: string; driverId: string;
driverName: string; driverName: string;
role: 'owner' | 'manager' | 'member'; role: 'owner' | 'admin' | 'member';
joinedAt: string; joinedAt: string;
isActive: boolean; isActive: boolean;
avatarUrl: string; avatarUrl: string;
@@ -26,7 +26,7 @@ export class TeamMemberViewModel {
get roleBadgeVariant(): string { get roleBadgeVariant(): string {
switch (this.role) { switch (this.role) {
case 'owner': return 'primary'; case 'owner': return 'primary';
case 'manager': return 'secondary'; case 'admin': return 'secondary';
case 'member': return 'default'; case 'member': return 'default';
default: return 'default'; default: return 'default';
} }

View File

@@ -1,4 +1,4 @@
import { WalletDto } from '../types/generated/WalletDto'; import { WalletDTO } from '../types/generated/WalletDTO';
import { FullTransactionDto, WalletTransactionViewModel } from './WalletTransactionViewModel'; import { FullTransactionDto, WalletTransactionViewModel } from './WalletTransactionViewModel';
export class WalletViewModel { export class WalletViewModel {
@@ -11,7 +11,7 @@ export class WalletViewModel {
createdAt: string; createdAt: string;
currency: string; currency: string;
constructor(dto: WalletDto & { transactions?: FullTransactionDto[] }) { constructor(dto: WalletDTO & { transactions?: any[] }) {
this.id = dto.id; this.id = dto.id;
this.leagueId = dto.leagueId; this.leagueId = dto.leagueId;
this.balance = dto.balance; this.balance = dto.balance;

View File

@@ -1,16 +1,34 @@
{ {
"extends": "../../tsconfig.base.json",
"compilerOptions": { "compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"], "target": "ES2022",
"allowJs": true, "module": "ESNext",
"module": "esnext", "lib": ["ES2022", "DOM", "dom", "dom.iterable", "esnext"],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"baseUrl": ".", "esModuleInterop": true,
"jsx": "preserve", "jsx": "preserve",
"incremental": true, "strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"strictNullChecks": true,
"alwaysStrict": true,
"exactOptionalPropertyTypes": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"baseUrl": ".",
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"incremental": true,
"noEmitOnError": true, "noEmitOnError": true,
"allowJs": true,
"types": ["react", "react-dom"], "types": ["react", "react-dom"],
"plugins": [ "plugins": [
{ {
@@ -19,9 +37,15 @@
], ],
"paths": { "paths": {
"@/*": ["./*"], "@/*": ["./*"],
"@testing/*": ["../../testing/*"] "@testing/*": ["../../testing/*"],
"@/lib/dtos": ["./lib/dtos"],
"@/lib/view-models": ["./lib/view-models"],
"@/lib/presenters": ["./lib/presenters"],
"@/lib/services": ["./lib/services"],
"@/lib/api": ["./lib/api"],
"@/lib/types": ["./lib/types"]
} }
}, },
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], "include": ["next-env.d.ts", "app/", "components/", "hooks/", "lib/", "types/", "utilities/"],
"exclude": ["node_modules", ".next"] "exclude": ["../../core/**", "../../adapters/**", "../../apps/api/**", "../../scripts/**", "../../testing/**", "../../html-dumps/**", "../../html-dumps-optimized/**", "../../nginx/**", "../../plans/**", "../../resources/**", "../../docs/**", "node_modules", ".next"]
} }

View File

@@ -15,6 +15,9 @@
import { execSync } from 'child_process'; import { execSync } from 'child_process';
import fs from 'fs/promises'; import fs from 'fs/promises';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
async function generateTypes() { async function generateTypes() {
const openapiPath = path.join(__dirname, '../apps/api/openapi.json'); const openapiPath = path.join(__dirname, '../apps/api/openapi.json');

View File

@@ -34,7 +34,8 @@
"@/lib/view-models": ["./apps/website/lib/view-models"], "@/lib/view-models": ["./apps/website/lib/view-models"],
"@/lib/presenters": ["./apps/website/lib/presenters"], "@/lib/presenters": ["./apps/website/lib/presenters"],
"@/lib/services": ["./apps/website/lib/services"], "@/lib/services": ["./apps/website/lib/services"],
"@/lib/api": ["./apps/website/lib/api"] "@/lib/api": ["./apps/website/lib/api"],
"@/lib/types": ["./apps/website/lib/types"]
} }
}, },
"exclude": ["node_modules", "dist"] "exclude": ["node_modules", "dist"]