From fc386db06a728dd1bf0d441e46879dc75f27bbb4 Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Thu, 18 Dec 2025 15:58:09 +0100 Subject: [PATCH] refactor page to use services --- apps/api/openapi.json | 582 +++++++++--------- .../api/src/domain/league/LeagueController.ts | 31 + apps/website/app/dashboard/page.tsx | 196 ++---- apps/website/app/drivers/[id]/page.tsx | 204 ++---- apps/website/app/leagues/[id]/layout.tsx | 86 +-- apps/website/app/leagues/[id]/page.tsx | 356 +++-------- .../app/leagues/[id]/settings/page.tsx | 141 +---- .../stewarding/protests/[protestId]/page.tsx | 201 +++--- apps/website/app/races/[id]/page.tsx | 46 +- apps/website/app/teams/[id]/page.tsx | 109 ++-- .../lib/api/dashboard/DashboardApiClient.ts | 71 +++ .../lib/api/drivers/DriversApiClient.ts | 12 +- apps/website/lib/api/index.ts | 3 + .../lib/api/leagues/LeaguesApiClient.ts | 28 + apps/website/lib/api/media/MediaApiClient.ts | 20 +- .../lib/api/protests/ProtestsApiClient.ts | 33 + .../lib/api/sponsors/SponsorsApiClient.ts | 5 + apps/website/lib/api/teams/TeamsApiClient.ts | 16 +- .../protests/ProtestDecisionCommandModel.ts | 58 ++ apps/website/lib/services/ServiceFactory.ts | 54 +- .../services/dashboard/DashboardService.ts | 22 + .../lib/services/drivers/DriverService.ts | 14 +- .../lib/services/leagues/LeagueService.ts | 167 ++++- .../services/leagues/LeagueSettingsService.ts | 95 +++ .../lib/services/media/MediaService.ts | 21 +- .../lib/services/protests/ProtestService.ts | 70 +++ .../website/lib/services/races/RaceService.ts | 28 + .../website/lib/services/teams/TeamService.ts | 38 +- apps/website/lib/types/DriverDTO.ts | 13 + apps/website/lib/types/GetAvatarOutputDto.ts | 9 + .../lib/types/LeagueScoringPresetDTO.ts | 15 + apps/website/lib/types/RaceDetailEntryDTO.ts | 14 + .../types/generated/ApplyPenaltyCommandDTO.ts | 6 +- .../lib/types/generated/DriverProfileDTO.ts | 100 +++ .../types/generated/LeagueAdminProtestsDTO.ts | 19 + .../view-models/DashboardOverviewViewModel.ts | 181 ++++++ .../lib/view-models/DriverProfileViewModel.ts | 141 +++++ .../lib/view-models/DriverSummaryViewModel.ts | 21 + .../view-models/LeagueDetailPageViewModel.ts | 192 ++++++ .../lib/view-models/LeagueDetailViewModel.ts | 35 ++ .../LeagueScoringPresetsViewModel.ts | 18 + .../view-models/LeagueSettingsViewModel.ts | 39 ++ .../lib/view-models/ProtestViewModel.ts | 24 +- .../view-models/RaceDetailViewModel.test.ts | 8 +- .../lib/view-models/RaceDetailViewModel.ts | 4 +- 45 files changed, 2254 insertions(+), 1292 deletions(-) create mode 100644 apps/website/lib/api/dashboard/DashboardApiClient.ts create mode 100644 apps/website/lib/api/protests/ProtestsApiClient.ts create mode 100644 apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts create mode 100644 apps/website/lib/services/dashboard/DashboardService.ts create mode 100644 apps/website/lib/services/leagues/LeagueSettingsService.ts create mode 100644 apps/website/lib/services/protests/ProtestService.ts create mode 100644 apps/website/lib/types/DriverDTO.ts create mode 100644 apps/website/lib/types/GetAvatarOutputDto.ts create mode 100644 apps/website/lib/types/LeagueScoringPresetDTO.ts create mode 100644 apps/website/lib/types/RaceDetailEntryDTO.ts create mode 100644 apps/website/lib/types/generated/DriverProfileDTO.ts create mode 100644 apps/website/lib/types/generated/LeagueAdminProtestsDTO.ts create mode 100644 apps/website/lib/view-models/DashboardOverviewViewModel.ts create mode 100644 apps/website/lib/view-models/DriverProfileViewModel.ts create mode 100644 apps/website/lib/view-models/DriverSummaryViewModel.ts create mode 100644 apps/website/lib/view-models/LeagueDetailPageViewModel.ts create mode 100644 apps/website/lib/view-models/LeagueDetailViewModel.ts create mode 100644 apps/website/lib/view-models/LeagueScoringPresetsViewModel.ts create mode 100644 apps/website/lib/view-models/LeagueSettingsViewModel.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index ea1e3e8ba..4f1381f24 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -198,171 +198,6 @@ "contactEmail" ] }, - "UpdatePaymentStatusInputDTO": { - "type": "object", - "properties": { - "paymentId": { - "type": "string" - } - }, - "required": [ - "paymentId" - ] - }, - "PaymentDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ] - }, - "MembershipFeeDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "leagueId": { - "type": "string" - } - }, - "required": [ - "id", - "leagueId" - ] - }, - "MemberPaymentDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "feeId": { - "type": "string" - }, - "driverId": { - "type": "string" - }, - "amount": { - "type": "number" - }, - "platformFee": { - "type": "number" - }, - "netAmount": { - "type": "number" - } - }, - "required": [ - "id", - "feeId", - "driverId", - "amount", - "platformFee", - "netAmount" - ] - }, - "PrizeDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "leagueId": { - "type": "string" - }, - "seasonId": { - "type": "string" - }, - "position": { - "type": "number" - }, - "name": { - "type": "string" - }, - "amount": { - "type": "number" - } - }, - "required": [ - "id", - "leagueId", - "seasonId", - "position", - "name", - "amount" - ] - }, - "WalletDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "leagueId": { - "type": "string" - }, - "balance": { - "type": "number" - }, - "totalRevenue": { - "type": "number" - }, - "totalPlatformFees": { - "type": "number" - }, - "totalWithdrawn": { - "type": "number" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "currency": { - "type": "string" - } - }, - "required": [ - "id", - "leagueId", - "balance", - "totalRevenue", - "totalPlatformFees", - "totalWithdrawn", - "createdAt", - "currency" - ] - }, - "TransactionDto": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "walletId": { - "type": "string" - } - }, - "required": [ - "id", - "walletId" - ] - }, - "PaymentDTO": { - "type": "object", - "properties": { - "id": { - "type": "string" - } - }, - "required": [ - "id" - ] - }, "WithdrawFromRaceParamsDTO": { "type": "object", "properties": { @@ -1058,6 +893,297 @@ "suitColor" ] }, + "UpdatePaymentStatusInputDTO": { + "type": "object", + "properties": { + "paymentId": { + "type": "string" + } + }, + "required": [ + "paymentId" + ] + }, + "PaymentDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "MembershipFeeDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "leagueId": { + "type": "string" + } + }, + "required": [ + "id", + "leagueId" + ] + }, + "MemberPaymentDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "feeId": { + "type": "string" + }, + "driverId": { + "type": "string" + }, + "amount": { + "type": "number" + }, + "platformFee": { + "type": "number" + }, + "netAmount": { + "type": "number" + } + }, + "required": [ + "id", + "feeId", + "driverId", + "amount", + "platformFee", + "netAmount" + ] + }, + "PrizeDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "leagueId": { + "type": "string" + }, + "seasonId": { + "type": "string" + }, + "position": { + "type": "number" + }, + "name": { + "type": "string" + }, + "amount": { + "type": "number" + } + }, + "required": [ + "id", + "leagueId", + "seasonId", + "position", + "name", + "amount" + ] + }, + "WalletDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "leagueId": { + "type": "string" + }, + "balance": { + "type": "number" + }, + "totalRevenue": { + "type": "number" + }, + "totalPlatformFees": { + "type": "number" + }, + "totalWithdrawn": { + "type": "number" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "currency": { + "type": "string" + } + }, + "required": [ + "id", + "leagueId", + "balance", + "totalRevenue", + "totalPlatformFees", + "totalWithdrawn", + "createdAt", + "currency" + ] + }, + "TransactionDto": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "walletId": { + "type": "string" + } + }, + "required": [ + "id", + "walletId" + ] + }, + "PaymentDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + } + }, + "required": [ + "id" + ] + }, + "GetDriverRegistrationStatusQueryDTO": { + "type": "object", + "properties": { + "raceId": { + "type": "string" + }, + "driverId": { + "type": "string" + } + }, + "required": [ + "raceId", + "driverId" + ] + }, + "DriverStatsDTO": { + "type": "object", + "properties": { + "totalDrivers": { + "type": "number" + } + }, + "required": [ + "totalDrivers" + ] + }, + "DriverRegistrationStatusDTO": { + "type": "object", + "properties": { + "isRegistered": { + "type": "boolean" + }, + "raceId": { + "type": "string" + }, + "driverId": { + "type": "string" + } + }, + "required": [ + "isRegistered", + "raceId", + "driverId" + ] + }, + "DriverLeaderboardItemDTO": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "rating": { + "type": "number" + }, + "skillLevel": { + "type": "string" + }, + "nationality": { + "type": "string" + }, + "racesCompleted": { + "type": "number" + }, + "wins": { + "type": "number" + }, + "podiums": { + "type": "number" + }, + "isActive": { + "type": "boolean" + }, + "rank": { + "type": "number" + } + }, + "required": [ + "id", + "name", + "rating", + "skillLevel", + "nationality", + "racesCompleted", + "wins", + "podiums", + "isActive", + "rank" + ] + }, + "CompleteOnboardingOutputDTO": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + } + }, + "required": [ + "success" + ] + }, + "CompleteOnboardingInputDTO": { + "type": "object", + "properties": { + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "displayName": { + "type": "string" + }, + "country": { + "type": "string" + } + }, + "required": [ + "firstName", + "lastName", + "displayName", + "country" + ] + }, "UpdateLeagueMemberRoleOutputDTO": { "type": "object", "properties": { @@ -1525,132 +1651,6 @@ "leagueId" ] }, - "GetDriverRegistrationStatusQueryDTO": { - "type": "object", - "properties": { - "raceId": { - "type": "string" - }, - "driverId": { - "type": "string" - } - }, - "required": [ - "raceId", - "driverId" - ] - }, - "DriverStatsDTO": { - "type": "object", - "properties": { - "totalDrivers": { - "type": "number" - } - }, - "required": [ - "totalDrivers" - ] - }, - "DriverRegistrationStatusDTO": { - "type": "object", - "properties": { - "isRegistered": { - "type": "boolean" - }, - "raceId": { - "type": "string" - }, - "driverId": { - "type": "string" - } - }, - "required": [ - "isRegistered", - "raceId", - "driverId" - ] - }, - "DriverLeaderboardItemDTO": { - "type": "object", - "properties": { - "id": { - "type": "string" - }, - "name": { - "type": "string" - }, - "rating": { - "type": "number" - }, - "skillLevel": { - "type": "string" - }, - "nationality": { - "type": "string" - }, - "racesCompleted": { - "type": "number" - }, - "wins": { - "type": "number" - }, - "podiums": { - "type": "number" - }, - "isActive": { - "type": "boolean" - }, - "rank": { - "type": "number" - } - }, - "required": [ - "id", - "name", - "rating", - "skillLevel", - "nationality", - "racesCompleted", - "wins", - "podiums", - "isActive", - "rank" - ] - }, - "CompleteOnboardingOutputDTO": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] - }, - "CompleteOnboardingInputDTO": { - "type": "object", - "properties": { - "firstName": { - "type": "string" - }, - "lastName": { - "type": "string" - }, - "displayName": { - "type": "string" - }, - "country": { - "type": "string" - } - }, - "required": [ - "firstName", - "lastName", - "displayName", - "country" - ] - }, "AuthenticatedUserDTO": { "type": "object", "properties": { diff --git a/apps/api/src/domain/league/LeagueController.ts b/apps/api/src/domain/league/LeagueController.ts index be4fc44ab..f2977c9c0 100644 --- a/apps/api/src/domain/league/LeagueController.ts +++ b/apps/api/src/domain/league/LeagueController.ts @@ -153,6 +153,37 @@ export class LeagueController { return this.leagueService.getLeagueProtests(query); } + @Get(':leagueId/protests/:protestId') + @ApiOperation({ summary: 'Get a specific protest for a league' }) + @ApiResponse({ status: 200, description: 'Protest details', type: LeagueAdminProtestsDTO }) + async getLeagueProtest( + @Param('leagueId') leagueId: string, + @Param('protestId') protestId: string, + ): Promise { + const query: GetLeagueProtestsQuery = { leagueId }; + const allProtests = await this.leagueService.getLeagueProtests(query); + + // Filter to only include the specific protest + const protest = allProtests.protests.find(p => p.id === protestId); + if (!protest) { + throw new Error('Protest not found'); + } + + // Find the race for this protest + const race = allProtests.racesById[protest.raceId]; + const protestingDriver = allProtests.driversById[protest.protestingDriverId]; + const accusedDriver = allProtests.driversById[protest.accusedDriverId]; + + return { + protests: [protest], + racesById: race ? { [race.id]: race } : {}, + driversById: { + ...(protestingDriver ? { [protestingDriver.id]: protestingDriver } : {}), + ...(accusedDriver ? { [accusedDriver.id]: accusedDriver } : {}), + }, + }; + } + @Get(':leagueId/seasons') @ApiOperation({ summary: 'Get seasons for a league' }) @ApiResponse({ status: 200, description: 'List of seasons for the league', type: [LeagueSeasonSummaryDTO] }) diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index a8966d478..9e81652ef 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState, useEffect } from 'react'; import Image from 'next/image'; import Link from 'next/link'; import { @@ -9,144 +10,24 @@ import { Star, Clock, Flag, - TrendingUp, ChevronRight, - Zap, Target, Award, Activity, Play, - Bell, Medal, Crown, Heart, - MessageCircle, UserPlus, } from 'lucide-react'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; -// TODO: Re-enable API integration once backend is ready -// import type { -// DashboardOverviewViewModel, -// DashboardFeedItemSummaryViewModel, -// } from '@core/racing/application/presenters/IDashboardOverviewPresenter'; +// Dashboard service imports +import { ServiceFactory } from '@/lib/services/ServiceFactory'; +import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; -// Mock data for prototype -const MOCK_CURRENT_DRIVER = { - id: 'driver-1', - name: 'Max Verstappen', - avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=MaxV', - country: 'NL', - totalRaces: 142, - wins: 28, - podiums: 67, - rating: 2847, - globalRank: 15, - consistency: 94, -}; - -const MOCK_NEXT_RACE = { - id: 'race-1', - track: 'Spa-Francorchamps', - car: 'Porsche 911 GT3 R', - scheduledAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), // 2 days from now - isMyLeague: true, - leagueName: 'GT3 Masters Series', -}; - -const MOCK_UPCOMING_RACES = [ - { - id: 'race-1', - track: 'Spa-Francorchamps', - car: 'Porsche 911 GT3 R', - scheduledAt: new Date(Date.now() + 2 * 24 * 60 * 60 * 1000), - isMyLeague: true, - }, - { - id: 'race-2', - track: 'Nürburgring GP', - car: 'BMW M4 GT3', - scheduledAt: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000), - isMyLeague: true, - }, - { - id: 'race-3', - track: 'Monza', - car: 'Ferrari 296 GT3', - scheduledAt: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000), - isMyLeague: false, - }, - { - id: 'race-4', - track: 'Silverstone', - car: 'Aston Martin Vantage GT3', - scheduledAt: new Date(Date.now() + 12 * 24 * 60 * 60 * 1000), - isMyLeague: true, - }, -]; - -const MOCK_LEAGUE_STANDINGS = [ - { - leagueId: 'league-1', - leagueName: 'GT3 Masters Series', - position: 2, - points: 186, - totalDrivers: 24, - }, - { - leagueId: 'league-2', - leagueName: 'Endurance Pro League', - position: 5, - points: 142, - totalDrivers: 32, - }, - { - leagueId: 'league-3', - leagueName: 'F1 Weekend Warriors', - position: 1, - points: 225, - totalDrivers: 18, - }, -]; - -const MOCK_FEED_ITEMS = [ - { - id: 'feed-1', - type: 'win', - headline: 'You won the race at Spa-Francorchamps!', - body: 'Great driving! You finished P1 with a 3.2s gap to second place.', - timestamp: new Date(Date.now() - 2 * 60 * 60 * 1000), - ctaHref: '/races/race-prev-1', - ctaLabel: 'View Results', - }, - { - id: 'feed-2', - type: 'friend_join', - headline: 'Lewis Hamilton joined GT3 Masters Series', - body: null, - timestamp: new Date(Date.now() - 8 * 60 * 60 * 1000), - ctaHref: '/leagues/league-1', - ctaLabel: 'View League', - }, - { - id: 'feed-3', - type: 'podium', - headline: 'Charles Leclerc finished P2 at Monza', - body: 'Your friend had a great race!', - timestamp: new Date(Date.now() - 24 * 60 * 60 * 1000), - ctaHref: '/drivers/driver-2', - ctaLabel: 'View Profile', - }, -]; - -const MOCK_FRIENDS = [ - { id: 'friend-1', name: 'Lewis Hamilton', avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Lewis', country: 'GB' }, - { id: 'friend-2', name: 'Charles Leclerc', avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Charles', country: 'MC' }, - { id: 'friend-3', name: 'Lando Norris', avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Lando', country: 'GB' }, - { id: 'friend-4', name: 'Oscar Piastri', avatarUrl: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Oscar', country: 'AU' }, -]; // Helper functions function getCountryFlag(countryCode: string): string { @@ -197,29 +78,56 @@ function getGreeting(): string { return 'Good evening'; } -interface FeedItem { - id: string; - type: string; - headline: string; - body: string | null; - timestamp: Date; - ctaHref?: string; - ctaLabel?: string; -} +import { DashboardFeedItemSummaryViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; export default function DashboardPage() { - // TODO: Re-enable API integration once backend is ready - // Currently using mock data for prototype - - const currentDriver = MOCK_CURRENT_DRIVER; - const nextRace = MOCK_NEXT_RACE; - const upcomingRaces = MOCK_UPCOMING_RACES; - const leagueStandingsSummaries = MOCK_LEAGUE_STANDINGS; - const feedSummary = { items: MOCK_FEED_ITEMS }; - const friends = MOCK_FRIENDS; - const activeLeaguesCount = 3; + const [dashboardData, setDashboardData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); - const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver; + useEffect(() => { + const fetchDashboardData = async () => { + try { + const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); + const dashboardService = serviceFactory.createDashboardService(); + const data = await dashboardService.getDashboardOverview(); + setDashboardData(data); + } catch (err) { + console.error('Failed to fetch dashboard data:', err); + setError('Failed to load dashboard data'); + } finally { + setIsLoading(false); + } + }; + + fetchDashboardData(); + }, []); + + if (isLoading) { + return ( +
+
Loading dashboard...
+
+ ); + } + + if (error || !dashboardData) { + return ( +
+
{error || 'Failed to load dashboard'}
+
+ ); + } + + const currentDriver = dashboardData.currentDriver; + const nextRace = dashboardData.nextRace; + const upcomingRaces = dashboardData.upcomingRaces; + const leagueStandingsSummaries = dashboardData.leagueStandings; + const feedSummary = { items: dashboardData.feedItems }; + const friends = dashboardData.friends; + const activeLeaguesCount = dashboardData.activeLeaguesCount; + + const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver; return (
@@ -581,7 +489,7 @@ export default function DashboardPage() { } // Feed Item Row Component -function FeedItemRow({ item }: { item: FeedItem }) { +function FeedItemRow({ item }: { item: DashboardFeedItemSummaryViewModel }) { const getActivityIcon = (type: string) => { if (type.includes('win')) return { icon: Trophy, color: 'text-yellow-400 bg-yellow-400/10' }; if (type.includes('podium')) return { icon: Medal, color: 'text-warning-amber bg-warning-amber/10' }; diff --git a/apps/website/app/drivers/[id]/page.tsx b/apps/website/app/drivers/[id]/page.tsx index dad75bb93..6fab6f405 100644 --- a/apps/website/app/drivers/[id]/page.tsx +++ b/apps/website/app/drivers/[id]/page.tsx @@ -29,23 +29,15 @@ import { MessageCircle, ArrowLeft, BarChart3, - History, Shield, Percent, Activity, - Megaphone, - DollarSign, } from 'lucide-react'; -import { AllTeamsPresenter } from '@/lib/presenters/AllTeamsPresenter'; -import { TeamMembersPresenter } from '@/lib/presenters/TeamMembersPresenter'; -import { Driver, EntityMappers, type Team } from '@core/racing'; -import type { DriverDTO } from '@core/racing'; -import type { ProfileOverviewViewModel } from '@core/racing/application/presenters/IProfileOverviewPresenter'; import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Breadcrumbs from '@/components/layout/Breadcrumbs'; -import type { GetDriverTeamQueryResultDTO } from '@core/racing/application/dtos/GetDriverTeamQueryResultDTO'; -import type { TeamMemberViewModel } from '@core/racing/application/presenters/ITeamMembersPresenter'; +import { ServiceFactory } from '@/lib/services/ServiceFactory'; +import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; // ============================================================================ // TYPES @@ -53,6 +45,16 @@ import type { TeamMemberViewModel } from '@core/racing/application/presenters/IT type ProfileTab = 'overview' | 'stats'; +interface Team { + id: string; + name: string; + tag: string; + description: string; + ownerId: string; + leagues: unknown[]; // TODO: define proper type + createdAt: Date; +} + interface SocialHandle { platform: 'twitter' | 'youtube' | 'twitch' | 'discord'; handle: string; @@ -306,59 +308,19 @@ function HorizontalBarChart({ data, maxValue }: BarChartProps) { // MAIN PAGE // ============================================================================ -interface DriverProfileStatsViewModel { - rating: number; - wins: number; - podiums: number; - dnfs: number; - totalRaces: number; - avgFinish: number; - bestFinish: number; - worstFinish: number; - consistency: number; - percentile: number; -} -interface DriverProfileFriendViewModel { - id: string; - name: string; - country: string; -} - -interface DriverProfileExtendedViewModel extends DriverExtendedProfile {} - -interface DriverProfileViewModel { - currentDriver?: { - id: string; - name: string; - iracingId?: string | null; - country: string; - bio?: string | null; - joinedAt: string | Date; - globalRank?: number; - totalDrivers?: number; - }; - stats?: DriverProfileStatsViewModel; - extendedProfile?: DriverProfileExtendedViewModel; - socialSummary?: { - friends: DriverProfileFriendViewModel[]; - }; -} export default function DriverDetailPage() { const router = useRouter(); const params = useParams(); const driverId = params.id as string; - const [driver, setDriver] = useState(null); + const [driverProfile, setDriverProfile] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState('overview'); - const [teamData, setTeamData] = useState(null); const [allTeamMemberships, setAllTeamMemberships] = useState([]); - const [friends, setFriends] = useState([]); const [friendRequestSent, setFriendRequestSent] = useState(false); - const [profileData, setProfileData] = useState(null); const search = typeof window !== 'undefined' @@ -392,56 +354,39 @@ export default function DriverDetailPage() { const loadDriver = async () => { try { - // Use GetProfileOverviewUseCase to load all profile data - const profileUseCase = getGetProfileOverviewUseCase(); - const profileViewModel = await profileUseCase.execute({ driverId }); + // Initialize service factory + const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || ''); + const driverService = serviceFactory.createDriverService(); + const teamService = serviceFactory.createTeamService(); - if (!profileViewModel || !profileViewModel.currentDriver) { + // Get driver profile + const profileViewModel = await driverService.getDriverProfile(driverId); + + if (!profileViewModel.currentDriver) { setError('Driver not found'); setLoading(false); return; } - // Set driver from ViewModel - const driverData: DriverDTO = { - id: profileViewModel.currentDriver.id, - name: profileViewModel.currentDriver.name, - iracingId: profileViewModel.currentDriver.iracingId ?? '', - country: profileViewModel.currentDriver.country, - bio: profileViewModel.currentDriver.bio || '', - joinedAt: profileViewModel.currentDriver.joinedAt, - }; - setDriver(driverData); - setProfileData(profileViewModel); + setDriverProfile(profileViewModel); - // Load ALL team memberships using caller-owned presenters - const allTeamsUseCase = getGetAllTeamsUseCase(); - const allTeamsPresenter = new AllTeamsPresenter(); - await allTeamsUseCase.execute(undefined as void, allTeamsPresenter); - const allTeamsViewModel = allTeamsPresenter.getViewModel(); - const allTeams = allTeamsViewModel?.teams ?? []; - - const membershipsUseCase = getGetTeamMembersUseCase(); + // Load team memberships - get all teams and check memberships + const allTeams = await teamService.getAllTeams(); const memberships: TeamMembershipInfo[] = []; for (const team of allTeams) { - const teamMembersPresenter = new TeamMembersPresenter(); - await membershipsUseCase.execute({ teamId: team.id }, teamMembersPresenter); - const membersResult = teamMembersPresenter.getViewModel(); - const members = membersResult?.members ?? []; - const membership = members.find( - (member: TeamMemberViewModel) => member.driverId === driverId, - ); + const teamMembers = await teamService.getTeamMembers(team.id, driverId, ''); // ownerId not available in summary + const membership = teamMembers.find(member => member.driverId === driverId); if (membership) { memberships.push({ team: { id: team.id, name: team.name, - tag: team.tag, - description: team.description, - ownerId: '', - leagues: team.leagues, - createdAt: new Date(), + tag: '', // Not available in summary + description: '', // Not available in summary + ownerId: '', // Not available in summary + leagues: [], // TODO: populate if needed + createdAt: new Date(), // TODO: add to API } as Team, role: membership.role, joinedAt: new Date(membership.joinedAt), @@ -449,16 +394,6 @@ export default function DriverDetailPage() { } } setAllTeamMemberships(memberships); - - // Set friends from ViewModel - const friendsList = profileViewModel.socialSummary?.friends.map(f => { - return { - id: f.id, - name: f.name, - country: f.country, - } as Driver; - }) || []; - setFriends(friendsList); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load driver'); } finally { @@ -483,7 +418,7 @@ export default function DriverDetailPage() { ); } - if (error || !driver) { + if (error || !driverProfile?.currentDriver) { return (
@@ -497,12 +432,12 @@ export default function DriverDetailPage() { ); } - const demoExtended = getDemoExtendedProfile(driver.id); + const demoExtended = getDemoExtendedProfile(driverProfile.currentDriver.id); const extendedProfile: DriverExtendedProfile = { - socialHandles: profileData?.extendedProfile?.socialHandles ?? demoExtended.socialHandles, + socialHandles: driverProfile?.extendedProfile?.socialHandles ?? demoExtended.socialHandles, achievements: - profileData?.extendedProfile?.achievements - ? profileData.extendedProfile.achievements.map((achievement) => ({ + driverProfile?.extendedProfile?.achievements + ? driverProfile.extendedProfile.achievements.map((achievement) => ({ id: achievement.id, title: achievement.title, description: achievement.description, @@ -511,25 +446,27 @@ export default function DriverDetailPage() { earnedAt: new Date(achievement.earnedAt), })) : demoExtended.achievements, - racingStyle: profileData?.extendedProfile?.racingStyle ?? demoExtended.racingStyle, - favoriteTrack: profileData?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack, - favoriteCar: profileData?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar, - timezone: profileData?.extendedProfile?.timezone ?? demoExtended.timezone, - availableHours: profileData?.extendedProfile?.availableHours ?? demoExtended.availableHours, + racingStyle: driverProfile?.extendedProfile?.racingStyle ?? demoExtended.racingStyle, + favoriteTrack: driverProfile?.extendedProfile?.favoriteTrack ?? demoExtended.favoriteTrack, + favoriteCar: driverProfile?.extendedProfile?.favoriteCar ?? demoExtended.favoriteCar, + timezone: driverProfile?.extendedProfile?.timezone ?? demoExtended.timezone, + availableHours: driverProfile?.extendedProfile?.availableHours ?? demoExtended.availableHours, lookingForTeam: - profileData?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam, + driverProfile?.extendedProfile?.lookingForTeam ?? demoExtended.lookingForTeam, openToRequests: - profileData?.extendedProfile?.openToRequests ?? demoExtended.openToRequests, + driverProfile?.extendedProfile?.openToRequests ?? demoExtended.openToRequests, }; - const stats = profileData?.stats || null; - const globalRank = profileData?.currentDriver?.globalRank || 1; + const stats = driverProfile?.stats || null; + const globalRank = driverProfile?.currentDriver?.globalRank || 1; + const driver = driverProfile.currentDriver; // Build sponsor insights for driver + const friendsCount = driverProfile?.socialSummary?.friends?.length ?? 0; const driverMetrics = [ MetricBuilders.rating(stats?.rating ?? 0, 'Driver Rating'), - MetricBuilders.views((friends.length * 8) + 50), + MetricBuilders.views((friendsCount * 8) + 50), MetricBuilders.engagement(stats?.consistency ?? 75), - MetricBuilders.reach((friends.length * 12) + 100), + MetricBuilders.reach((friendsCount * 12) + 100), ]; return ( @@ -596,7 +533,7 @@ export default function DriverDetailPage() {
{driver.name} {getCountryFlag(driver.country)} - {teamData?.team.tag && ( - - [{teamData.team.tag}] - - )}
{/* Rating and Rank */} @@ -636,16 +568,6 @@ export default function DriverDetailPage() {
)} - {teamData && ( - - - {teamData.team.name} - - - )}
{/* Meta info */} @@ -982,37 +904,37 @@ export default function DriverDetailPage() { {/* Friends Preview */} - {friends.length > 0 && ( + {driverProfile.socialSummary.friends.length > 0 && (

Friends - ({friends.length}) + ({driverProfile.socialSummary.friends.length})

- {friends.slice(0, 8).map((friend) => ( + {driverProfile.socialSummary.friends.slice(0, 8).map((friend) => (
- {friend.name} -
+ {friend.name} +
{friend.name} {getCountryFlag(friend.country)} ))} - {friends.length > 8 && ( -
+{friends.length - 8} more
+ {driverProfile.socialSummary.friends.length > 8 && ( +
+{driverProfile.socialSummary.friends.length - 8} more
)}
diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index d076975f9..2874e920e 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -3,18 +3,11 @@ import Breadcrumbs from '@/components/layout/Breadcrumbs'; import LeagueHeader from '@/components/leagues/LeagueHeader'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; -import type { League } from '@core/racing/domain/entities/League'; +import { ServiceFactory } from '@/lib/services/ServiceFactory'; +import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel'; import { useParams, usePathname, useRouter } from 'next/navigation'; import React, { useEffect, useState } from 'react'; -// Main sponsor info for "by XYZ" display -interface MainSponsorInfo { - name: string; - logoUrl: string; - websiteUrl: string; -} - export default function LeagueLayout({ children, }: { @@ -26,61 +19,18 @@ export default function LeagueLayout({ const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const [league, setLeague] = useState(null); - const [ownerName, setOwnerName] = useState(''); - const [mainSponsor, setMainSponsor] = useState(null); - const [isAdmin, setIsAdmin] = useState(false); + const [leagueDetail, setLeagueDetail] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { async function loadLeague() { try { - const leagueRepo = getLeagueRepository(); - const driverRepo = getDriverRepository(); - const membershipRepo = getLeagueMembershipRepository(); - const seasonRepo = getSeasonRepository(); - const sponsorRepo = getSponsorRepository(); - const sponsorshipRepo = getSeasonSponsorshipRepository(); - - const leagueData = await leagueRepo.findById(leagueId); - - if (!leagueData) { - setLoading(false); - return; - } + const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || ''); + const leagueService = serviceFactory.createLeagueService(); - setLeague(leagueData); + const leagueDetailData = await leagueService.getLeagueDetail(leagueId, currentDriverId); - const owner = await driverRepo.findById(leagueData.ownerId); - setOwnerName(owner ? owner.name : `${leagueData.ownerId.slice(0, 8)}...`); - - // Check if current user is admin - const membership = await membershipRepo.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); - - // Load main sponsor for "by XYZ" display - try { - const seasons = await seasonRepo.findByLeagueId(leagueId); - const activeSeason = seasons.find((s: { status: string }) => s.status === 'active') ?? seasons[0]; - - if (activeSeason) { - const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id); - const mainSponsorship = sponsorships.find(s => s.tier === 'main' && s.status === 'active'); - - if (mainSponsorship) { - const sponsor = await sponsorRepo.findById(mainSponsorship.sponsorId); - if (sponsor) { - setMainSponsor({ - name: sponsor.name, - logoUrl: sponsor.logoUrl ?? '', - websiteUrl: sponsor.websiteUrl ?? '', - }); - } - } - } - } catch (sponsorError) { - console.warn('Failed to load main sponsor:', sponsorError); - } + setLeagueDetail(leagueDetailData); } catch (error) { console.error('Failed to load league:', error); } finally { @@ -101,7 +51,7 @@ export default function LeagueLayout({ ); } - if (!league) { + if (!leagueDetail) { return (
@@ -126,12 +76,8 @@ export default function LeagueLayout({ { label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false }, ]; - const tabs = isAdmin ? [...baseTabs, ...adminTabs] : baseTabs; + const tabs = leagueDetail.isAdmin ? [...baseTabs, ...adminTabs] : baseTabs; - // Determine active tab - const activeTab = tabs.find(tab => - tab.exact ? pathname === tab.href : pathname.startsWith(tab.href) - ); return (
@@ -140,17 +86,17 @@ export default function LeagueLayout({ items={[ { label: 'Home', href: '/' }, { label: 'Leagues', href: '/leagues' }, - { label: league.name }, + { label: leagueDetail.name }, ]} /> {/* Tab Navigation */} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 31ee97cfb..5056c14e6 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -13,180 +13,54 @@ import SponsorInsightsCard, { import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { getLeagueMembers, getMembership } from '@/lib/leagueMembership'; -import { getLeagueRoleDisplay } from '@/lib/leagueRoles'; -import { LeagueScoringConfigPresenter } from '@/lib/presenters/LeagueScoringConfigPresenter'; -import { - Driver, - EntityMappers, - League, - Race, - type DriverDTO, - type LeagueScoringConfigDTO, -} from '@core/racing'; +import { LeagueMembershipService } from '@/lib/services/leagues/LeagueMembershipService'; +import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay'; +import { ServiceFactory } from '@/lib/services/ServiceFactory'; +import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; -// Sponsor info type -interface SponsorInfo { - id: string; - name: string; - logoUrl?: string; - websiteUrl?: string; - tier: 'main' | 'secondary'; - tagline?: string; -} - export default function LeagueDetailPage() { const router = useRouter(); const params = useParams(); const leagueId = params.id as string; const isSponsor = useSponsorMode(); - const [league, setLeague] = useState(null); - const [owner, setOwner] = useState(null); - const [drivers, setDrivers] = useState([]); - const [scoringConfig, setScoringConfig] = useState(null); - const [averageSOF, setAverageSOF] = useState(null); - const [completedRacesCount, setCompletedRacesCount] = useState(0); - const [sponsors, setSponsors] = useState([]); - const [runningRaces, setRunningRaces] = useState([]); + const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); - const [refreshKey, setRefreshKey] = useState(0); const [endRaceModalRaceId, setEndRaceModalRaceId] = useState(null); const currentDriverId = useEffectiveDriverId(); - const membership = getMembership(leagueId, currentDriverId); - const leagueMemberships = getLeagueMembers(leagueId); + const membership = LeagueMembershipService.getMembership(leagueId, currentDriverId); + const leagueMemberships = LeagueMembershipService.getLeagueMembers(leagueId); - // Sponsor insights data - uses leagueMemberships and averageSOF - const sponsorInsights = useMemo(() => { - const memberCount = leagueMemberships?.length || 20; - const mainSponsorTaken = sponsors.some(s => s.tier === 'main'); - const secondaryTaken = sponsors.filter(s => s.tier === 'secondary').length; - - return { - avgViewsPerRace: 5400 + memberCount * 50, - totalImpressions: 45000 + memberCount * 500, - engagementRate: (3.5 + (memberCount / 50)).toFixed(1), - estimatedReach: memberCount * 150, - mainSponsorAvailable: !mainSponsorTaken, - secondarySlotsAvailable: Math.max(0, 2 - secondaryTaken), - mainSponsorPrice: 800 + Math.floor(memberCount * 10), - secondaryPrice: 250 + Math.floor(memberCount * 3), - tier: (averageSOF && averageSOF > 3000 ? 'premium' : averageSOF && averageSOF > 2000 ? 'standard' : 'starter') as 'premium' | 'standard' | 'starter', - trustScore: Math.min(100, 60 + memberCount + completedRacesCount), - discordMembers: memberCount * 3, - monthlyActivity: Math.min(100, 40 + completedRacesCount * 2), - }; - }, [averageSOF, leagueMemberships?.length, sponsors, completedRacesCount]); - // Build metrics for SponsorInsightsCard - const leagueMetrics: SponsorMetric[] = useMemo(() => [ - MetricBuilders.views(sponsorInsights.avgViewsPerRace, 'Avg Views/Race'), - MetricBuilders.engagement(sponsorInsights.engagementRate), - MetricBuilders.reach(sponsorInsights.estimatedReach), - MetricBuilders.sof(averageSOF ?? '—'), - ], [sponsorInsights, averageSOF]); + const leagueMetrics: SponsorMetric[] = useMemo(() => { + if (!viewModel) return []; + return [ + MetricBuilders.views(viewModel.sponsorInsights.avgViewsPerRace, 'Avg Views/Race'), + MetricBuilders.engagement(viewModel.sponsorInsights.engagementRate), + MetricBuilders.reach(viewModel.sponsorInsights.estimatedReach), + MetricBuilders.sof(viewModel.averageSOF ?? '—'), + ]; + }, [viewModel]); const loadLeagueData = async () => { try { - const leagueRepo = getLeagueRepository(); - const raceRepo = getRaceRepository(); - const driverRepo = getDriverRepository(); - const leagueStatsUseCase = getGetLeagueStatsUseCase(); - const seasonRepo = getSeasonRepository(); - const sponsorRepo = getSponsorRepository(); - const sponsorshipRepo = getSeasonSponsorshipRepository(); + const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || ''); + const leagueService = serviceFactory.createLeagueService(); - const leagueData = await leagueRepo.findById(leagueId); - - if (!leagueData) { + const viewModelData = await leagueService.getLeagueDetailPageData(leagueId); + + if (!viewModelData) { setError('League not found'); setLoading(false); return; } - setLeague(leagueData); - - // Load owner data - const ownerData = await driverRepo.findById(leagueData.ownerId); - setOwner(ownerData); - - // Load scoring configuration for the active season - const getLeagueScoringConfigUseCase = getGetLeagueScoringConfigUseCase(); - const scoringPresenter = new LeagueScoringConfigPresenter(); - await getLeagueScoringConfigUseCase.execute({ leagueId }, scoringPresenter); - const scoringViewModel = scoringPresenter.getViewModel(); - setScoringConfig(scoringViewModel as unknown as LeagueScoringConfigDTO); - - // Load all drivers for standings and map to DTOs for UI components - const allDrivers = await driverRepo.findAll(); - const driverDtos: DriverDTO[] = allDrivers - .map((driver) => EntityMappers.toDriverDTO(driver)) - .filter((dto): dto is DriverDTO => dto !== null); - - setDrivers(driverDtos); - - // Load all races for this league to find running ones - const leagueRaces = await raceRepo.findByLeagueId(leagueId); - const runningRaces = leagueRaces.filter(r => r.status === 'running'); - setRunningRaces(runningRaces); - - // Load league stats including average SOF from application use case - await leagueStatsUseCase.execute({ leagueId }); - const leagueStatsViewModel = leagueStatsUseCase.presenter.getViewModel(); - if (leagueStatsViewModel) { - setAverageSOF(leagueStatsViewModel.averageSOF); - setCompletedRacesCount(leagueStatsViewModel.completedRaces); - } else { - // Fallback: count completed races manually - const completedRaces = leagueRaces.filter(r => r.status === 'completed'); - setCompletedRacesCount(completedRaces.length); - } - - // Load sponsors for this league - try { - const seasons = await seasonRepo.findByLeagueId(leagueId); - const activeSeason = seasons.find((s: { status: string }) => s.status === 'active') ?? seasons[0]; - - if (activeSeason) { - const sponsorships = await sponsorshipRepo.findBySeasonId(activeSeason.id); - const activeSponsorships = sponsorships.filter((s) => s.status === 'active'); - - const sponsorInfos: SponsorInfo[] = []; - for (const sponsorship of activeSponsorships) { - const sponsor = await sponsorRepo.findById(sponsorship.sponsorId); - if (sponsor) { - const testingSupportModule = await import('@gridpilot/testing-support'); - const demoSponsors = testingSupportModule.sponsors as Array<{ id: string; tagline?: string }>; - const demoSponsor = demoSponsors.find((demo) => demo.id === sponsor.id); - - sponsorInfos.push({ - id: sponsor.id, - name: sponsor.name, - logoUrl: sponsor.logoUrl ?? '', - websiteUrl: sponsor.websiteUrl ?? '', - tier: sponsorship.tier, - tagline: demoSponsor?.tagline ?? '', - }); - } - } - - // Sort: main sponsors first, then secondary - sponsorInfos.sort((a, b) => { - if (a.tier === 'main' && b.tier !== 'main') return -1; - if (a.tier !== 'main' && b.tier === 'main') return 1; - return 0; - }); - - setSponsors(sponsorInfos); - } - } catch (sponsorError) { - console.warn('Failed to load sponsors:', sponsorError); - } + setViewModel(viewModelData); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load league'); } finally { @@ -200,66 +74,14 @@ export default function LeagueDetailPage() { }, [leagueId]); const handleMembershipChange = () => { - setRefreshKey(prev => prev + 1); loadLeagueData(); }; - const driversById = useMemo(() => { - const map: Record = {}; - for (const d of drivers) { - map[d.id] = d; - } - return map; - }, [drivers]); - - const ownerMembership = leagueMemberships.find((m) => m.role === 'owner') ?? null; - const adminMemberships = leagueMemberships.filter((m) => m.role === 'admin'); - const stewardMemberships = leagueMemberships.filter((m) => m.role === 'steward'); - - const buildDriverSummary = (driverId: string) => { - const driverDto = driversById[driverId]; - if (!driverDto) { - return null; - } - - const stats = getDriverStats(driverDto.id); - const allRankings = getAllDriverRankings(); - - let rating: number | null = stats?.rating ?? null; - let rank: number | null = null; - - if (stats) { - if (typeof stats.overallRank === 'number' && stats.overallRank > 0) { - rank = stats.overallRank; - } else { - const indexInGlobal = allRankings.findIndex( - (stat) => stat.driverId === stats.driverId, - ); - if (indexInGlobal !== -1) { - rank = indexInGlobal + 1; - } - } - - if (rating === null) { - const globalEntry = allRankings.find( - (stat) => stat.driverId === stats.driverId, - ); - if (globalEntry) { - rating = globalEntry.rating; - } - } - } - - return { - driver: driverDto, - rating, - rank, - }; - }; + // Note: driver summaries are now handled by the ViewModel return loading ? (
Loading league...
- ) : error || !league ? ( + ) : error || !viewModel ? (
{error || 'League not found'} @@ -274,35 +96,35 @@ export default function LeagueDetailPage() { ) : ( <> {/* Sponsor Insights Card - Only shown to sponsors, at top of page */} - {isSponsor && league && ( + {isSponsor && viewModel && ( )} {/* Live Race Card - Prominently show running races */} - {runningRaces.length > 0 && ( + {viewModel && viewModel.runningRaces.length > 0 && (
@@ -310,7 +132,7 @@ export default function LeagueDetailPage() {
- {runningRaces.map((race) => ( + {viewModel.runningRaces.map((race) => (
LIVE

- {race.track} - {race.car} + {race.name}

@@ -347,9 +169,10 @@ export default function LeagueDetailPage() {
- Started {new Date(race.scheduledAt).toLocaleDateString()} + Started {new Date(race.date).toLocaleDateString()}
- {race.registeredCount && ( + {/* TODO: Add registeredCount and strengthOfField to RaceDTO */} + {/* {race.registeredCount && (
{race.registeredCount} drivers registered @@ -360,7 +183,7 @@ export default function LeagueDetailPage() { SOF: {race.strengthOfField}
- )} + )} */}
))} @@ -405,15 +228,15 @@ export default function LeagueDetailPage() { {/* Stats Grid */}
-
{leagueMemberships.length}
+
{viewModel.memberships.length}
Members
-
{completedRacesCount}
+
{viewModel.completedRacesCount}
Races
-
{averageSOF ?? '—'}
+
{viewModel.averageSOF ?? '—'}
Avg SOF
@@ -422,16 +245,16 @@ export default function LeagueDetailPage() {
Structure - Solo • {league.settings.maxDrivers ?? 32} max + Solo • {viewModel.settings.maxDrivers ?? 32} max
Scoring - {scoringConfig?.scoringPresetName ?? 'Standard'} + {viewModel.scoringConfig?.scoringPresetName ?? 'Standard'}
Created - {new Date(league.createdAt).toLocaleDateString('en-US', { + {new Date(viewModel.createdAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} @@ -439,7 +262,7 @@ export default function LeagueDetailPage() {
- {league.socialLinks && ( + {viewModel.socialLinks && (
{league.socialLinks.discordUrl && ( @@ -478,14 +301,14 @@ export default function LeagueDetailPage() { {/* Sponsors Section - Show sponsor logos */} - {sponsors.length > 0 && ( + {viewModel.sponsors.length > 0 && (

- {sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'} + {viewModel.sponsors.find(s => s.tier === 'main') ? 'Presented by' : 'Sponsors'}

{/* Main Sponsor - Featured prominently */} - {sponsors.filter(s => s.tier === 'main').map(sponsor => ( + {viewModel.sponsors.filter(s => s.tier === 'main').map(sponsor => (
s.tier === 'secondary').length > 0 && ( + {viewModel.sponsors.filter(s => s.tier === 'secondary').length > 0 && (
- {sponsors.filter(s => s.tier === 'secondary').map(sponsor => ( + {viewModel.sponsors.filter(s => s.tier === 'secondary').map(sponsor => (
0 || stewardMemberships.length > 0) && ( + {viewModel && (viewModel.ownerSummary || viewModel.adminSummaries.length > 0 || viewModel.stewardSummaries.length > 0) && (

Management

- {ownerMembership && (() => { - const driverDto = driversById[ownerMembership.driverId]; - const summary = buildDriverSummary(ownerMembership.driverId); - const roleDisplay = getLeagueRoleDisplay('owner'); - const meta = summary && summary.rating !== null + {viewModel.ownerSummary && (() => { + const summary = viewModel.ownerSummary; + const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('owner'); + const meta = summary.rating !== null ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` : null; - return driverDto ? ( + return (
@@ -600,23 +422,21 @@ export default function LeagueDetailPage() { {roleDisplay.text}
- ) : null; + ); })()} - {adminMemberships.slice(0, 3).map((membership) => { - const driverDto = driversById[membership.driverId]; - const summary = buildDriverSummary(membership.driverId); - const roleDisplay = getLeagueRoleDisplay('admin'); - const meta = summary && summary.rating !== null + {viewModel.adminSummaries.map((summary) => { + const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('admin'); + const meta = summary.rating !== null ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` : null; - return driverDto ? ( -
+ return ( +
@@ -625,23 +445,21 @@ export default function LeagueDetailPage() { {roleDisplay.text}
- ) : null; + ); })} - {stewardMemberships.slice(0, 3).map((membership) => { - const driverDto = driversById[membership.driverId]; - const summary = buildDriverSummary(membership.driverId); - const roleDisplay = getLeagueRoleDisplay('steward'); - const meta = summary && summary.rating !== null + {viewModel.stewardSummaries.map((summary) => { + const roleDisplay = LeagueRoleDisplay.getLeagueRoleDisplay('steward'); + const meta = summary.rating !== null ? `Rating ${summary.rating}${summary.rank ? ` • Rank ${summary.rank}` : ''}` : null; - return driverDto ? ( -
+ return ( +
@@ -650,7 +468,7 @@ export default function LeagueDetailPage() { {roleDisplay.text}
- ) : null; + ); })}
@@ -659,16 +477,18 @@ export default function LeagueDetailPage() {
{/* End Race Modal */} - {endRaceModalRaceId && (() => { - const race = runningRaces.find(r => r.id === endRaceModalRaceId); + {endRaceModalRaceId && viewModel && (() => { + const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId); return race ? ( { try { - const completeRace = getCompleteRaceUseCase(); - await completeRace.execute({ raceId: race.id }); + // TODO: Use service to complete race + // const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_URL || ''); + // const raceService = serviceFactory.createRaceService(); + // await raceService.completeRace(race.id); await loadLeagueData(); setEndRaceModalRaceId(null); } catch (err) { diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index 8403a71f6..efb397fba 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -6,13 +6,10 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { isLeagueAdminOrHigherRole } from '@/lib/leagueRoles'; -import { LeagueFullConfigPresenter } from '@/lib/presenters/LeagueFullConfigPresenter'; -import { LeagueScoringPresetsPresenter } from '@/lib/presenters/LeagueScoringPresetsPresenter'; -import type { LeagueConfigFormModel } from '@core/racing/application'; -import type { DriverDTO } from '@core/racing/application/dto/DriverDTO'; -import { EntityMappers } from '@core/racing/application/mappers/EntityMappers'; -import type { LeagueScoringPresetDTO } from '@core/racing/application/ports/LeagueScoringPresetProvider'; -import type { League } from '@core/racing/domain/entities/League'; +import { ServiceFactory } from '@/lib/services/ServiceFactory'; +import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; +import type { DriverDTO } from '@/lib/types/DriverDTO'; +import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; import { AlertTriangle, Settings, UserCog } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; @@ -22,76 +19,35 @@ export default function LeagueSettingsPage() { const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const [league, setLeague] = useState(null); - const [configForm, setConfigForm] = useState(null); - const [presets, setPresets] = useState([]); - const [ownerDriver, setOwnerDriver] = useState(null); + const [settings, setSettings] = useState(null); const [loading, setLoading] = useState(true); const [isAdmin, setIsAdmin] = useState(false); const [showTransferDialog, setShowTransferDialog] = useState(false); const [selectedNewOwner, setSelectedNewOwner] = useState(''); const [transferring, setTransferring] = useState(false); - const [allMembers, setAllMembers] = useState([]); const router = useRouter(); + const serviceFactory = useMemo(() => new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || ''), []); + const leagueMembershipService = useMemo(() => serviceFactory.createLeagueMembershipService(), [serviceFactory]); + const leagueSettingsService = useMemo(() => serviceFactory.createLeagueSettingsService(), [serviceFactory]); + useEffect(() => { async function checkAdmin() { - const membershipRepo = getLeagueMembershipRepository(); - const membership = await membershipRepo.getMembership(leagueId, currentDriverId); + const memberships = await leagueMembershipService.fetchLeagueMemberships(leagueId); + const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); setIsAdmin(membership ? isLeagueAdminOrHigherRole(membership.role) : false); } checkAdmin(); - }, [leagueId, currentDriverId]); + }, [leagueId, currentDriverId, leagueMembershipService]); useEffect(() => { async function loadSettings() { setLoading(true); try { - const leagueRepo = getLeagueRepository(); - const driverRepo = getDriverRepository(); - const useCase = getGetLeagueFullConfigUseCase(); - const presetsUseCase = getListLeagueScoringPresetsUseCase(); - - const leagueData = await leagueRepo.findById(leagueId); - if (!leagueData) { - setLoading(false); - return; + const settingsData = await leagueSettingsService.getLeagueSettings(leagueId); + if (settingsData) { + setSettings(settingsData); } - - setLeague(leagueData); - - const configPresenter = new LeagueFullConfigPresenter(); - await useCase.execute({ leagueId }, configPresenter); - const configViewModel = configPresenter.getViewModel(); - if (configViewModel) { - setConfigForm(configViewModel as LeagueConfigFormModel); - } - - const presetsPresenter = new LeagueScoringPresetsPresenter(); - await presetsUseCase.execute(undefined as void, presetsPresenter); - const presetsViewModel = presetsPresenter.getViewModel(); - setPresets(presetsViewModel.presets); - - const entity = await driverRepo.findById(leagueData.ownerId); - if (entity) { - setOwnerDriver(EntityMappers.toDriverDTO(entity)); - } - - const membershipRepo = getLeagueMembershipRepository(); - const memberships = await membershipRepo.getLeagueMembers(leagueId); - const memberDrivers: DriverDTO[] = []; - for (const m of memberships) { - if (m.driverId !== leagueData.ownerId && m.status === 'active') { - const d = await driverRepo.findById(m.driverId); - if (d) { - const dto = EntityMappers.toDriverDTO(d); - if (dto) { - memberDrivers.push(dto); - } - } - } - } - setAllMembers(memberDrivers); } catch (err) { console.error('Failed to load league settings:', err); } finally { @@ -102,60 +58,17 @@ export default function LeagueSettingsPage() { if (isAdmin) { loadSettings(); } - }, [leagueId, isAdmin]); + }, [leagueId, isAdmin, leagueSettingsService]); - const ownerSummary = useMemo(() => { - if (!ownerDriver) { - return null; - } - - const stats = getDriverStats(ownerDriver.id); - const allRankings = getAllDriverRankings(); - - let rating: number | null = stats?.rating ?? null; - let rank: number | null = null; - - if (stats) { - if (typeof stats.overallRank === 'number' && stats.overallRank > 0) { - rank = stats.overallRank; - } else { - const indexInGlobal = allRankings.findIndex( - (stat) => stat.driverId === stats.driverId, - ); - if (indexInGlobal !== -1) { - rank = indexInGlobal + 1; - } - } - - if (rating === null) { - const globalEntry = allRankings.find( - (stat) => stat.driverId === stats.driverId, - ); - if (globalEntry) { - rating = globalEntry.rating; - } - } - } - - return { - driver: ownerDriver, - rating, - rank, - }; - }, [ownerDriver]); + const ownerSummary = settings?.owner || null; const handleTransferOwnership = async () => { - if (!selectedNewOwner || !league) return; - + if (!selectedNewOwner || !settings) return; + setTransferring(true); try { - const useCase = getTransferLeagueOwnershipUseCase(); - await useCase.execute({ - leagueId, - currentOwnerId: currentDriverId, - newOwnerId: selectedNewOwner, - }); - + await leagueSettingsService.transferOwnership(leagueId, currentDriverId, selectedNewOwner); + setShowTransferDialog(false); router.refresh(); } catch (err) { @@ -190,7 +103,7 @@ export default function LeagueSettingsPage() { ); } - if (!configForm || !league) { + if (!settings) { return (
@@ -217,7 +130,7 @@ export default function LeagueSettingsPage() { {/* READONLY INFORMATION SECTION - Compact */}
- + {/* League Owner - Compact */}
@@ -234,7 +147,7 @@ export default function LeagueSettingsPage() {
{/* Transfer Ownership - Owner Only */} - {league.ownerId === currentDriverId && allMembers.length > 0 && ( + {settings.league.ownerId === currentDriverId && settings.members.length > 0 && (
@@ -243,7 +156,7 @@ export default function LeagueSettingsPage() {

Transfer league ownership to another active member. You will become an admin.

- + {!showTransferDialog ? ( - {membership && isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( + {LeagueMembershipUtility.isOwnerOrAdmin(viewModel.league?.id || '', currentDriverId) && ( <>
@@ -316,7 +289,7 @@ export default function TeamDetailPage() { )} {activeTab === 'standings' && ( - + )} {activeTab === 'admin' && isAdmin && ( diff --git a/apps/website/lib/api/dashboard/DashboardApiClient.ts b/apps/website/lib/api/dashboard/DashboardApiClient.ts new file mode 100644 index 000000000..3dac992fa --- /dev/null +++ b/apps/website/lib/api/dashboard/DashboardApiClient.ts @@ -0,0 +1,71 @@ +import { BaseApiClient } from '../base/BaseApiClient'; + +// DTOs +export type DriverDto = { + id: string; + name: string; + avatarUrl: string; + country: string; + totalRaces: number; + wins: number; + podiums: number; + rating: number; + globalRank: number; + consistency: number; +}; + +export type RaceDto = { + id: string; + track: string; + car: string; + scheduledAt: string; // ISO date string + isMyLeague: boolean; + leagueName?: string; +}; + +export type LeagueStandingDto = { + leagueId: string; + leagueName: string; + position: number; + points: number; + totalDrivers: number; +}; + +export type FeedItemDto = { + id: string; + type: string; + headline: string; + body: string | null; + timestamp: string; // ISO date string + ctaHref?: string; + ctaLabel?: string; +}; + +export type FriendDto = { + id: string; + name: string; + avatarUrl: string; + country: string; +}; + +export type DashboardOverviewDto = { + currentDriver: DriverDto; + nextRace: RaceDto | null; + upcomingRaces: RaceDto[]; + leagueStandings: LeagueStandingDto[]; + feedItems: FeedItemDto[]; + friends: FriendDto[]; + activeLeaguesCount: number; +}; + +/** + * Dashboard API Client + * + * Handles dashboard overview data aggregation. + */ +export class DashboardApiClient extends BaseApiClient { + /** Get dashboard overview data */ + getDashboardOverview(): Promise { + return this.get('/dashboard/overview'); + } +} \ No newline at end of file diff --git a/apps/website/lib/api/drivers/DriversApiClient.ts b/apps/website/lib/api/drivers/DriversApiClient.ts index 9b76039fc..3399b9c8f 100644 --- a/apps/website/lib/api/drivers/DriversApiClient.ts +++ b/apps/website/lib/api/drivers/DriversApiClient.ts @@ -1,6 +1,6 @@ import { BaseApiClient } from '../base/BaseApiClient'; // Import generated types -import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO } from '../../types/generated'; +import type { CompleteOnboardingInputDTO, CompleteOnboardingOutputDTO, DriverRegistrationStatusDTO, DriverLeaderboardItemDTO, DriverProfileDTO } from '../../types/generated'; // TODO: Create proper DriverDTO in generated types type DriverDTO = { @@ -40,4 +40,14 @@ export class DriversApiClient extends BaseApiClient { getRegistrationStatus(driverId: string, raceId: string): Promise { return this.get(`/drivers/${driverId}/races/${raceId}/registration-status`); } + + /** Get driver by ID */ + getDriver(driverId: string): Promise { + return this.get(`/drivers/${driverId}`); + } + + /** Get driver profile with full details */ + getDriverProfile(driverId: string): Promise { + return this.get(`/drivers/${driverId}/profile`); + } } \ No newline at end of file diff --git a/apps/website/lib/api/index.ts b/apps/website/lib/api/index.ts index 8e531d31e..9fb450640 100644 --- a/apps/website/lib/api/index.ts +++ b/apps/website/lib/api/index.ts @@ -7,6 +7,7 @@ import { MediaApiClient } from './media/MediaApiClient'; import { AnalyticsApiClient } from './analytics/AnalyticsApiClient'; import { AuthApiClient } from './auth/AuthApiClient'; import { PaymentsApiClient } from './payments/PaymentsApiClient'; +import { DashboardApiClient } from './dashboard/DashboardApiClient'; /** * Main API Client @@ -23,6 +24,7 @@ export class ApiClient { public readonly analytics: AnalyticsApiClient; public readonly auth: AuthApiClient; public readonly payments: PaymentsApiClient; + public readonly dashboard: DashboardApiClient; constructor(baseUrl: string) { this.leagues = new LeaguesApiClient(baseUrl); @@ -34,6 +36,7 @@ export class ApiClient { this.analytics = new AnalyticsApiClient(baseUrl); this.auth = new AuthApiClient(baseUrl); this.payments = new PaymentsApiClient(baseUrl); + this.dashboard = new DashboardApiClient(baseUrl); } } diff --git a/apps/website/lib/api/leagues/LeaguesApiClient.ts b/apps/website/lib/api/leagues/LeaguesApiClient.ts index c5346e1bf..c82005807 100644 --- a/apps/website/lib/api/leagues/LeaguesApiClient.ts +++ b/apps/website/lib/api/leagues/LeaguesApiClient.ts @@ -49,4 +49,32 @@ export class LeaguesApiClient extends BaseApiClient { removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise<{ success: boolean }> { return this.patch<{ success: boolean }>(`/leagues/${leagueId}/members/${targetDriverId}/remove`, { performerDriverId }); } + + /** Get league seasons */ + getSeasons(leagueId: string): Promise<{ seasons: Array<{ id: string; status: string }> }> { + return this.get<{ seasons: Array<{ id: string; status: string }> }>(`/leagues/${leagueId}/seasons`); + } + + /** Get season sponsorships */ + getSeasonSponsorships(seasonId: string): Promise<{ sponsorships: Array<{ sponsorId: string; tier: string; status: string }> }> { + return this.get<{ sponsorships: Array<{ sponsorId: string; tier: string; status: string }> }>(`/seasons/${seasonId}/sponsorships`); + } + + /** Get league config */ + getLeagueConfig(leagueId: string): Promise<{ config: any }> { + return this.get<{ config: any }>(`/leagues/${leagueId}/config`); + } + + /** Get league scoring presets */ + getScoringPresets(): Promise<{ presets: any[] }> { + return this.get<{ presets: any[] }>(`/leagues/scoring-presets`); + } + + /** Transfer league ownership */ + transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise<{ success: boolean }> { + return this.post<{ success: boolean }>(`/leagues/${leagueId}/transfer-ownership`, { + currentOwnerId, + newOwnerId, + }); + } } \ No newline at end of file diff --git a/apps/website/lib/api/media/MediaApiClient.ts b/apps/website/lib/api/media/MediaApiClient.ts index d96665667..cc1b51fd9 100644 --- a/apps/website/lib/api/media/MediaApiClient.ts +++ b/apps/website/lib/api/media/MediaApiClient.ts @@ -1,15 +1,15 @@ -import { BaseApiClient } from '../base/BaseApiClient'; import type { - RequestAvatarGenerationInputDto, - RequestAvatarGenerationOutputDto, - UploadMediaInputDto, - UploadMediaOutputDto, - GetMediaOutputDto, - DeleteMediaOutputDto, - GetAvatarOutputDto, - UpdateAvatarInputDto, - UpdateAvatarOutputDto, + DeleteMediaOutputDto, + GetMediaOutputDto, + RequestAvatarGenerationInputDto, + RequestAvatarGenerationOutputDto, + UpdateAvatarInputDto, + UpdateAvatarOutputDto, + UploadMediaInputDto, + UploadMediaOutputDto, } from '../../dtos'; +import type { GetAvatarOutputDto } from '../../types/GetAvatarOutputDto'; +import { BaseApiClient } from '../base/BaseApiClient'; /** * Media API Client diff --git a/apps/website/lib/api/protests/ProtestsApiClient.ts b/apps/website/lib/api/protests/ProtestsApiClient.ts new file mode 100644 index 000000000..c9da9836f --- /dev/null +++ b/apps/website/lib/api/protests/ProtestsApiClient.ts @@ -0,0 +1,33 @@ +import { BaseApiClient } from '../base/BaseApiClient'; +import type { + LeagueAdminProtestsDTO, + ApplyPenaltyCommandDTO, + RequestProtestDefenseCommandDTO, +} from '../../types'; + +/** + * Protests API Client + * + * Handles all protest-related API operations. + */ +export class ProtestsApiClient extends BaseApiClient { + /** Get protests for a league */ + getLeagueProtests(leagueId: string): Promise { + return this.get(`/leagues/${leagueId}/protests`); + } + + /** Get a specific protest for a league */ + getLeagueProtest(leagueId: string, protestId: string): Promise { + return this.get(`/leagues/${leagueId}/protests/${protestId}`); + } + + /** Apply a penalty */ + applyPenalty(input: ApplyPenaltyCommandDTO): Promise { + return this.post('/races/penalties/apply', input); + } + + /** Request protest defense */ + requestDefense(input: RequestProtestDefenseCommandDTO): Promise { + return this.post('/races/protests/defense/request', input); + } +} \ No newline at end of file diff --git a/apps/website/lib/api/sponsors/SponsorsApiClient.ts b/apps/website/lib/api/sponsors/SponsorsApiClient.ts index dbca575d7..a8de775f0 100644 --- a/apps/website/lib/api/sponsors/SponsorsApiClient.ts +++ b/apps/website/lib/api/sponsors/SponsorsApiClient.ts @@ -39,4 +39,9 @@ export class SponsorsApiClient extends BaseApiClient { getSponsorships(sponsorId: string): Promise { return this.get(`/sponsors/${sponsorId}/sponsorships`); } + + /** Get sponsor by ID */ + getSponsor(sponsorId: string): Promise { + return this.get(`/sponsors/${sponsorId}`); + } } \ No newline at end of file diff --git a/apps/website/lib/api/teams/TeamsApiClient.ts b/apps/website/lib/api/teams/TeamsApiClient.ts index f8cdda5e6..45138093a 100644 --- a/apps/website/lib/api/teams/TeamsApiClient.ts +++ b/apps/website/lib/api/teams/TeamsApiClient.ts @@ -1,15 +1,16 @@ -import { BaseApiClient } from '../base/BaseApiClient'; +import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; import type { AllTeamsDto, - TeamDetailsDto, - TeamMembersDto, - TeamJoinRequestsDto, CreateTeamInputDto, CreateTeamOutputDto, + DriverTeamDto, + TeamDetailsDto, + TeamJoinRequestsDto, + TeamMembersDto, UpdateTeamInputDto, UpdateTeamOutputDto, - DriverTeamDto, } from '../../dtos'; +import { BaseApiClient } from '../base/BaseApiClient'; /** * Teams API Client @@ -51,4 +52,9 @@ export class TeamsApiClient extends BaseApiClient { getDriverTeam(driverId: string): Promise { return this.get(`/teams/driver/${driverId}`); } + + /** Get membership for a driver in a team */ + getMembership(teamId: string, driverId: string): Promise { + return this.get(`/teams/${teamId}/members/${driverId}`); + } } \ No newline at end of file diff --git a/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts b/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts new file mode 100644 index 000000000..a40b0c0d5 --- /dev/null +++ b/apps/website/lib/command-models/protests/ProtestDecisionCommandModel.ts @@ -0,0 +1,58 @@ +import { ApplyPenaltyCommandDTO } from '../../types'; + +export type PenaltyType = 'time_penalty' | 'grid_penalty' | 'points_deduction' | 'disqualification' | 'warning' | 'license_points'; + +export interface ProtestDecisionData { + decision: 'uphold' | 'dismiss' | null; + penaltyType: PenaltyType; + penaltyValue: number; + stewardNotes: string; +} + +export class ProtestDecisionCommandModel { + decision: 'uphold' | 'dismiss' | null = null; + penaltyType: PenaltyType = 'time_penalty'; + penaltyValue: number = 5; + stewardNotes: string = ''; + + constructor(initial: Partial = {}) { + this.decision = initial.decision ?? null; + this.penaltyType = initial.penaltyType ?? 'time_penalty'; + this.penaltyValue = initial.penaltyValue ?? 5; + this.stewardNotes = initial.stewardNotes ?? ''; + } + + get isValid(): boolean { + return this.decision !== null && this.stewardNotes.trim().length > 0; + } + + get canSubmit(): boolean { + return this.isValid; + } + + reset(): void { + this.decision = null; + this.penaltyType = 'time_penalty'; + this.penaltyValue = 5; + this.stewardNotes = ''; + } + + toApplyPenaltyCommand(raceId: string, driverId: string, stewardId: string, protestId: string): ApplyPenaltyCommandDTO { + return { + raceId, + driverId, + stewardId, + type: this.penaltyType, + value: this.getPenaltyValue(), + reason: 'Protest upheld', // TODO: Make this configurable + protestId, + notes: this.stewardNotes, + }; + } + + private getPenaltyValue(): number { + // Some penalties don't require a value + const penaltiesWithoutValue: PenaltyType[] = ['disqualification', 'warning']; + return penaltiesWithoutValue.includes(this.penaltyType) ? 0 : this.penaltyValue; + } +} \ No newline at end of file diff --git a/apps/website/lib/services/ServiceFactory.ts b/apps/website/lib/services/ServiceFactory.ts index 5d45f326d..8199dbe75 100644 --- a/apps/website/lib/services/ServiceFactory.ts +++ b/apps/website/lib/services/ServiceFactory.ts @@ -7,6 +7,8 @@ import { PaymentsApiClient } from '../api/payments/PaymentsApiClient'; import { AuthApiClient } from '../api/auth/AuthApiClient'; import { AnalyticsApiClient } from '../api/analytics/AnalyticsApiClient'; import { MediaApiClient } from '../api/media/MediaApiClient'; +import { DashboardApiClient } from '../api/dashboard/DashboardApiClient'; +import { ProtestsApiClient } from '../api/protests/ProtestsApiClient'; import { ConsoleErrorReporter } from '../infrastructure/logging/ConsoleErrorReporter'; import { ConsoleLogger } from '../infrastructure/logging/ConsoleLogger'; @@ -19,17 +21,20 @@ import { TeamService } from './teams/TeamService'; import { TeamJoinService } from './teams/TeamJoinService'; import { LeagueService } from './leagues/LeagueService'; import { LeagueMembershipService } from './leagues/LeagueMembershipService'; +import { LeagueSettingsService } from './leagues/LeagueSettingsService'; import { SponsorService } from './sponsors/SponsorService'; import { SponsorshipService } from './sponsors/SponsorshipService'; import { PaymentService } from './payments/PaymentService'; import { AnalyticsService } from './analytics/AnalyticsService'; -import { DashboardService } from './analytics/DashboardService'; +import { DashboardService as AnalyticsDashboardService } from './analytics/DashboardService'; +import { DashboardService } from './dashboard/DashboardService'; import { MediaService } from './media/MediaService'; import { AvatarService } from './media/AvatarService'; import { WalletService } from './payments/WalletService'; import { MembershipFeeService } from './payments/MembershipFeeService'; import { AuthService } from './auth/AuthService'; import { SessionService } from './auth/SessionService'; +import { ProtestService } from './protests/ProtestService'; /** * ServiceFactory - Composition root for all services @@ -52,6 +57,8 @@ export class ServiceFactory { auth: AuthApiClient; analytics: AnalyticsApiClient; media: MediaApiClient; + dashboard: DashboardApiClient; + protests: ProtestsApiClient; }; constructor(baseUrl: string) { @@ -66,6 +73,8 @@ export class ServiceFactory { auth: new AuthApiClient(baseUrl, this.errorReporter, this.logger), analytics: new AnalyticsApiClient(baseUrl, this.errorReporter, this.logger), media: new MediaApiClient(baseUrl, this.errorReporter, this.logger), + dashboard: new DashboardApiClient(baseUrl, this.errorReporter, this.logger), + protests: new ProtestsApiClient(baseUrl, this.errorReporter, this.logger), }; } @@ -115,15 +124,22 @@ export class ServiceFactory { * Create LeagueService instance */ createLeagueService(): LeagueService { - return new LeagueService(this.apiClients.leagues); + return new LeagueService(this.apiClients.leagues, this.apiClients.drivers, this.apiClients.sponsors, this.apiClients.races); } /** - * Create LeagueMembershipService instance - */ - createLeagueMembershipService(): LeagueMembershipService { - return new LeagueMembershipService(this.apiClients.leagues); - } + * Create LeagueMembershipService instance + */ + createLeagueMembershipService(): LeagueMembershipService { + return new LeagueMembershipService(this.apiClients.leagues); + } + + /** + * Create LeagueSettingsService instance + */ + createLeagueSettingsService(): LeagueSettingsService { + return new LeagueSettingsService(this.apiClients.leagues, this.apiClients.drivers); + } /** * Create SponsorService instance @@ -154,11 +170,18 @@ export class ServiceFactory { } /** - * Create DashboardService instance - */ - createDashboardService(): DashboardService { - return new DashboardService(this.apiClients.analytics); - } + * Create Analytics DashboardService instance + */ + createAnalyticsDashboardService(): AnalyticsDashboardService { + return new AnalyticsDashboardService(this.apiClients.analytics); + } + + /** + * Create DashboardService instance + */ + createDashboardService(): DashboardService { + return new DashboardService(this.apiClients.dashboard); + } /** * Create MediaService instance @@ -201,4 +224,11 @@ export class ServiceFactory { createSessionService(): SessionService { return new SessionService(this.apiClients.auth); } + + /** + * Create ProtestService instance + */ + createProtestService(): ProtestService { + return new ProtestService(this.apiClients.protests); + } } \ No newline at end of file diff --git a/apps/website/lib/services/dashboard/DashboardService.ts b/apps/website/lib/services/dashboard/DashboardService.ts new file mode 100644 index 000000000..73a83754a --- /dev/null +++ b/apps/website/lib/services/dashboard/DashboardService.ts @@ -0,0 +1,22 @@ +import { DashboardOverviewViewModel } from '../../view-models/DashboardOverviewViewModel'; +import { DashboardApiClient } from '../../api/dashboard/DashboardApiClient'; + +/** + * Dashboard Service + * + * Orchestrates dashboard operations by coordinating API calls and view model creation. + * All dependencies are injected via constructor. + */ +export class DashboardService { + constructor( + private readonly apiClient: DashboardApiClient + ) {} + + /** + * Get dashboard overview data with view model transformation + */ + async getDashboardOverview(): Promise { + const dto = await this.apiClient.getDashboardOverview(); + return new DashboardOverviewViewModel(dto); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index 84e0222cd..6fb684f2f 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -1,8 +1,10 @@ import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; import { CompleteOnboardingInputDTO } from "@/lib/types/generated/CompleteOnboardingInputDTO"; +import { DriverProfileDTO } from "@/lib/types/generated/DriverProfileDTO"; import { CompleteOnboardingViewModel } from "@/lib/view-models/CompleteOnboardingViewModel"; import { DriverLeaderboardViewModel } from "@/lib/view-models/DriverLeaderboardViewModel"; import { DriverViewModel } from "@/lib/view-models/DriverViewModel"; +import { DriverProfileViewModel } from "@/lib/view-models/DriverProfileViewModel"; // TODO: Create proper DriverDTO in generated types type DriverDTO = { @@ -41,8 +43,8 @@ export class DriverService { } /** - * Get current driver with view model transformation - */ + * Get current driver with view model transformation + */ async getCurrentDriver(): Promise { const dto = await this.apiClient.getCurrent(); if (!dto) { @@ -50,4 +52,12 @@ export class DriverService { } return new DriverViewModel(dto); } + + /** + * Get driver profile with full details and view model transformation + */ + async getDriverProfile(driverId: string): Promise { + const dto = await this.apiClient.getDriverProfile(driverId); + return new DriverProfileViewModel(dto); + } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 421cbc0b4..3a73a82ff 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -1,4 +1,7 @@ import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; +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 { LeagueWithCapacityDTO } from "@/lib/types/generated/LeagueWithCapacityDTO"; import { CreateLeagueViewModel } from "@/lib/view-models/CreateLeagueViewModel"; @@ -8,7 +11,14 @@ 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 { LeagueDetailPageViewModel, SponsorInfo } from "@/lib/view-models/LeagueDetailPageViewModel"; import { SubmitBlocker, ThrottleBlocker } from "@/lib/blockers"; +import { DriverDTO } from "@/lib/types/DriverDTO"; +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"; /** @@ -22,7 +32,10 @@ export class LeagueService { private readonly throttle = new ThrottleBlocker(500); constructor( - private readonly apiClient: LeaguesApiClient + private readonly apiClient: LeaguesApiClient, + private readonly driversApiClient: DriversApiClient, + private readonly sponsorsApiClient: SponsorsApiClient, + private readonly racesApiClient: RacesApiClient ) {} /** @@ -93,4 +106,156 @@ export class LeagueService { const dto = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId); return new RemoveMemberViewModel(dto); } + + /** + * Get league detail with owner, membership, and sponsor info + */ + async getLeagueDetail(leagueId: string, currentDriverId: string): Promise { + // 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); + if (!leagueDto) return null; + + // Assume league has description, ownerId - need to update DTO + const league = { + id: leagueDto.id, + name: leagueDto.name, + description: 'Description not available', // TODO: add to API + ownerId: 'owner-id', // TODO: add to API + }; + + // Get owner + const owner = await this.driversApiClient.getDriver(league.ownerId); + const ownerName = owner ? owner.name : `${league.ownerId.slice(0, 8)}...`; + + // Get membership + const membershipsDto = await this.apiClient.getMemberships(leagueId); + const membership = membershipsDto.members.find(m => m.driverId === currentDriverId); + const isAdmin = membership ? ['admin', 'owner'].includes(membership.role) : false; + + // Get main sponsor + let mainSponsor = null; + try { + const seasonsDto = await this.apiClient.getSeasons(leagueId); + const activeSeason = seasonsDto.seasons.find((s: any) => s.status === 'active') ?? seasonsDto.seasons[0]; + if (activeSeason) { + const sponsorshipsDto = await this.apiClient.getSeasonSponsorships(activeSeason.id); + const mainSponsorship = sponsorshipsDto.sponsorships.find((s: any) => s.tier === 'main' && s.status === 'active'); + if (mainSponsorship) { + const sponsor = await this.sponsorsApiClient.getSponsor(mainSponsorship.sponsorId); + if (sponsor) { + mainSponsor = { + name: sponsor.name, + logoUrl: sponsor.logoUrl ?? '', + websiteUrl: sponsor.websiteUrl ?? '', + }; + } + } + } + } catch (error) { + console.warn('Failed to load main sponsor:', error); + } + + return new LeagueDetailViewModel( + league.id, + league.name, + league.description, + league.ownerId, + ownerName, + mainSponsor, + isAdmin + ); + } + + /** + * Get comprehensive league detail page data + */ + async getLeagueDetailPageData(leagueId: string): Promise { + try { + // Get league basic info + const allLeagues = await this.apiClient.getAllWithCapacity(); + const league = allLeagues.leagues.find(l => l.id === leagueId); + if (!league) return null; + + // Get owner + const owner = await this.driversApiClient.getDriver(league.ownerId); + + // Get scoring config - TODO: implement API endpoint + const scoringConfig: LeagueScoringConfigDTO | null = null; // TODO: fetch from API + + // Get all drivers - TODO: implement API endpoint for all drivers + const drivers: DriverDTO[] = []; // TODO: fetch from API + + // Get memberships + const memberships = await this.apiClient.getMemberships(leagueId); + + // Get all races for this league - TODO: implement API endpoint + const allRaces: RaceDTO[] = []; // TODO: fetch from API + + // Get league stats + const leagueStats = await this.apiClient.getTotal(); // TODO: get stats for specific league + + // Get sponsors + const sponsors = await this.getLeagueSponsors(leagueId); + + return new LeagueDetailPageViewModel( + league, + owner, + scoringConfig, + drivers, + memberships, + allRaces, + leagueStats, + sponsors + ); + } catch (error) { + console.error('Failed to load league detail page data:', error); + return null; + } + } + + /** + * Get sponsors for a league + */ + private async getLeagueSponsors(leagueId: string): Promise { + try { + const seasons = await this.apiClient.getSeasons(leagueId); + const activeSeason = seasons.seasons.find((s: any) => s.status === 'active') ?? seasons.seasons[0]; + + if (!activeSeason) return []; + + const sponsorships = await this.apiClient.getSeasonSponsorships(activeSeason.id); + const activeSponsorships = sponsorships.sponsorships.filter((s: any) => s.status === 'active'); + + const sponsorInfos: SponsorInfo[] = []; + for (const sponsorship of activeSponsorships) { + const sponsor = await this.sponsorsApiClient.getSponsor(sponsorship.sponsorId); + if (sponsor) { + // TODO: Get tagline from testing support or API + sponsorInfos.push({ + id: sponsor.id, + name: sponsor.name, + logoUrl: sponsor.logoUrl ?? '', + websiteUrl: sponsor.websiteUrl ?? '', + tier: sponsorship.tier, + tagline: '', // TODO: fetch tagline + }); + } + } + + // Sort: main sponsors first, then secondary + sponsorInfos.sort((a, b) => { + if (a.tier === 'main' && b.tier !== 'main') return -1; + if (a.tier !== 'main' && b.tier === 'main') return 1; + return 0; + }); + + return sponsorInfos; + } catch (error) { + console.warn('Failed to load sponsors:', error); + return []; + } + } } \ No newline at end of file diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.ts b/apps/website/lib/services/leagues/LeagueSettingsService.ts new file mode 100644 index 000000000..f10048796 --- /dev/null +++ b/apps/website/lib/services/leagues/LeagueSettingsService.ts @@ -0,0 +1,95 @@ +import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient"; +import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient"; +import type { LeagueConfigFormModel } from "@/lib/types/LeagueConfigFormModel"; +import type { LeagueScoringPresetDTO } from "@/lib/types/LeagueScoringPresetDTO"; +import type { DriverDTO } from "@/lib/types/DriverDTO"; +import { LeagueSettingsViewModel } from "@/lib/view-models/LeagueSettingsViewModel"; +import { DriverSummaryViewModel } from "@/lib/view-models/DriverSummaryViewModel"; + +/** + * League Settings Service + * + * Orchestrates league settings operations by coordinating API calls and view model creation. + * All dependencies are injected via constructor. + */ +export class LeagueSettingsService { + constructor( + private readonly leaguesApiClient: LeaguesApiClient, + private readonly driversApiClient: DriversApiClient + ) {} + + /** + * Get league settings with view model transformation + */ + async getLeagueSettings(leagueId: string): Promise { + try { + // Get league basic info + const allLeagues = await this.leaguesApiClient.getAllWithCapacity(); + const leagueDto = allLeagues.leagues.find(l => l.id === leagueId); + if (!leagueDto) return null; + + // Assume league has ownerId - need to update API + const league = { + id: leagueDto.id, + name: leagueDto.name, + ownerId: 'owner-id', // TODO: add to API + }; + + // Get config + const configDto = await this.leaguesApiClient.getLeagueConfig(leagueId); + const config: LeagueConfigFormModel = configDto.config; + + // Get presets + const presetsDto = await this.leaguesApiClient.getScoringPresets(); + const presets: LeagueScoringPresetDTO[] = presetsDto.presets; + + // Get owner + const ownerDriver = await this.driversApiClient.getDriver(league.ownerId); + let owner: DriverSummaryViewModel | null = null; + if (ownerDriver) { + // TODO: get rating and rank from API + owner = new DriverSummaryViewModel({ + driver: ownerDriver, + rating: ownerDriver.rating ?? null, + rank: null, // TODO: get from API + }); + } + + // Get members + const membershipsDto = await this.leaguesApiClient.getMemberships(leagueId); + const members: DriverDTO[] = []; + for (const member of membershipsDto.members) { + if (member.driverId !== league.ownerId && member.role !== 'owner') { + const driver = await this.driversApiClient.getDriver(member.driverId); + if (driver) { + members.push(driver); + } + } + } + + return new LeagueSettingsViewModel({ + league, + config, + presets, + owner, + members, + }); + } catch (error) { + console.error('Failed to load league settings:', error); + return null; + } + } + + /** + * Transfer league ownership + */ + async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise { + try { + const result = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId); + return result.success; + } catch (error) { + console.error('Failed to transfer ownership:', error); + throw error; + } + } +} \ No newline at end of file diff --git a/apps/website/lib/services/media/MediaService.ts b/apps/website/lib/services/media/MediaService.ts index 36fc88cf3..103f4257c 100644 --- a/apps/website/lib/services/media/MediaService.ts +++ b/apps/website/lib/services/media/MediaService.ts @@ -34,10 +34,17 @@ export class MediaService { } /** - * Delete media by ID with view model transformation - */ - async deleteMedia(mediaId: string): Promise { - const dto = await this.apiClient.deleteMedia(mediaId); - return new DeleteMediaViewModel(dto); - } -} \ No newline at end of file + * Delete media by ID with view model transformation + */ + async deleteMedia(mediaId: string): Promise { + const dto = await this.apiClient.deleteMedia(mediaId); + return new DeleteMediaViewModel(dto); + } + + /** + * Get team logo URL + */ + getTeamLogo(teamId: string): string { + return `/api/media/teams/${teamId}/logo`; + } + } \ No newline at end of file diff --git a/apps/website/lib/services/protests/ProtestService.ts b/apps/website/lib/services/protests/ProtestService.ts new file mode 100644 index 000000000..53186e3df --- /dev/null +++ b/apps/website/lib/services/protests/ProtestService.ts @@ -0,0 +1,70 @@ +import { ProtestsApiClient } from '../../api/protests/ProtestsApiClient'; +import { ProtestViewModel } from '../../view-models/ProtestViewModel'; +import type { LeagueAdminProtestsDTO, ApplyPenaltyCommandDTO, RequestProtestDefenseCommandDTO, DriverSummaryDTO } from '../../types'; + +/** + * Protest Service + * + * Orchestrates protest operations by coordinating API calls and view model creation. + * All dependencies are injected via constructor. + */ +export class ProtestService { + constructor( + private readonly apiClient: ProtestsApiClient + ) {} + + /** + * Get protests for a league with view model transformation + */ + async getLeagueProtests(leagueId: string): Promise<{ + protests: ProtestViewModel[]; + racesById: LeagueAdminProtestsDTO['racesById']; + driversById: LeagueAdminProtestsDTO['driversById']; + }> { + const dto = await this.apiClient.getLeagueProtests(leagueId); + return { + protests: dto.protests.map(protest => new ProtestViewModel(protest)), + racesById: dto.racesById, + driversById: dto.driversById, + }; + } + + /** + * Get a single protest by ID from league protests + */ + async getProtestById(leagueId: string, protestId: string): Promise<{ + protest: ProtestViewModel; + race: LeagueAdminProtestsDTO['racesById'][string]; + protestingDriver: DriverSummaryDTO; + accusedDriver: DriverSummaryDTO; + } | null> { + const dto = await this.apiClient.getLeagueProtest(leagueId, protestId); + const protest = dto.protests[0]; + if (!protest) return null; + + const race = Object.values(dto.racesById)[0]; + const protestingDriver = dto.driversById[protest.protestingDriverId]; + const accusedDriver = dto.driversById[protest.accusedDriverId]; + + return { + protest: new ProtestViewModel(protest), + race, + protestingDriver, + accusedDriver, + }; + } + + /** + * Apply a penalty + */ + async applyPenalty(input: ApplyPenaltyCommandDTO): Promise { + await this.apiClient.applyPenalty(input); + } + + /** + * Request protest defense + */ + async requestDefense(input: RequestProtestDefenseCommandDTO): Promise { + await this.apiClient.requestDefense(input); + } +} \ No newline at end of file diff --git a/apps/website/lib/services/races/RaceService.ts b/apps/website/lib/services/races/RaceService.ts index 134babfd1..99362e95d 100644 --- a/apps/website/lib/services/races/RaceService.ts +++ b/apps/website/lib/services/races/RaceService.ts @@ -54,6 +54,34 @@ export class RaceService { return new RaceStatsViewModel(dto); } + /** + * Register for a race + */ + async registerForRace(raceId: string, leagueId: string, driverId: string): Promise { + await this.apiClient.register(raceId, { leagueId, driverId }); + } + + /** + * Withdraw from a race + */ + async withdrawFromRace(raceId: string, driverId: string): Promise { + await this.apiClient.withdraw(raceId, { driverId }); + } + + /** + * Cancel a race + */ + async cancelRace(raceId: string): Promise { + await this.apiClient.cancel(raceId); + } + + /** + * Complete a race + */ + async completeRace(raceId: string): Promise { + await this.apiClient.complete(raceId); + } + /** * Transform API races page data to view model format */ diff --git a/apps/website/lib/services/teams/TeamService.ts b/apps/website/lib/services/teams/TeamService.ts index d74c1db39..100fa36cd 100644 --- a/apps/website/lib/services/teams/TeamService.ts +++ b/apps/website/lib/services/teams/TeamService.ts @@ -4,6 +4,7 @@ import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { CreateTeamViewModel } from '@/lib/view-models/CreateTeamViewModel'; import { UpdateTeamViewModel } from '@/lib/view-models/UpdateTeamViewModel'; import { DriverTeamViewModel } from '@/lib/view-models/DriverTeamViewModel'; +import { LeagueMemberDTO } from '@/lib/types/generated/LeagueMemberDTO'; import type { TeamsApiClient } from '../../api/teams/TeamsApiClient'; // TODO: Move these types to apps/website/lib/types/generated when available @@ -71,10 +72,33 @@ export class TeamService { } /** - * Get driver's team with view model transformation - */ - async getDriverTeam(driverId: string): Promise { - const dto = await this.apiClient.getDriverTeam(driverId); - return dto ? new DriverTeamViewModel(dto) : null; - } -} \ No newline at end of file + * Get driver's team with view model transformation + */ + async getDriverTeam(driverId: string): Promise { + const dto = await this.apiClient.getDriverTeam(driverId); + return dto ? new DriverTeamViewModel(dto) : null; + } + + /** + * Get team membership for a driver + */ + async getMembership(teamId: string, driverId: string): Promise { + return this.apiClient.getMembership(teamId, driverId); + } + + /** + * Remove a driver from the team + */ + async removeMembership(teamId: string, driverId: string): Promise { + // TODO: Implement when API endpoint is available + throw new Error('Not implemented: API endpoint for removing team membership'); + } + + /** + * Update team membership role + */ + async updateMembership(teamId: string, driverId: string, role: string): Promise { + // TODO: Implement when API endpoint is available + throw new Error('Not implemented: API endpoint for updating team membership role'); + } + } \ No newline at end of file diff --git a/apps/website/lib/types/DriverDTO.ts b/apps/website/lib/types/DriverDTO.ts new file mode 100644 index 000000000..3dd2c6f92 --- /dev/null +++ b/apps/website/lib/types/DriverDTO.ts @@ -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 DriverDTO { + id: string; + name: string; + avatarUrl?: string; + iracingId?: string; + rating?: number; +} \ No newline at end of file diff --git a/apps/website/lib/types/GetAvatarOutputDto.ts b/apps/website/lib/types/GetAvatarOutputDto.ts new file mode 100644 index 000000000..b1666ab1a --- /dev/null +++ b/apps/website/lib/types/GetAvatarOutputDto.ts @@ -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 GetAvatarOutputDto { + avatarUrl: string; +} \ No newline at end of file diff --git a/apps/website/lib/types/LeagueScoringPresetDTO.ts b/apps/website/lib/types/LeagueScoringPresetDTO.ts new file mode 100644 index 000000000..c0faea850 --- /dev/null +++ b/apps/website/lib/types/LeagueScoringPresetDTO.ts @@ -0,0 +1,15 @@ +export type LeagueScoringPresetPrimaryChampionshipType = + | 'driver' + | 'team' + | 'nations' + | 'trophy'; + +export interface LeagueScoringPresetDTO { + id: string; + name: string; + description: string; + primaryChampionshipType: LeagueScoringPresetPrimaryChampionshipType; + sessionSummary: string; + bonusSummary: string; + dropPolicySummary: string; +} \ No newline at end of file diff --git a/apps/website/lib/types/RaceDetailEntryDTO.ts b/apps/website/lib/types/RaceDetailEntryDTO.ts new file mode 100644 index 000000000..97afdb019 --- /dev/null +++ b/apps/website/lib/types/RaceDetailEntryDTO.ts @@ -0,0 +1,14 @@ +/** + * 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 RaceDetailEntryDTO { + id: string; + name: string; + country: string; + avatarUrl: string; + rating: number | null; + isCurrentUser: boolean; +} diff --git a/apps/website/lib/types/generated/ApplyPenaltyCommandDTO.ts b/apps/website/lib/types/generated/ApplyPenaltyCommandDTO.ts index 0d03cc70f..2fa2f5aae 100644 --- a/apps/website/lib/types/generated/ApplyPenaltyCommandDTO.ts +++ b/apps/website/lib/types/generated/ApplyPenaltyCommandDTO.ts @@ -8,5 +8,9 @@ export interface ApplyPenaltyCommandDTO { raceId: string; driverId: string; stewardId: string; - enum: string; + type: string; + value?: number; + reason: string; + protestId?: string; + notes?: string; } diff --git a/apps/website/lib/types/generated/DriverProfileDTO.ts b/apps/website/lib/types/generated/DriverProfileDTO.ts new file mode 100644 index 000000000..52e23e847 --- /dev/null +++ b/apps/website/lib/types/generated/DriverProfileDTO.ts @@ -0,0 +1,100 @@ +export interface DriverProfileDriverSummaryDTO { + id: string; + name: string; + country: string; + avatarUrl: string; + iracingId: string | null; + joinedAt: string; + rating: number | null; + globalRank: number | null; + consistency: number | null; + bio: string | null; + totalDrivers: number | null; +} + +export interface DriverProfileStatsDTO { + totalRaces: number; + wins: number; + podiums: number; + dnfs: number; + avgFinish: number | null; + bestFinish: number | null; + worstFinish: number | null; + finishRate: number | null; + winRate: number | null; + podiumRate: number | null; + percentile: number | null; + rating: number | null; + consistency: number | null; + overallRank: number | null; +} + +export interface DriverProfileFinishDistributionDTO { + totalRaces: number; + wins: number; + podiums: number; + topTen: number; + dnfs: number; + other: number; +} + +export interface DriverProfileTeamMembershipDTO { + teamId: string; + teamName: string; + teamTag: string | null; + role: string; + joinedAt: string; + isCurrent: boolean; +} + +export interface DriverProfileSocialFriendSummaryDTO { + id: string; + name: string; + country: string; + avatarUrl: string; +} + +export interface DriverProfileSocialSummaryDTO { + friendsCount: number; + friends: DriverProfileSocialFriendSummaryDTO[]; +} + +export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord'; + +export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +export interface DriverProfileAchievementDTO { + id: string; + title: string; + description: string; + icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; + rarity: DriverProfileAchievementRarity; + earnedAt: string; +} + +export interface DriverProfileSocialHandleDTO { + platform: DriverProfileSocialPlatform; + handle: string; + url: string; +} + +export interface DriverProfileExtendedProfileDTO { + socialHandles: DriverProfileSocialHandleDTO[]; + achievements: DriverProfileAchievementDTO[]; + racingStyle: string; + favoriteTrack: string; + favoriteCar: string; + timezone: string; + availableHours: string; + lookingForTeam: boolean; + openToRequests: boolean; +} + +export interface DriverProfileDTO { + currentDriver: DriverProfileDriverSummaryDTO | null; + stats: DriverProfileStatsDTO | null; + finishDistribution: DriverProfileFinishDistributionDTO | null; + teamMemberships: DriverProfileTeamMembershipDTO[]; + socialSummary: DriverProfileSocialSummaryDTO; + extendedProfile: DriverProfileExtendedProfileDTO | null; +} \ No newline at end of file diff --git a/apps/website/lib/types/generated/LeagueAdminProtestsDTO.ts b/apps/website/lib/types/generated/LeagueAdminProtestsDTO.ts new file mode 100644 index 000000000..2fb5c65ed --- /dev/null +++ b/apps/website/lib/types/generated/LeagueAdminProtestsDTO.ts @@ -0,0 +1,19 @@ +/** + * 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 + */ + +import { ProtestDTO } from './ProtestDTO'; +import { RaceDTO } from './RaceDTO'; + +export interface DriverSummaryDTO { + id: string; + name: string; +} + +export interface LeagueAdminProtestsDTO { + protests: ProtestDTO[]; + racesById: { [raceId: string]: RaceDTO }; + driversById: { [driverId: string]: DriverSummaryDTO }; +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DashboardOverviewViewModel.ts b/apps/website/lib/view-models/DashboardOverviewViewModel.ts new file mode 100644 index 000000000..8b2899a6a --- /dev/null +++ b/apps/website/lib/view-models/DashboardOverviewViewModel.ts @@ -0,0 +1,181 @@ +import { DashboardOverviewDto, DriverDto, RaceDto, LeagueStandingDto, FeedItemDto, FriendDto } from '../api/dashboard/DashboardApiClient'; + +export class DriverViewModel { + constructor(private readonly dto: DriverDto) {} + + get id(): string { + return this.dto.id; + } + + get name(): string { + return this.dto.name; + } + + get avatarUrl(): string { + return this.dto.avatarUrl; + } + + get country(): string { + return this.dto.country; + } + + get totalRaces(): number { + return this.dto.totalRaces; + } + + get wins(): number { + return this.dto.wins; + } + + get podiums(): number { + return this.dto.podiums; + } + + get rating(): number { + return this.dto.rating; + } + + get globalRank(): number { + return this.dto.globalRank; + } + + get consistency(): number { + return this.dto.consistency; + } +} + +export class RaceViewModel { + constructor(private readonly dto: RaceDto) {} + + get id(): string { + return this.dto.id; + } + + get track(): string { + return this.dto.track; + } + + get car(): string { + return this.dto.car; + } + + get scheduledAt(): Date { + return new Date(this.dto.scheduledAt); + } + + get isMyLeague(): boolean { + return this.dto.isMyLeague; + } + + get leagueName(): string | undefined { + return this.dto.leagueName; + } +} + +export class LeagueStandingViewModel { + constructor(private readonly dto: LeagueStandingDto) {} + + get leagueId(): string { + return this.dto.leagueId; + } + + get leagueName(): string { + return this.dto.leagueName; + } + + get position(): number { + return this.dto.position; + } + + get points(): number { + return this.dto.points; + } + + get totalDrivers(): number { + return this.dto.totalDrivers; + } +} + +export class DashboardFeedItemSummaryViewModel { + constructor(private readonly dto: FeedItemDto) {} + + get id(): string { + return this.dto.id; + } + + get type(): string { + return this.dto.type; + } + + get headline(): string { + return this.dto.headline; + } + + get body(): string | null { + return this.dto.body; + } + + get timestamp(): Date { + return new Date(this.dto.timestamp); + } + + get ctaHref(): string | undefined { + return this.dto.ctaHref; + } + + get ctaLabel(): string | undefined { + return this.dto.ctaLabel; + } +} + +export class FriendViewModel { + constructor(private readonly dto: FriendDto) {} + + get id(): string { + return this.dto.id; + } + + get name(): string { + return this.dto.name; + } + + get avatarUrl(): string { + return this.dto.avatarUrl; + } + + get country(): string { + return this.dto.country; + } +} + +export class DashboardOverviewViewModel { + constructor(private readonly dto: DashboardOverviewDto) {} + + get currentDriver(): DriverViewModel { + return new DriverViewModel(this.dto.currentDriver); + } + + get nextRace(): RaceViewModel | null { + return this.dto.nextRace ? new RaceViewModel(this.dto.nextRace) : null; + } + + get upcomingRaces(): RaceViewModel[] { + return this.dto.upcomingRaces.map(dto => new RaceViewModel(dto)); + } + + get leagueStandings(): LeagueStandingViewModel[] { + return this.dto.leagueStandings.map(dto => new LeagueStandingViewModel(dto)); + } + + get feedItems(): DashboardFeedItemSummaryViewModel[] { + return this.dto.feedItems.map(dto => new DashboardFeedItemSummaryViewModel(dto)); + } + + get friends(): FriendViewModel[] { + return this.dto.friends.map(dto => new FriendViewModel(dto)); + } + + get activeLeaguesCount(): number { + return this.dto.activeLeaguesCount; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverProfileViewModel.ts b/apps/website/lib/view-models/DriverProfileViewModel.ts new file mode 100644 index 000000000..806919ec5 --- /dev/null +++ b/apps/website/lib/view-models/DriverProfileViewModel.ts @@ -0,0 +1,141 @@ +export interface DriverProfileDriverSummaryViewModel { + id: string; + name: string; + country: string; + avatarUrl: string; + iracingId: string | null; + joinedAt: string; + rating: number | null; + globalRank: number | null; + consistency: number | null; + bio: string | null; + totalDrivers: number | null; +} + +export interface DriverProfileStatsViewModel { + totalRaces: number; + wins: number; + podiums: number; + dnfs: number; + avgFinish: number | null; + bestFinish: number | null; + worstFinish: number | null; + finishRate: number | null; + winRate: number | null; + podiumRate: number | null; + percentile: number | null; + rating: number | null; + consistency: number | null; + overallRank: number | null; +} + +export interface DriverProfileFinishDistributionViewModel { + totalRaces: number; + wins: number; + podiums: number; + topTen: number; + dnfs: number; + other: number; +} + +export interface DriverProfileTeamMembershipViewModel { + teamId: string; + teamName: string; + teamTag: string | null; + role: string; + joinedAt: string; + isCurrent: boolean; +} + +export interface DriverProfileSocialFriendSummaryViewModel { + id: string; + name: string; + country: string; + avatarUrl: string; +} + +export interface DriverProfileSocialSummaryViewModel { + friendsCount: number; + friends: DriverProfileSocialFriendSummaryViewModel[]; +} + +export type DriverProfileSocialPlatform = 'twitter' | 'youtube' | 'twitch' | 'discord'; + +export type DriverProfileAchievementRarity = 'common' | 'rare' | 'epic' | 'legendary'; + +export interface DriverProfileAchievementViewModel { + id: string; + title: string; + description: string; + icon: 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; + rarity: DriverProfileAchievementRarity; + earnedAt: string; +} + +export interface DriverProfileSocialHandleViewModel { + platform: DriverProfileSocialPlatform; + handle: string; + url: string; +} + +export interface DriverProfileExtendedProfileViewModel { + socialHandles: DriverProfileSocialHandleViewModel[]; + achievements: DriverProfileAchievementViewModel[]; + racingStyle: string; + favoriteTrack: string; + favoriteCar: string; + timezone: string; + availableHours: string; + lookingForTeam: boolean; + openToRequests: boolean; +} + +export interface DriverProfileViewModel { + currentDriver: DriverProfileDriverSummaryViewModel | null; + stats: DriverProfileStatsViewModel | null; + finishDistribution: DriverProfileFinishDistributionViewModel | null; + teamMemberships: DriverProfileTeamMembershipViewModel[]; + socialSummary: DriverProfileSocialSummaryViewModel; + extendedProfile: DriverProfileExtendedProfileViewModel | null; +} + +/** + * Driver Profile View Model + * + * Represents a fully prepared UI state for driver profile display. + * Transforms API DTOs into UI-ready data structures. + */ +export class DriverProfileViewModel { + constructor(private readonly dto: DriverProfileViewModel) {} + + get currentDriver(): DriverProfileDriverSummaryViewModel | null { + return this.dto.currentDriver; + } + + get stats(): DriverProfileStatsViewModel | null { + return this.dto.stats; + } + + get finishDistribution(): DriverProfileFinishDistributionViewModel | null { + return this.dto.finishDistribution; + } + + get teamMemberships(): DriverProfileTeamMembershipViewModel[] { + return this.dto.teamMemberships; + } + + get socialSummary(): DriverProfileSocialSummaryViewModel { + return this.dto.socialSummary; + } + + get extendedProfile(): DriverProfileExtendedProfileViewModel | null { + return this.dto.extendedProfile; + } + + /** + * Get the raw DTO for serialization or further processing + */ + toDTO(): DriverProfileViewModel { + return this.dto; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/DriverSummaryViewModel.ts b/apps/website/lib/view-models/DriverSummaryViewModel.ts new file mode 100644 index 000000000..c95902817 --- /dev/null +++ b/apps/website/lib/view-models/DriverSummaryViewModel.ts @@ -0,0 +1,21 @@ +import type { DriverDTO } from '../types/DriverDTO'; + +/** + * View Model for driver summary with rating and rank + * Transform from DTO to ViewModel with UI fields + */ +export class DriverSummaryViewModel { + driver: DriverDTO; + rating: number | null; + rank: number | null; + + constructor(dto: { + driver: DriverDTO; + rating?: number | null; + rank?: number | null; + }) { + this.driver = dto.driver; + this.rating = dto.rating ?? null; + this.rank = dto.rank ?? null; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueDetailPageViewModel.ts b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts new file mode 100644 index 000000000..aacb329a2 --- /dev/null +++ b/apps/website/lib/view-models/LeagueDetailPageViewModel.ts @@ -0,0 +1,192 @@ +import { LeagueWithCapacityDTO } from '../types/generated/LeagueWithCapacityDTO'; +import { LeagueStatsDTO } from '../types/generated/LeagueStatsDTO'; +import { LeagueMembershipsDTO } from '../types/generated/LeagueMembershipsDTO'; +import { LeagueScheduleDTO } from '../types/generated/LeagueScheduleDTO'; +import { LeagueStandingsDTO } from '../types/generated/LeagueStandingsDTO'; +import { DriverDTO } from '../types/DriverDTO'; +import { RaceDTO } from '../types/generated/RaceDTO'; +import { LeagueScoringConfigDTO } from '../types/LeagueScoringConfigDTO'; + +// Sponsor info type +export interface SponsorInfo { + id: string; + name: string; + logoUrl?: string; + websiteUrl?: string; + tier: 'main' | 'secondary'; + tagline?: string; +} + +// Driver summary for management section +export interface DriverSummary { + driver: DriverDTO; + rating: number | null; + rank: number | null; +} + +// League membership with role +export interface LeagueMembershipWithRole { + driverId: string; + role: 'owner' | 'admin' | 'steward' | 'member'; + status: 'active' | 'inactive'; + joinedAt: string; +} + +export class LeagueDetailPageViewModel { + // League basic info + id: string; + name: string; + description?: string; + ownerId: string; + createdAt: string; + settings: { + maxDrivers?: number; + }; + socialLinks?: { + discordUrl?: string; + youtubeUrl?: string; + websiteUrl?: string; + }; + + // Owner info + owner: DriverDTO | null; + + // Scoring configuration + scoringConfig: LeagueScoringConfigDTO | null; + + // Drivers and memberships + drivers: DriverDTO[]; + memberships: LeagueMembershipWithRole[]; + + // Races + allRaces: RaceDTO[]; + runningRaces: RaceDTO[]; + + // Stats + averageSOF: number | null; + completedRacesCount: number; + + // Sponsors + sponsors: SponsorInfo[]; + + // Sponsor insights data + sponsorInsights: { + avgViewsPerRace: number; + totalImpressions: number; + engagementRate: string; + estimatedReach: number; + mainSponsorAvailable: boolean; + secondarySlotsAvailable: number; + mainSponsorPrice: number; + secondaryPrice: number; + tier: 'premium' | 'standard' | 'starter'; + trustScore: number; + discordMembers: number; + monthlyActivity: number; + }; + + // Driver summaries for management + ownerSummary: DriverSummary | null; + adminSummaries: DriverSummary[]; + stewardSummaries: DriverSummary[]; + + constructor( + league: LeagueWithCapacityDTO, + owner: DriverDTO | null, + scoringConfig: LeagueScoringConfigDTO | null, + drivers: DriverDTO[], + memberships: LeagueMembershipsDTO, + allRaces: RaceDTO[], + leagueStats: LeagueStatsDTO, + sponsors: SponsorInfo[] + ) { + this.id = league.id; + this.name = league.name; + this.description = league.description; + this.ownerId = league.ownerId; + this.createdAt = league.createdAt; + this.settings = { + maxDrivers: league.maxDrivers, + }; + this.socialLinks = league.socialLinks; + + this.owner = owner; + this.scoringConfig = scoringConfig; + this.drivers = drivers; + this.memberships = memberships.memberships.map(m => ({ + driverId: m.driverId, + role: m.role, + status: m.status, + joinedAt: m.joinedAt, + })); + + this.allRaces = allRaces; + this.runningRaces = allRaces.filter(r => r.status === 'running'); + + this.averageSOF = leagueStats.averageSOF ?? null; + this.completedRacesCount = leagueStats.completedRaces ?? 0; + + this.sponsors = sponsors; + + // Calculate sponsor insights + const memberCount = this.memberships.length; + const mainSponsorTaken = this.sponsors.some(s => s.tier === 'main'); + const secondaryTaken = this.sponsors.filter(s => s.tier === 'secondary').length; + + this.sponsorInsights = { + avgViewsPerRace: 5400 + memberCount * 50, + totalImpressions: 45000 + memberCount * 500, + engagementRate: (3.5 + (memberCount / 50)).toFixed(1), + estimatedReach: memberCount * 150, + mainSponsorAvailable: !mainSponsorTaken, + secondarySlotsAvailable: Math.max(0, 2 - secondaryTaken), + mainSponsorPrice: 800 + Math.floor(memberCount * 10), + secondaryPrice: 250 + Math.floor(memberCount * 3), + tier: (this.averageSOF && this.averageSOF > 3000 ? 'premium' : this.averageSOF && this.averageSOF > 2000 ? 'standard' : 'starter') as 'premium' | 'standard' | 'starter', + trustScore: Math.min(100, 60 + memberCount + this.completedRacesCount), + discordMembers: memberCount * 3, + monthlyActivity: Math.min(100, 40 + this.completedRacesCount * 2), + }; + + // Build driver summaries + this.ownerSummary = this.buildDriverSummary(this.ownerId); + this.adminSummaries = this.memberships + .filter(m => m.role === 'admin') + .slice(0, 3) + .map(m => this.buildDriverSummary(m.driverId)) + .filter((s): s is DriverSummary => s !== null); + this.stewardSummaries = this.memberships + .filter(m => m.role === 'steward') + .slice(0, 3) + .map(m => this.buildDriverSummary(m.driverId)) + .filter((s): s is DriverSummary => s !== null); + } + + private buildDriverSummary(driverId: string): DriverSummary | null { + const driver = this.drivers.find(d => d.id === driverId); + if (!driver) return null; + + // TODO: Get driver stats and rankings from service + // For now, return basic info + return { + driver, + rating: null, // TODO: fetch from service + rank: null, // TODO: fetch from service + }; + } + + // UI helper methods + get isSponsorMode(): boolean { + // TODO: implement sponsor mode check + return false; + } + + get currentUserMembership(): LeagueMembershipWithRole | null { + // TODO: get current user ID and find membership + return null; + } + + get canEndRaces(): boolean { + return this.currentUserMembership?.role === 'admin' || this.currentUserMembership?.role === 'owner'; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueDetailViewModel.ts b/apps/website/lib/view-models/LeagueDetailViewModel.ts new file mode 100644 index 000000000..4c8c235f1 --- /dev/null +++ b/apps/website/lib/view-models/LeagueDetailViewModel.ts @@ -0,0 +1,35 @@ +export interface MainSponsorInfo { + name: string; + logoUrl: string; + websiteUrl: string; +} + +export class LeagueDetailViewModel { + id: string; + name: string; + description: string; + ownerId: string; + ownerName: string; + mainSponsor: MainSponsorInfo | null; + isAdmin: boolean; + + constructor( + id: string, + name: string, + description: string, + ownerId: string, + ownerName: string, + mainSponsor: MainSponsorInfo | null, + isAdmin: boolean + ) { + this.id = id; + this.name = name; + this.description = description; + this.ownerId = ownerId; + this.ownerName = ownerName; + this.mainSponsor = mainSponsor; + this.isAdmin = isAdmin; + } + + // UI-specific getters can be added here if needed +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueScoringPresetsViewModel.ts b/apps/website/lib/view-models/LeagueScoringPresetsViewModel.ts new file mode 100644 index 000000000..6bcd72047 --- /dev/null +++ b/apps/website/lib/view-models/LeagueScoringPresetsViewModel.ts @@ -0,0 +1,18 @@ +import type { LeagueScoringPresetDTO } from '../types/LeagueScoringPresetDTO'; + +/** + * View Model for league scoring presets + * Transform from DTO to ViewModel with UI fields + */ +export class LeagueScoringPresetsViewModel { + presets: LeagueScoringPresetDTO[]; + totalCount: number; + + constructor(dto: { + presets: LeagueScoringPresetDTO[]; + totalCount?: number; + }) { + this.presets = dto.presets; + this.totalCount = dto.totalCount ?? dto.presets.length; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/LeagueSettingsViewModel.ts b/apps/website/lib/view-models/LeagueSettingsViewModel.ts new file mode 100644 index 000000000..688ed28fe --- /dev/null +++ b/apps/website/lib/view-models/LeagueSettingsViewModel.ts @@ -0,0 +1,39 @@ +import type { LeagueConfigFormModel } from '../types/LeagueConfigFormModel'; +import type { LeagueScoringPresetDTO } from '../types/LeagueScoringPresetDTO'; +import type { DriverDTO } from '../types/DriverDTO'; +import { LeagueScoringPresetsViewModel } from './LeagueScoringPresetsViewModel'; +import { DriverSummaryViewModel } from './DriverSummaryViewModel'; + +/** + * View Model for league settings page + * Combines league config, presets, owner, and members + */ +export class LeagueSettingsViewModel { + league: { + id: string; + name: string; + ownerId: string; + }; + config: LeagueConfigFormModel; + presets: LeagueScoringPresetDTO[]; + owner: DriverSummaryViewModel | null; + members: DriverDTO[]; + + constructor(dto: { + league: { + id: string; + name: string; + ownerId: string; + }; + config: LeagueConfigFormModel; + presets: LeagueScoringPresetDTO[]; + owner: DriverSummaryViewModel | null; + members: DriverDTO[]; + }) { + this.league = dto.league; + this.config = dto.config; + this.presets = dto.presets; + this.owner = dto.owner; + this.members = dto.members; + } +} \ No newline at end of file diff --git a/apps/website/lib/view-models/ProtestViewModel.ts b/apps/website/lib/view-models/ProtestViewModel.ts index 1f3f110d0..0ff9ec499 100644 --- a/apps/website/lib/view-models/ProtestViewModel.ts +++ b/apps/website/lib/view-models/ProtestViewModel.ts @@ -7,29 +7,27 @@ import { ProtestDTO } from '../types/generated/ProtestDTO'; export class ProtestViewModel { id: string; raceId: string; - complainantId: string; - defendantId: string; + protestingDriverId: string; + accusedDriverId: string; description: string; - status: string; - createdAt: string; + submittedAt: string; constructor(dto: ProtestDTO) { this.id = dto.id; this.raceId = dto.raceId; - this.complainantId = dto.complainantId; - this.defendantId = dto.defendantId; + this.protestingDriverId = dto.protestingDriverId; + this.accusedDriverId = dto.accusedDriverId; this.description = dto.description; - this.status = dto.status; - this.createdAt = dto.createdAt; + this.submittedAt = dto.submittedAt; } - /** UI-specific: Formatted created date */ - get formattedCreatedAt(): string { - return new Date(this.createdAt).toLocaleString(); + /** UI-specific: Formatted submitted date */ + get formattedSubmittedAt(): string { + return new Date(this.submittedAt).toLocaleString(); } - /** UI-specific: Status display */ + /** UI-specific: Status display - placeholder since status not in current DTO */ get statusDisplay(): string { - return this.status.charAt(0).toUpperCase() + this.status.slice(1); + return 'Pending'; // TODO: Update when status is added to DTO } } \ No newline at end of file diff --git a/apps/website/lib/view-models/RaceDetailViewModel.test.ts b/apps/website/lib/view-models/RaceDetailViewModel.test.ts index 761f9ef5f..fabfe0e19 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.test.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.test.ts @@ -1,10 +1,10 @@ -import { describe, it, expect } from 'vitest'; -import { RaceDetailViewModel } from './RaceDetailViewModel'; -import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; +import { describe, expect, it } from 'vitest'; import type { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO'; -import type { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO'; +import type { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; import type { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO'; import type { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; +import type { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO'; +import { RaceDetailViewModel } from './RaceDetailViewModel'; describe('RaceDetailViewModel', () => { const createMockRace = (overrides?: Partial): RaceDetailRaceDTO => ({ diff --git a/apps/website/lib/view-models/RaceDetailViewModel.ts b/apps/website/lib/view-models/RaceDetailViewModel.ts index 82570cfe9..0e925f171 100644 --- a/apps/website/lib/view-models/RaceDetailViewModel.ts +++ b/apps/website/lib/view-models/RaceDetailViewModel.ts @@ -1,8 +1,8 @@ -import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; import { RaceDetailLeagueDTO } from '../types/generated/RaceDetailLeagueDTO'; -import { RaceDetailEntryDTO } from '../types/generated/RaceDetailEntryDTO'; +import { RaceDetailRaceDTO } from '../types/generated/RaceDetailRaceDTO'; import { RaceDetailRegistrationDTO } from '../types/generated/RaceDetailRegistrationDTO'; import { RaceDetailUserResultDTO } from '../types/generated/RaceDetailUserResultDTO'; +import { RaceDetailEntryDTO } from '../types/RaceDetailEntryDTO'; export class RaceDetailViewModel { race: RaceDetailRaceDTO | null;