From c06f93f1b6287a8b7da981ec5b1a40466edf8b5e Mon Sep 17 00:00:00 2001 From: Marc Mintel Date: Wed, 21 Jan 2026 01:56:07 +0100 Subject: [PATCH] website refactor --- apps/website/app/leagues/[id]/layout.tsx | 29 ++- apps/website/app/leagues/[id]/page.tsx | 11 +- apps/website/app/leagues/[id]/roster/page.tsx | 67 ++++++ .../components/leagues/LeagueHeader.tsx | 61 ------ .../components/leagues/LeagueHeaderPanel.tsx | 83 -------- .../components/leagues/LeagueLogoWrapper.tsx | 16 -- .../components/leagues/LeagueNavTabs.tsx | 57 ----- .../leagues/LeagueSchedulePanel.tsx | 7 + .../leagues/LeagueStandingsTable.tsx | 10 +- .../website/components/leagues/LeagueTabs.tsx | 40 ---- .../view-data/LeagueDetailViewDataBuilder.ts | 55 ++++- .../services/leagues/LeagueRulebookService.ts | 56 +++-- .../services/leagues/LeagueScheduleService.ts | 68 +++--- .../lib/services/leagues/LeagueService.ts | 73 +++++-- .../leagues/LeagueStandingsService.ts | 105 ++++------ .../lib/view-data/LeagueDetailViewData.ts | 2 + .../leagues/LeagueScheduleViewData.ts | 1 + .../templates/LeagueDetailTemplate.tsx | 42 +++- .../templates/LeagueOverviewTemplate.tsx | 197 +++++++++++------- .../templates/LeagueScheduleTemplate.tsx | 3 +- .../templates/LeagueStandingsTemplate.tsx | 6 +- plans/league-detail-restoration.md | 74 +++++++ 22 files changed, 576 insertions(+), 487 deletions(-) create mode 100644 apps/website/app/leagues/[id]/roster/page.tsx delete mode 100644 apps/website/components/leagues/LeagueHeader.tsx delete mode 100644 apps/website/components/leagues/LeagueHeaderPanel.tsx delete mode 100644 apps/website/components/leagues/LeagueLogoWrapper.tsx delete mode 100644 apps/website/components/leagues/LeagueNavTabs.tsx delete mode 100644 apps/website/components/leagues/LeagueTabs.tsx create mode 100644 plans/league-detail-restoration.md diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 6e719fbc4..dd4750ebf 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -2,6 +2,7 @@ import { notFound } from 'next/navigation'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; +import { DriverService } from '@/lib/services/drivers/DriverService'; import { Text } from '@/ui/Text'; export default async function LeagueLayout({ @@ -14,7 +15,10 @@ export default async function LeagueLayout({ const { id: leagueId } = await params; // Execute PageQuery to get league data - const result = await LeagueDetailPageQuery.execute(leagueId); + const [result, currentDriver] = await Promise.all([ + LeagueDetailPageQuery.execute(leagueId), + new DriverService().getCurrentDriver(), + ]); if (result.isErr()) { const error = result.getError(); @@ -34,6 +38,7 @@ export default async function LeagueLayout({ ownerSummary: null, adminSummaries: [], stewardSummaries: [], + memberSummaries: [], sponsorInsights: null }} tabs={[]} @@ -47,11 +52,11 @@ export default async function LeagueLayout({ const viewData = LeagueDetailViewDataBuilder.build({ league: data.league, - owner: null, - scoringConfig: null, - memberships: { members: [] }, - races: [], - sponsors: [], + owner: data.owner, + scoringConfig: data.scoringConfig, + memberships: data.memberships, + races: data.races, + sponsors: data.sponsors, }); // Define tab configuration @@ -59,19 +64,23 @@ export default async function LeagueLayout({ { label: 'Overview', href: `/leagues/${leagueId}`, exact: true }, { label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false }, { label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false }, + { label: 'Roster', href: `/leagues/${leagueId}/roster`, exact: false }, { label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false }, ]; - const adminTabs = [ + // Check if user is admin or owner + const isOwner = currentDriver && data.league.ownerId === currentDriver.id; + const isAdmin = currentDriver && data.memberships.members?.some(m => m.driverId === currentDriver.id && m.role === 'admin'); + const hasAdminAccess = isOwner || isAdmin; + + const adminTabs = hasAdminAccess ? [ { label: 'Schedule Admin', href: `/leagues/${leagueId}/schedule/admin`, exact: false }, { label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false }, { label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false }, { label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false }, { label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false }, - ]; + ] : []; - // TODO: Admin check needs to be implemented properly - // For now, show admin tabs if user is logged in const tabs = [...baseTabs, ...adminTabs]; return ( diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 43bcce209..65d15b5f3 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -64,14 +64,13 @@ export default async function Page({ params }: Props) { const league = data.league; // Build ViewData using the builder - // Note: This would need additional data (owner, scoring config, etc.) in real implementation const viewData = LeagueDetailViewDataBuilder.build({ league: data.league, - owner: null, - scoringConfig: null, - memberships: { members: [] }, - races: [], - sponsors: [], + owner: data.owner, + scoringConfig: data.scoringConfig, + memberships: data.memberships, + races: data.races, + sponsors: data.sponsors, }); const jsonLd = { diff --git a/apps/website/app/leagues/[id]/roster/page.tsx b/apps/website/app/leagues/[id]/roster/page.tsx new file mode 100644 index 000000000..24d3dacc2 --- /dev/null +++ b/apps/website/app/leagues/[id]/roster/page.tsx @@ -0,0 +1,67 @@ +import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; +import { notFound } from 'next/navigation'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; + +interface Props { + params: Promise<{ id: string }>; +} + +export default async function LeagueRosterPage({ params }: Props) { + const { id: leagueId } = await params; + const result = await LeagueDetailPageQuery.execute(leagueId); + + if (result.isErr()) { + notFound(); + } + + const data = result.unwrap(); + const members = data.memberships.members || []; + + return ( + + + League Roster + All drivers currently registered in this league. + + + + + + + + + + + + + {members.map((member) => ( + + + + + + ))} + {members.length === 0 && ( + + + + )} + +
DriverRoleJoined
+ + + {member.driver.name} + + + {member.role} + + {new Date(member.joinedAt).toLocaleDateString()} +
+ No members found in this league. +
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx deleted file mode 100644 index f49ca2e4f..000000000 --- a/apps/website/components/leagues/LeagueHeader.tsx +++ /dev/null @@ -1,61 +0,0 @@ - - -import { Heading } from '@/ui/Heading'; -import { Image } from '@/ui/Image'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { ReactNode } from 'react'; - -interface LeagueHeaderProps { - name: string; - description?: string | null; - logoUrl: string; - sponsorContent?: ReactNode; - statusContent?: ReactNode; -} - -export function LeagueHeader({ - name, - description, - logoUrl, - sponsorContent, - statusContent, -}: LeagueHeaderProps) { - return ( - - - - - {`${name} - - - - - {name} - {sponsorContent && ( - - by {sponsorContent} - - )} - - {statusContent} - - {description && ( - - {description} - - )} - - - - - ); -} diff --git a/apps/website/components/leagues/LeagueHeaderPanel.tsx b/apps/website/components/leagues/LeagueHeaderPanel.tsx deleted file mode 100644 index 2c8113bbe..000000000 --- a/apps/website/components/leagues/LeagueHeaderPanel.tsx +++ /dev/null @@ -1,83 +0,0 @@ -'use client'; - -import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; -import { Card } from '@/ui/Card'; -import { Heading } from '@/ui/Heading'; -import { Icon } from '@/ui/Icon'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Activity, Timer, Trophy, Users, type LucideIcon } from 'lucide-react'; - -interface LeagueHeaderPanelProps { - viewData: LeagueDetailViewData; -} - -export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) { - return ( - - {/* Background Accent */} - {null} - - - - - - - - - {viewData.name} - - - - {viewData.description} - - - - - - - - - - - ); -} - -function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) { - return ( - - - - - {label.toUpperCase()} - - - - {value} - - - ); -} diff --git a/apps/website/components/leagues/LeagueLogoWrapper.tsx b/apps/website/components/leagues/LeagueLogoWrapper.tsx deleted file mode 100644 index 0c614b203..000000000 --- a/apps/website/components/leagues/LeagueLogoWrapper.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react'; -import { LeagueLogo as UiLeagueLogo } from '@/components/leagues/LeagueLogo'; - -export interface LeagueLogoProps { - leagueId: string; - alt: string; -} - -export function LeagueLogo({ leagueId, alt }: LeagueLogoProps) { - return ( - - ); -} diff --git a/apps/website/components/leagues/LeagueNavTabs.tsx b/apps/website/components/leagues/LeagueNavTabs.tsx deleted file mode 100644 index 150afc165..000000000 --- a/apps/website/components/leagues/LeagueNavTabs.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import { Link } from '@/ui/Link'; -import { Stack } from '@/ui/Stack'; - -interface Tab { - label: string; - href: string; - exact?: boolean; -} - -interface LeagueNavTabsProps { - tabs: Tab[]; - currentPathname: string; -} - -export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) { - return ( - - - {tabs.map((tab) => { - const isActive = tab.exact - ? currentPathname === tab.href - : currentPathname.startsWith(tab.href); - - return ( - - - {tab.label} - - {isActive && ( - - )} - - ); - })} - - - ); -} diff --git a/apps/website/components/leagues/LeagueSchedulePanel.tsx b/apps/website/components/leagues/LeagueSchedulePanel.tsx index 2826d3d10..142fcf7ac 100644 --- a/apps/website/components/leagues/LeagueSchedulePanel.tsx +++ b/apps/website/components/leagues/LeagueSchedulePanel.tsx @@ -12,6 +12,7 @@ interface RaceEvent { date: string; time: string; status: 'upcoming' | 'live' | 'completed'; + strengthOfField?: number; } interface LeagueSchedulePanelProps { @@ -56,6 +57,12 @@ export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) { {event.time} + {event.strengthOfField && ( + + SOF + {event.strengthOfField} + + )} diff --git a/apps/website/components/leagues/LeagueStandingsTable.tsx b/apps/website/components/leagues/LeagueStandingsTable.tsx index 4d6dd9ad4..1d1a4f095 100644 --- a/apps/website/components/leagues/LeagueStandingsTable.tsx +++ b/apps/website/components/leagues/LeagueStandingsTable.tsx @@ -11,6 +11,8 @@ interface StandingEntry { points: number; wins: number; podiums: number; + races: number; + avgFinish: number | null; gap: string; } @@ -34,10 +36,10 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) { Team - Wins + Races - Podiums + Avg Points @@ -60,10 +62,10 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) { {entry.teamName || '—'} - {entry.wins} + {entry.races} - {entry.podiums} + {entry.avgFinish?.toFixed(1) || '—'} {entry.points} diff --git a/apps/website/components/leagues/LeagueTabs.tsx b/apps/website/components/leagues/LeagueTabs.tsx deleted file mode 100644 index 0fe0dd060..000000000 --- a/apps/website/components/leagues/LeagueTabs.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import { Link } from '@/ui/Link'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; - -interface Tab { - label: string; - href: string; - exact?: boolean; -} - -interface LeagueTabsProps { - tabs: Tab[]; -} - -export function LeagueTabs({ tabs }: LeagueTabsProps) { - return ( - - - {tabs.map((tab) => ( - - - - {tab.label} - - - - ))} - - - ); -} diff --git a/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts b/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts index 24e6f68d7..7accc3789 100644 --- a/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/LeagueDetailViewDataBuilder.ts @@ -35,9 +35,12 @@ export class LeagueDetailViewDataBuilder { // Calculate info data const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0; - const completedRacesCount = races.filter(r => r.name.includes('Completed')).length; // Placeholder - const avgSOF = races.length > 0 - ? Math.round(races.reduce((sum, _r) => sum + 0, 0) / races.length) + const completedRacesCount = races.filter(r => (r as any).status === 'completed').length; + + // Compute real avgSOF from races + const racesWithSOF = races.filter(r => typeof (r as any).strengthOfField === 'number' && (r as any).strengthOfField > 0); + const avgSOF = racesWithSOF.length > 0 + ? Math.round(racesWithSOF.reduce((sum, r) => sum + ((r as any).strengthOfField || 0), 0) / racesWithSOF.length) : null; const info: LeagueInfoData = { @@ -76,16 +79,58 @@ export class LeagueDetailViewDataBuilder { tagline: s.tagline, })); + // Convert memberships to summaries + const adminSummaries: DriverSummaryData[] = (memberships.members || []) + .filter(m => m.role === 'admin') + .map(m => ({ + driverId: m.driverId, + driverName: m.driver.name, + avatarUrl: (m.driver as any).avatarUrl || null, + rating: null, + rank: null, + roleBadgeText: 'Admin', + roleBadgeClasses: 'bg-blue-500/10 text-blue-500 border-blue-500/30', + profileUrl: `/drivers/${m.driverId}`, + })); + + const stewardSummaries: DriverSummaryData[] = (memberships.members || []) + .filter(m => m.role === 'steward') + .map(m => ({ + driverId: m.driverId, + driverName: m.driver.name, + avatarUrl: (m.driver as any).avatarUrl || null, + rating: null, + rank: null, + roleBadgeText: 'Steward', + roleBadgeClasses: 'bg-purple-500/10 text-purple-500 border-purple-500/30', + profileUrl: `/drivers/${m.driverId}`, + })); + + const memberSummaries: DriverSummaryData[] = (memberships.members || []) + .filter(m => m.role === 'member') + .map(m => ({ + driverId: m.driverId, + driverName: m.driver.name, + avatarUrl: (m.driver as any).avatarUrl || null, + rating: null, + rank: null, + roleBadgeText: 'Member', + roleBadgeClasses: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/30', + profileUrl: `/drivers/${m.driverId}`, + })); + return { leagueId: league.id, name: league.name, description: league.description || '', + logoUrl: league.logoUrl, info, runningRaces, sponsors: sponsorInfo, ownerSummary, - adminSummaries: [], // Would need additional data - stewardSummaries: [], // Would need additional data + adminSummaries, + stewardSummaries, + memberSummaries, sponsorInsights: null, // Only for sponsor mode }; } diff --git a/apps/website/lib/services/leagues/LeagueRulebookService.ts b/apps/website/lib/services/leagues/LeagueRulebookService.ts index b0b967e52..650039327 100644 --- a/apps/website/lib/services/leagues/LeagueRulebookService.ts +++ b/apps/website/lib/services/leagues/LeagueRulebookService.ts @@ -1,30 +1,44 @@ import { Result } from '@/lib/contracts/Result'; import { Service, DomainError } from '@/lib/contracts/services/Service'; import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; export class LeagueRulebookService implements Service { + private apiClient: LeaguesApiClient; + + constructor() { + const baseUrl = getWebsiteApiBaseUrl(); + this.apiClient = new LeaguesApiClient( + baseUrl, + new ConsoleErrorReporter(), + new ConsoleLogger() + ); + } + async getRulebookData(leagueId: string): Promise> { - // Mock data since backend not implemented - const mockData: RulebookApiDto = { - leagueId, - scoringConfig: { - gameName: 'iRacing', - scoringPresetName: 'Custom Rules', - championships: [ - { - type: 'driver', + try { + const config = await this.apiClient.getLeagueConfig(leagueId); + + const mockData: RulebookApiDto = { + leagueId, + scoringConfig: { + gameName: 'iRacing', + scoringPresetName: config.form?.scoring?.type || 'Standard', + championships: (config.form?.championships || []).map((c: any) => ({ + type: c.type || 'driver', sessionTypes: ['Race'], - pointsPreview: [ - { sessionType: 'Race', position: 1, points: 25 }, - { sessionType: 'Race', position: 2, points: 20 }, - { sessionType: 'Race', position: 3, points: 16 }, - ], - bonusSummary: ['Pole Position: +1', 'Fastest Lap: +1'], - } - ], - dropPolicySummary: 'All results count', - }, - }; - return Result.ok(mockData); + pointsPreview: [], + bonusSummary: [], + })), + dropPolicySummary: config.form?.dropPolicy?.strategy || 'All results count', + }, + }; + return Result.ok(mockData); + } catch (error: unknown) { + return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch rulebook' }); + } } } diff --git a/apps/website/lib/services/leagues/LeagueScheduleService.ts b/apps/website/lib/services/leagues/LeagueScheduleService.ts index 97ba0e562..8632d0da7 100644 --- a/apps/website/lib/services/leagues/LeagueScheduleService.ts +++ b/apps/website/lib/services/leagues/LeagueScheduleService.ts @@ -1,39 +1,45 @@ import { Result } from '@/lib/contracts/Result'; import { Service, DomainError } from '@/lib/contracts/services/Service'; import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; +import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; +import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; export class LeagueScheduleService implements Service { + private apiClient: LeaguesApiClient; + + constructor() { + const baseUrl = getWebsiteApiBaseUrl(); + this.apiClient = new LeaguesApiClient( + baseUrl, + new ConsoleErrorReporter(), + new ConsoleLogger() + ); + } + async getScheduleData(leagueId: string): Promise> { - // Mock data since backend not implemented - const mockData: LeagueScheduleApiDto = { - leagueId, - races: [ - { - id: 'race-1', - name: 'Round 1 - Monza', - date: '2024-10-15T14:00:00Z', - track: 'Monza Circuit', - car: 'Ferrari SF90', - sessionType: 'Race', - }, - { - id: 'race-2', - name: 'Round 2 - Silverstone', - date: '2024-10-22T13:00:00Z', - track: 'Silverstone Circuit', - car: 'Mercedes W10', - sessionType: 'Race', - }, - { - id: 'race-3', - name: 'Round 3 - Spa-Francorchamps', - date: '2024-10-29T12:00:00Z', - track: 'Circuit de Spa-Francorchamps', - car: 'Red Bull RB15', - sessionType: 'Race', - }, - ], - }; - return Result.ok(mockData); + try { + const data = await this.apiClient.getSchedule(leagueId); + + // Map LeagueScheduleDTO to LeagueScheduleApiDto + const apiDto: LeagueScheduleApiDto = { + leagueId, + races: data.races.map(race => ({ + id: race.id, + name: race.name, + date: race.date, + track: (race as any).track || 'TBA', + car: (race as any).car || 'TBA', + sessionType: (race as any).sessionType || 'Race', + status: (race as any).status || 'scheduled', + strengthOfField: (race as any).strengthOfField, + })), + }; + + return Result.ok(apiDto); + } catch (error: unknown) { + return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch schedule' }); + } } } diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 1c78c3c30..5641c5ef6 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -18,6 +18,9 @@ import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO'; import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO'; import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO'; +import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO'; +import type { RaceDTO } from '@/lib/types/generated/RaceDTO'; +import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO'; import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; @@ -45,7 +48,11 @@ export interface LeagueRosterAdminData { export interface LeagueDetailData { league: LeagueWithCapacityAndScoringDTO; - apiDto: AllLeaguesWithCapacityAndScoringDTO; + owner: GetDriverOutputDTO | null; + scoringConfig: LeagueScoringConfigDTO | null; + memberships: LeagueMembershipsDTO; + races: RaceDTO[]; + sponsors: any[]; } /** @@ -58,24 +65,28 @@ export interface LeagueDetailData { @injectable() export class LeagueService implements Service { private apiClient: LeaguesApiClient; - private driversApiClient?: DriversApiClient; - private sponsorsApiClient?: SponsorsApiClient; - private racesApiClient?: RacesApiClient; + private driversApiClient: DriversApiClient; + private sponsorsApiClient: SponsorsApiClient; + private racesApiClient: RacesApiClient; constructor(@unmanaged() apiClient?: LeaguesApiClient) { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + if (apiClient) { this.apiClient = apiClient; } else { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: false, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); } - // Optional clients can be initialized if needed + + this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger); + this.sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger); + this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); } async getLeagueStandings(leagueId: string): Promise { @@ -143,7 +154,11 @@ export class LeagueService implements Service { async getLeagueDetailData(leagueId: string): Promise> { try { - const apiDto = await this.apiClient.getAllWithCapacityAndScoring(); + const [apiDto, memberships, racesResponse] = await Promise.all([ + this.apiClient.getAllWithCapacityAndScoring(), + this.apiClient.getMemberships(leagueId), + this.apiClient.getRaces(leagueId), + ]); if (!apiDto || !apiDto.leagues) { return Result.err({ type: 'notFound', message: 'Leagues not found' }); @@ -153,10 +168,40 @@ export class LeagueService implements Service { if (!league) { return Result.err({ type: 'notFound', message: 'League not found' }); } + + // Fetch owner if ownerId exists + let owner: GetDriverOutputDTO | null = null; + if (league.ownerId) { + owner = await this.driversApiClient.getDriver(league.ownerId); + } + + // Fetch scoring config if available + let scoringConfig: LeagueScoringConfigDTO | null = null; + try { + const config = await this.apiClient.getLeagueConfig(leagueId); + if (config.form?.scoring) { + // Map form scoring to LeagueScoringConfigDTO if possible, or just use partial + scoringConfig = { + leagueId, + seasonId: '', // Not available in this context + gameId: '', + gameName: '', + scoringPresetId: (config.form.scoring as any).presetId, + dropPolicySummary: '', + championships: [], + }; + } + } catch (e) { + console.warn('Failed to fetch league scoring config', e); + } return Result.ok({ league, - apiDto, + owner, + scoringConfig, + memberships, + races: racesResponse.races, + sponsors: [], // Sponsors integration can be added here }); } catch (error: unknown) { console.error('LeagueService.getLeagueDetailData failed:', error); diff --git a/apps/website/lib/services/leagues/LeagueStandingsService.ts b/apps/website/lib/services/leagues/LeagueStandingsService.ts index 76e57063c..4eaf546dc 100644 --- a/apps/website/lib/services/leagues/LeagueStandingsService.ts +++ b/apps/website/lib/services/leagues/LeagueStandingsService.ts @@ -18,72 +18,51 @@ export class LeagueStandingsService implements Service { ); } - async getStandingsData(_: string): Promise> { - // Mock data since backend may not be implemented - const mockStandings: LeagueStandingsApiDto = { - standings: [ - { - driverId: 'driver1', - driver: { - id: 'driver1', - name: 'John Doe', - iracingId: '12345', - country: 'US', - joinedAt: new Date().toISOString(), - }, - points: 100, - position: 1, - wins: 2, - podiums: 3, - races: 5, - }, - { - driverId: 'driver2', - driver: { - id: 'driver2', - name: 'Jane Smith', - iracingId: '67890', - country: 'UK', - joinedAt: new Date().toISOString(), - }, - points: 80, - position: 2, - wins: 1, - podiums: 2, - races: 5, - }, - ], - }; + async getStandingsData(leagueId: string): Promise> { + try { + const [standingsData, membershipsData] = await Promise.all([ + this.apiClient.getStandings(leagueId), + this.apiClient.getMemberships(leagueId), + ]); - const mockMemberships: LeagueMembershipsApiDto = { - members: [ - { - driverId: 'driver1', + // Map LeagueStandingsDTO to LeagueStandingsApiDto + const standings: LeagueStandingsApiDto = { + standings: standingsData.standings.map(s => ({ + driverId: s.driverId, driver: { - id: 'driver1', - name: 'John Doe', - iracingId: '12345', - country: 'US', - joinedAt: new Date().toISOString(), + id: s.driver.id, + name: s.driver.name, + iracingId: s.driver.iracingId, + country: s.driver.country, + joinedAt: s.driver.joinedAt, }, - role: 'member', - joinedAt: new Date().toISOString(), - }, - { - driverId: 'driver2', - driver: { - id: 'driver2', - name: 'Jane Smith', - iracingId: '67890', - country: 'UK', - joinedAt: new Date().toISOString(), - }, - role: 'member', - joinedAt: new Date().toISOString(), - }, - ], - }; + points: s.points, + position: s.position, + wins: s.wins, + podiums: s.podiums, + races: s.races, + })), + }; - return Result.ok({ standings: mockStandings, memberships: mockMemberships }); + // Map LeagueMembershipsDTO to LeagueMembershipsApiDto + const memberships: LeagueMembershipsApiDto = { + members: (membershipsData.members || []).map(m => ({ + driverId: m.driverId, + driver: { + id: m.driver.id, + name: m.driver.name, + iracingId: m.driver.iracingId, + country: m.driver.country, + joinedAt: m.driver.joinedAt, + }, + role: m.role as any, + joinedAt: m.joinedAt, + })), + }; + + return Result.ok({ standings, memberships }); + } catch (error: unknown) { + return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch standings' }); + } } } diff --git a/apps/website/lib/view-data/LeagueDetailViewData.ts b/apps/website/lib/view-data/LeagueDetailViewData.ts index 9c60fccc7..fadcf35ae 100644 --- a/apps/website/lib/view-data/LeagueDetailViewData.ts +++ b/apps/website/lib/view-data/LeagueDetailViewData.ts @@ -70,6 +70,7 @@ export interface LeagueDetailViewData extends ViewData { leagueId: string; name: string; description: string; + logoUrl?: string; // Info card data info: LeagueInfoData; @@ -84,6 +85,7 @@ export interface LeagueDetailViewData extends ViewData { ownerSummary: DriverSummaryData | null; adminSummaries: DriverSummaryData[]; stewardSummaries: DriverSummaryData[]; + memberSummaries: DriverSummaryData[]; // Sponsor insights (for sponsor mode) sponsorInsights: { diff --git a/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts b/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts index 5cbb94bfc..2b7656ef5 100644 --- a/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts +++ b/apps/website/lib/view-data/leagues/LeagueScheduleViewData.ts @@ -10,5 +10,6 @@ export interface LeagueScheduleViewData { isPast: boolean; isUpcoming: boolean; status: 'scheduled' | 'completed'; + strengthOfField?: number; }>; } \ No newline at end of file diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx index a547337e5..57eda221a 100644 --- a/apps/website/templates/LeagueDetailTemplate.tsx +++ b/apps/website/templates/LeagueDetailTemplate.tsx @@ -1,5 +1,6 @@ 'use client'; +import { usePathname } from 'next/navigation'; import { LeagueCard } from '@/components/leagues/LeagueCardWrapper'; import { routes } from '@/lib/routing/RouteConfig'; import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; @@ -13,6 +14,8 @@ import { ChevronRight } from 'lucide-react'; import { TemplateProps } from '@/lib/contracts/components/ComponentContracts'; export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps & { children?: React.ReactNode, tabs?: any[] }) { + const pathname = usePathname(); + return ( @@ -26,8 +29,43 @@ export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps {viewData.name} - {children} - {/* ... rest of the template ... */} + + {/* Tabs */} + {tabs && tabs.length > 0 && ( + + + {tabs.map((tab) => { + const isActive = tab.exact + ? pathname === tab.href + : pathname.startsWith(tab.href); + + return ( + + + + {tab.label} + + + + ); + })} + + + )} + + + {children} + diff --git a/apps/website/templates/LeagueOverviewTemplate.tsx b/apps/website/templates/LeagueOverviewTemplate.tsx index 4373e731c..84035ffe5 100644 --- a/apps/website/templates/LeagueOverviewTemplate.tsx +++ b/apps/website/templates/LeagueOverviewTemplate.tsx @@ -1,9 +1,11 @@ 'use client'; +import { LeagueLogo } from '@/components/leagues/LeagueLogo'; import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react'; interface LeagueOverviewTemplateProps { @@ -12,86 +14,139 @@ interface LeagueOverviewTemplateProps { export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) { return ( - - {/* Main Content */} - - - - About the League - - - {viewData.description || 'No description provided for this league.'} - - - - - - Quick Stats - - - - - - - + + {/* Header with Logo */} + + + + {viewData.name} + {viewData.info.structure} • Created {new Date(viewData.info.createdAt).toLocaleDateString()} - {/* Sidebar */} - - - - Management - - - {viewData.ownerSummary && ( - - - - - - Owner - {viewData.ownerSummary.driverName} - - - )} - - Admins - - {viewData.adminSummaries.map(admin => ( - - {admin.driverName} - + + {/* Main Content */} + + + + About the League + + + {viewData.description || 'No description provided for this league.'} + + + + + + Quick Stats + + + + + + + + + {/* Roster Preview */} + + + Roster Preview + + View All + + + + + + {viewData.adminSummaries.concat(viewData.stewardSummaries).concat(viewData.memberSummaries).slice(0, 5).map((member) => ( + + + + ))} - {viewData.adminSummaries.length === 0 && No admins assigned} - - - - + {viewData.adminSummaries.length === 0 && viewData.stewardSummaries.length === 0 && viewData.memberSummaries.length === 0 && ( + + + + )} + +
+ + + {member.driverName} + + + {member.roleBadgeText} +
+ No members to display +
+
+
+
- - Sponsors - - - {viewData.sponsors.length > 0 ? ( - viewData.sponsors.map(sponsor => ( - - - + {/* Sidebar */} + + + + Management + + + {viewData.ownerSummary && ( + + + - {sponsor.name} + + Owner + {viewData.ownerSummary.driverName} + - )) - ) : ( - No active sponsors - )} - - + )} + + Admins + + {viewData.adminSummaries.map(admin => ( + + {admin.driverName} + + ))} + {viewData.adminSummaries.length === 0 && No admins assigned} + + + + + + + + Sponsors + + + {viewData.sponsors.length > 0 ? ( + viewData.sponsors.map(sponsor => ( + + + + + {sponsor.name} + + )) + ) : ( + No active sponsors + )} + + + -
+
-
+ ); } @@ -107,4 +162,4 @@ function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: strin
); -} +} \ No newline at end of file diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index ebd155612..d9d625a67 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -16,7 +16,8 @@ export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps trackName: race.track || 'TBA', date: race.scheduledAt, time: new Date(race.scheduledAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), - status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming' + status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming', + strengthOfField: race.strengthOfField })); return ( diff --git a/apps/website/templates/LeagueStandingsTemplate.tsx b/apps/website/templates/LeagueStandingsTemplate.tsx index 2e5fbd82a..0f60d3940 100644 --- a/apps/website/templates/LeagueStandingsTemplate.tsx +++ b/apps/website/templates/LeagueStandingsTemplate.tsx @@ -32,8 +32,10 @@ export function LeagueStandingsTemplate({ position: entry.position, driverName: driver?.name || 'Unknown Driver', points: entry.totalPoints, - wins: 0, - podiums: 0, + wins: 0, // Placeholder + podiums: 0, // Placeholder + races: entry.racesStarted, + avgFinish: entry.avgFinish, gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}` }; }); diff --git a/plans/league-detail-restoration.md b/plans/league-detail-restoration.md new file mode 100644 index 000000000..175021c14 --- /dev/null +++ b/plans/league-detail-restoration.md @@ -0,0 +1,74 @@ +# Detailed Restoration Plan for League Detail Pages + +## Executive Summary +Restore `/leagues/:id` to full functionality per [`ALPHA_PLAN.md`](docs/ALPHA_PLAN.md:123): +- Logo display. +- Team members (roster). +- Races (schedule). +- SoF (avg Strength of Field). +- All tabs/subpages with data. + +**Timeline**: Surgical fixes (data queries, templates, cleanup). Production-ready: Data from bootstrap adapters, no mocks. + +## Current State (From Tools) +- Routes: Tabs via layout.tsx (Overview, Schedule, Standings, Rulebook + admin). +- page.tsx: [`LeagueDetailPageQuery`](apps/website/lib/page-queries/LeagueDetailPageQuery.ts:1) → LeagueService → partial DTO → hardcoded empty ViewData. +- Templates: Overview shows stats (0s), sidebar empty. Detail has breadcrumb/tabs. +- Subpages: schedule/standings exist (empty fallbacks), no roster/page.tsx. + +## Target State +``` +Data: Full relations from seeded adapters. +UI: Logo header, roster preview, races summary, SoF stat, populated tabs. +``` + +## Mermaid Flow +```mermaid +graph LR + PQ[LeagueDetailPageQuery] + LS[LeagueService.getLeagueDetailData
FIX: +includes(relations)] + VB[ViewDataBuilder
FIX: real inputs, avgSOF] + LT[LeagueDetailTemplate
Tabs active] + OV[OverviewTemplate
Logo/stats/roster] + SP[Sub Queries/Templates] + + Bootstrap[Adapters/Bootstrap Seed] -.-> LS +``` + +## Step-by-Step Execution (Todo) +1. **Data (1-3)**: + - Read/extend LeagueService: Repo.findById({include: ['memberships', 'races', 'sponsors', 'logoUrl']}). + - Bootstrap: Seed demo-league w/ logo/members/races.sof. + - VB: Pass query data, compute SOF avg. + +2. **UI (4-8)**: + - Logo: Add to DTO/template header (img fallback icon). + - Roster preview in overview (members.slice(0,5)). + - Create roster/page.tsx (members table). + - schedule/page.tsx: Races list (date/track/sof/status). + - standings/page.tsx: Standings + per-race SOF. + +3. **Tabs (9)**: + - DetailTemplate: usePathname for active tab. + +4. **Cleanup (10-11)**: + - search_files unused exports. + - Delete. + +5. **Verify (12-13)**: + - eslint/tsc/test. + - Manual demo-league test. + +## Files to Edit (Clickable) +[`LeagueService`](lib/services/leagues/LeagueService.ts) +[`LeagueDetailPageQuery`](lib/page-queries/LeagueDetailPageQuery.ts) +[`LeagueDetailViewDataBuilder`](lib/builders/view-data/LeagueDetailViewDataBuilder.ts) +[`LeagueOverviewTemplate`](templates/LeagueOverviewTemplate.tsx) +[`roster/page.tsx`](app/leagues/[id]/roster/page.tsx) (new) + +## Success Criteria +- Demo league: Logo shows, members>0, races list, SOF>0, tabs data. +- No empties/hardcodes. +- Tests pass. + +Review/approve before code. \ No newline at end of file