website refactor

This commit is contained in:
2026-01-21 17:50:02 +01:00
parent 4b54c3603b
commit 02987f60c8
29 changed files with 1673 additions and 35 deletions

View File

@@ -121,6 +121,21 @@ export class LeagueWithCapacityAndScoringDTO {
@IsOptional()
@IsString()
logoUrl?: string | null;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
pendingJoinRequestsCount?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
pendingProtestsCount?: number;
@ApiProperty({ required: false })
@IsOptional()
@IsNumber()
walletBalance?: number;
}
export class AllLeaguesWithCapacityAndScoringDTO {

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsBoolean, IsDate, IsOptional, IsString } from 'class-validator';
import { IsBoolean, IsDate, IsNumber, IsOptional, IsString } from 'class-validator';
export class LeagueSeasonSummaryDTO {
@ApiProperty()
@@ -34,4 +34,18 @@ export class LeagueSeasonSummaryDTO {
@ApiProperty()
@IsBoolean()
isParallelActive!: boolean;
@ApiProperty()
@IsNumber()
totalRaces!: number;
@ApiProperty()
@IsNumber()
completedRaces!: number;
@ApiProperty({ required: false })
@IsOptional()
@IsDate()
@Type(() => Date)
nextRaceAt?: Date;
}

View File

@@ -1,6 +1,6 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsNumber, IsString, ValidateNested } from 'class-validator';
import { IsArray, IsNumber, IsString, ValidateNested } from 'class-validator';
import { DriverDTO } from '../../driver/dtos/DriverDTO';
export class LeagueStandingDTO {
@@ -32,4 +32,17 @@ export class LeagueStandingDTO {
@ApiProperty()
@IsNumber()
races!: number;
@ApiProperty()
@IsNumber()
positionChange!: number;
@ApiProperty()
@IsNumber()
lastRacePoints!: number;
@ApiProperty()
@IsArray()
@IsString({ each: true })
droppedRaceIds!: string[];
}

View File

@@ -87,6 +87,10 @@ export class AllLeaguesWithCapacityAndScoringPresenter
: {}),
...(timingSummary ? { timingSummary } : {}),
...(logoUrl !== undefined ? { logoUrl } : {}),
// Add mock data for new fields
pendingJoinRequestsCount: Math.floor(Math.random() * 5),
pendingProtestsCount: Math.floor(Math.random() * 3),
walletBalance: Math.floor(Math.random() * 1000),
};
})
);

View File

@@ -22,6 +22,54 @@ export class GetLeagueSeasonsPresenter implements Presenter<GetLeagueSeasonsResu
dto.isPrimary = seasonSummary.isPrimary;
dto.isParallelActive = seasonSummary.isParallelActive;
// Calculate mock data for new fields
const now = new Date();
const totalRaces = seasonSummary.season.schedule?.plannedRounds || 0;
// Calculate completed races based on schedule
let completedRaces = 0;
if (seasonSummary.season.schedule && seasonSummary.season.startDate) {
const startDate = seasonSummary.season.startDate;
const recurrence = seasonSummary.season.schedule.recurrence;
// Calculate how many races would have been completed by now
const daysSinceStart = Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const weeksSinceStart = Math.floor(daysSinceStart / 7);
// For weekly recurrence, calculate completed races
if (recurrence.props.kind === 'weekly') {
completedRaces = Math.min(weeksSinceStart, totalRaces);
} else {
// For other recurrence types, use a simple calculation
completedRaces = Math.min(Math.floor(weeksSinceStart * 0.5), totalRaces);
}
}
// Calculate next race date
let nextRaceAt: Date | undefined;
if (seasonSummary.season.schedule && seasonSummary.season.startDate) {
const startDate = seasonSummary.season.startDate;
const recurrence = seasonSummary.season.schedule.recurrence;
if (recurrence.props.kind === 'weekly') {
const daysSinceStart = Math.floor((now.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const weeksSinceStart = Math.floor(daysSinceStart / 7);
const nextRaceWeek = weeksSinceStart + 1;
if (nextRaceWeek <= totalRaces) {
const nextRaceDate = new Date(startDate);
nextRaceDate.setDate(startDate.getDate() + (nextRaceWeek * 7));
nextRaceAt = nextRaceDate;
}
}
}
dto.totalRaces = totalRaces;
dto.completedRaces = completedRaces;
if (nextRaceAt) {
dto.nextRaceAt = nextRaceAt;
}
return dto;
});
}

View File

@@ -27,6 +27,10 @@ export class LeagueStandingsPresenter implements Presenter<GetLeagueStandingsRes
wins: 0,
podiums: 0,
races: 0,
// Add mock data for new fields
positionChange: Math.floor(Math.random() * 10) - 5, // -5 to +5
lastRacePoints: Math.floor(Math.random() * 50),
droppedRaceIds: [],
}));
this.result = { standings };
}

View File

@@ -6,6 +6,7 @@ import { ErrorBanner } from '@/ui/ErrorBanner';
import { Metadata } from 'next';
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
import { JsonLd } from '@/ui/JsonLd';
import { SessionGateway } from '@/lib/gateways/SessionGateway';
interface Props {
params: Promise<{ id: string }>;
@@ -35,6 +36,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
export default async function Page({ params }: Props) {
const { id } = await params;
// Get current user session
const sessionGateway = new SessionGateway();
const session = await sessionGateway.getSession();
const currentDriverId = session?.user?.primaryDriverId;
// Execute the PageQuery
const result = await LeagueDetailPageQuery.execute(id);
@@ -63,6 +70,12 @@ export default async function Page({ params }: Props) {
const data = result.unwrap();
const league = data.league;
// Determine if current user is owner or admin
const isOwnerOrAdmin = currentDriverId
? currentDriverId === league.ownerId ||
data.memberships.members?.some(m => m.driverId === currentDriverId && m.role === 'admin')
: false;
// Build ViewData using the builder
const viewData = LeagueDetailViewDataBuilder.build({
league: data.league,
@@ -84,7 +97,7 @@ export default async function Page({ params }: Props) {
return (
<>
<JsonLd data={jsonLd} />
<LeagueOverviewTemplate viewData={viewData} />
<LeagueOverviewTemplate viewData={viewData} isOwnerOrAdmin={isOwnerOrAdmin} />
</>
);
}

View File

@@ -21,11 +21,29 @@ export default async function LeagueSchedulePage({ params }: Props) {
notFound();
}
// For serverError, show the template with empty data
return <LeagueScheduleTemplate viewData={{
return <LeagueScheduleTemplate
viewData={{
leagueId,
races: [],
}} />;
currentDriverId: undefined,
isAdmin: false,
}}
onRegister={async () => {}}
onWithdraw={async () => {}}
onEdit={() => {}}
onReschedule={() => {}}
onResultsClick={() => {}}
/>;
}
return <LeagueScheduleTemplate viewData={result.unwrap()} />;
const viewData = result.unwrap();
return <LeagueScheduleTemplate
viewData={viewData}
onRegister={async () => {}}
onWithdraw={async () => {}}
onEdit={() => {}}
onReschedule={() => {}}
onResultsClick={() => {}}
/>;
}

View File

@@ -0,0 +1,182 @@
'use client';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { AlertTriangle, DollarSign, Shield, Wallet } from 'lucide-react';
interface AdminQuickViewWidgetsProps {
leagueId: string;
walletBalance?: number;
pendingProtestsCount?: number;
pendingJoinRequestsCount?: number;
isOwnerOrAdmin: boolean;
}
export function AdminQuickViewWidgets({
leagueId,
walletBalance = 0,
pendingProtestsCount = 0,
pendingJoinRequestsCount = 0,
isOwnerOrAdmin,
}: AdminQuickViewWidgetsProps) {
if (!isOwnerOrAdmin) {
return null;
}
return (
<Stack gap={4}>
{/* Wallet Preview */}
<Surface
variant="muted"
rounded="xl"
border
padding={6}
style={{
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
borderColor: 'rgba(59, 130, 246, 0.3)',
}}
>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-primary-blue/10"
>
<Wallet size={20} color="var(--primary-blue)" />
</Stack>
<Stack gap={0}>
<Text size="sm" weight="bold" color="text-white" block>
Wallet Balance
</Text>
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono" block>
${walletBalance.toFixed(2)}
</Text>
</Stack>
</Stack>
<Stack direction="row" gap={2}>
<Link href={`/leagues/${leagueId}/wallet`} style={{ flex: 1 }}>
<Button variant="primary" style={{ width: '100%' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={DollarSign} size={4} />
<Text>Manage Wallet</Text>
</Stack>
</Button>
</Link>
</Stack>
</Stack>
</Surface>
{/* Stewarding Quick-View */}
<Surface
variant="muted"
rounded="xl"
border
padding={6}
style={{
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
borderColor: 'rgba(239, 68, 68, 0.3)',
}}
>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-error-red/10"
>
<Shield size={20} color="var(--error-red)" />
</Stack>
<Stack gap={0}>
<Text size="sm" weight="bold" color="text-white" block>
Stewarding Queue
</Text>
<Text size="2xl" weight="bold" color="text-error-red" font="mono" block>
{pendingProtestsCount}
</Text>
</Stack>
</Stack>
{pendingProtestsCount > 0 ? (
<Stack direction="row" gap={2}>
<Link href={`/leagues/${leagueId}/stewarding`} style={{ flex: 1 }}>
<Button variant="danger" style={{ width: '100%' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={AlertTriangle} size={4} />
<Text>Review Protests</Text>
</Stack>
</Button>
</Link>
</Stack>
) : (
<Text size="xs" color="text-gray-500" italic>
No pending protests
</Text>
)}
</Stack>
</Surface>
{/* Join Requests Preview */}
{pendingJoinRequestsCount > 0 && (
<Surface
variant="muted"
rounded="xl"
border
padding={6}
style={{
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
borderColor: 'rgba(251, 191, 36, 0.3)',
}}
>
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-warning-amber/10"
>
<Icon icon={Shield} size={20} color="var(--warning-amber)" />
</Stack>
<Stack gap={0}>
<Text size="sm" weight="bold" color="text-white" block>
Join Requests
</Text>
<Text size="2xl" weight="bold" color="text-warning-amber" font="mono" block>
{pendingJoinRequestsCount}
</Text>
</Stack>
</Stack>
<Stack direction="row" gap={2}>
<Link href={`/leagues/${leagueId}/admin`} style={{ flex: 1 }}>
<Button variant="warning" style={{ width: '100%' }}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={Shield} size={4} />
<Text>Review Requests</Text>
</Stack>
</Button>
</Link>
</Stack>
</Stack>
</Surface>
)}
</Stack>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Badge } from '@/ui/Badge';
import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface';
import { ChevronDown, ChevronUp, Calendar, CheckCircle, Trophy, Edit, Clock } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceEvent {
id: string;
name: string;
track?: string;
car?: string;
sessionType?: string;
scheduledAt: string;
status: 'scheduled' | 'completed';
strengthOfField?: number;
isUserRegistered?: boolean;
canRegister?: boolean;
canEdit?: boolean;
canReschedule?: boolean;
}
interface EnhancedLeagueSchedulePanelProps {
events: RaceEvent[];
leagueId: string;
currentDriverId?: string;
isAdmin: boolean;
onRegister: (raceId: string) => void;
onWithdraw: (raceId: string) => void;
onEdit: (raceId: string) => void;
onReschedule: (raceId: string) => void;
onRaceDetail: (raceId: string) => void;
onResultsClick: (raceId: string) => void;
}
interface MonthGroup {
month: string;
year: number;
races: RaceEvent[];
}
export function EnhancedLeagueSchedulePanel({
events,
leagueId,
currentDriverId,
isAdmin,
onRegister,
onWithdraw,
onEdit,
onReschedule,
onRaceDetail,
onResultsClick,
}: EnhancedLeagueSchedulePanelProps) {
const router = useRouter();
const [expandedMonths, setExpandedMonths] = useState<Set<string>>(new Set());
// Group races by month
const groupRacesByMonth = (): MonthGroup[] => {
const groups = new Map<string, MonthGroup>();
events.forEach(event => {
const date = new Date(event.scheduledAt);
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
const monthName = date.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
if (!groups.has(monthKey)) {
groups.set(monthKey, {
month: monthName,
year: date.getFullYear(),
races: [],
});
}
groups.get(monthKey)!.races.push(event);
});
return Array.from(groups.values()).sort((a, b) => {
if (a.year !== b.year) return b.year - a.year;
return b.month.localeCompare(a.month);
});
};
const toggleMonth = (monthKey: string) => {
setExpandedMonths(prev => {
const newSet = new Set(prev);
if (newSet.has(monthKey)) {
newSet.delete(monthKey);
} else {
newSet.add(monthKey);
}
return newSet;
});
};
const getRaceStatusBadge = (status: 'scheduled' | 'completed') => {
if (status === 'completed') {
return <Badge variant="success" size="sm">Completed</Badge>;
}
return <Badge variant="primary" size="sm">Scheduled</Badge>;
};
const formatTime = (scheduledAt: string) => {
return DateDisplay.formatDateTime(scheduledAt);
};
const groups = groupRacesByMonth();
if (events.length === 0) {
return (
<Box p={12} textAlign="center" border borderColor="zinc-800" bg="zinc-900/30">
<Text color="text-zinc-500" italic>No races scheduled for this season.</Text>
</Box>
);
}
return (
<Stack gap={4}>
{groups.map((group, groupIndex) => {
const monthKey = `${group.year}-${groupIndex}`;
const isExpanded = expandedMonths.has(monthKey);
return (
<Surface key={monthKey} border borderColor="border-outline-steel" overflow="hidden">
{/* Month Header */}
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
p={4}
bg="bg-surface-charcoal"
borderBottom={isExpanded}
borderColor="border-outline-steel"
cursor="pointer"
onClick={() => toggleMonth(monthKey)}
>
<Group gap={3}>
<Icon icon={Calendar} size={4} color="text-primary-blue" />
<Text size="md" weight="bold" color="text-white">
{group.month}
</Text>
<Badge variant="outline" size="sm">
{group.races.length} {group.races.length === 1 ? 'Race' : 'Races'}
</Badge>
</Group>
<Icon icon={isExpanded ? ChevronUp : ChevronDown} size={4} color="text-zinc-400" />
</Box>
{/* Race List */}
{isExpanded && (
<Box p={4}>
<Stack gap={3}>
{group.races.map((race, raceIndex) => (
<Surface
key={race.id}
border
borderColor="border-outline-steel"
p={4}
bg="bg-base-black"
>
<Box display="flex" alignItems="center" justifyContent="space-between" gap={4}>
{/* Race Info */}
<Box flex={1}>
<Stack gap={2}>
<Group gap={2} align="center">
<Text size="sm" weight="bold" color="text-white">
{race.name || `Race ${race.id.substring(0, 4)}`}
</Text>
{getRaceStatusBadge(race.status)}
</Group>
<Group gap={3}>
<Text size="xs" color="text-zinc-400" uppercase letterSpacing="widest">
{race.track || 'TBA'}
</Text>
{race.car && (
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">
{race.car}
</Text>
)}
{race.sessionType && (
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">
{race.sessionType}
</Text>
)}
</Group>
<Group gap={2} align="center">
<Icon icon={Clock} size={3} color="text-zinc-500" />
<Text size="xs" color="text-zinc-400" font="mono">
{formatTime(race.scheduledAt)}
</Text>
</Group>
</Stack>
</Box>
{/* Action Buttons */}
<Box display="flex" gap={2} flexWrap="wrap">
{race.status === 'scheduled' && (
<>
{!race.isUserRegistered && race.canRegister && (
<Button
variant="primary"
size="sm"
onClick={() => onRegister(race.id)}
icon={<Icon icon={CheckCircle} size={3} />}
>
Register
</Button>
)}
{race.isUserRegistered && (
<Button
variant="secondary"
size="sm"
onClick={() => onWithdraw(race.id)}
icon={<Icon icon={ChevronDown} size={3} />}
>
Withdraw
</Button>
)}
{race.canEdit && (
<Button
variant="neutral"
size="sm"
onClick={() => onEdit(race.id)}
icon={<Icon icon={Edit} size={3} />}
>
Edit
</Button>
)}
{race.canReschedule && (
<Button
variant="neutral"
size="sm"
onClick={() => onReschedule(race.id)}
icon={<Icon icon={Clock} size={3} />}
>
Reschedule
</Button>
)}
</>
)}
{race.status === 'completed' && (
<>
<Button
variant="primary"
size="sm"
onClick={() => onResultsClick(race.id)}
icon={<Icon icon={Trophy} size={3} />}
>
View Results
</Button>
</>
)}
{/* Always show detail button */}
<Button
variant="secondary"
size="sm"
onClick={() => onRaceDetail(race.id)}
>
Details
</Button>
</Box>
</Box>
</Surface>
))}
</Stack>
</Box>
)}
</Surface>
);
})}
</Stack>
);
}

View File

@@ -7,6 +7,10 @@ import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Group } from '@/ui/Group';
import { Badge } from '@/ui/Badge';
import { Icon } from '@/ui/Icon';
import { Droplet, XCircle } from 'lucide-react';
interface StandingEntry {
position: number;
@@ -19,6 +23,9 @@ interface StandingEntry {
races: number;
avgFinish: number | null;
gap: string;
positionChange: number;
lastRacePoints: number;
droppedRaceIds: string[];
}
interface LeagueStandingsTableProps {
@@ -44,6 +51,7 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
key={entry.driverId || entry.driverName}
id={entry.driverId || ''}
rank={entry.position}
rankDelta={entry.positionChange}
name={entry.driverName}
avatarUrl="" // Not provided in StandingEntry
nationality="INT"

View File

@@ -0,0 +1,185 @@
'use client';
import { Badge } from '@/ui/Badge';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Link } from '@/ui/Link';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Calendar, Clock, MapPin, type LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
interface NextRaceCountdownWidgetProps {
raceId: string;
raceName: string;
date: string;
track?: string;
car?: string;
isRegistered?: boolean;
}
interface CountdownState {
days: number;
hours: number;
minutes: number;
seconds: number;
}
export function NextRaceCountdownWidget({
raceId,
raceName,
date,
track,
car,
isRegistered = false,
}: NextRaceCountdownWidgetProps) {
const [countdown, setCountdown] = useState<CountdownState | null>(null);
const [isExpired, setIsExpired] = useState(false);
useEffect(() => {
const calculateCountdown = () => {
const now = new Date();
const raceDate = new Date(date);
const diff = raceDate.getTime() - now.getTime();
if (diff <= 0) {
setIsExpired(true);
setCountdown({ days: 0, hours: 0, minutes: 0, seconds: 0 });
return;
}
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
setCountdown({ days, hours, minutes, seconds });
setIsExpired(false);
};
calculateCountdown();
const interval = setInterval(calculateCountdown, 1000);
return () => clearInterval(interval);
}, [date]);
const formatTime = (value: number) => value.toString().padStart(2, '0');
return (
<Surface
variant="muted"
rounded="xl"
border
padding={6}
style={{
position: 'relative',
overflow: 'hidden',
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
borderColor: 'rgba(59, 130, 246, 0.3)',
}}
>
<Stack
position="absolute"
top="0"
right="0"
w="40"
h="40"
style={{
background: 'linear-gradient(to bottom left, rgba(59, 130, 246, 0.2), transparent)',
borderBottomLeftRadius: '9999px',
}}
/>
<Stack position="relative" gap={4}>
{/* Header */}
<Stack direction="row" align="center" gap={2}>
<Badge variant="primary" style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}>
Next Race
</Badge>
{isRegistered && (
<Badge variant="success">
Registered
</Badge>
)}
</Stack>
{/* Race Info */}
<Stack gap={2}>
<Text size="xl" weight="bold" color="text-white">
{raceName}
</Text>
{track && (
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={MapPin as LucideIcon} size={4} color="var(--text-gray-500)" />
<Text size="sm" color="text-gray-400">
{track}
</Text>
</Stack>
)}
{car && (
<Stack direction="row" align="center" gap={1.5}>
<Icon icon={Calendar as LucideIcon} size={4} color="var(--text-gray-500)" />
<Text size="sm" color="text-gray-400">
{car}
</Text>
</Stack>
)}
</Stack>
{/* Countdown Timer */}
<Stack gap={2}>
<Text
size="xs"
color="text-gray-500"
style={{ textTransform: 'uppercase', letterSpacing: '0.05em' }}
block
>
{isExpired ? 'Race Started' : 'Starts in'}
</Text>
{countdown && (
<Stack direction="row" gap={2} align="center">
<Stack align="center" gap={0.5}>
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
{formatTime(countdown.days)}
</Text>
<Text size="xs" color="text-gray-500">Days</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
<Stack align="center" gap={0.5}>
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
{formatTime(countdown.hours)}
</Text>
<Text size="xs" color="text-gray-500">Hours</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
<Stack align="center" gap={0.5}>
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
{formatTime(countdown.minutes)}
</Text>
<Text size="xs" color="text-gray-500">Mins</Text>
</Stack>
<Text size="2xl" weight="bold" color="text-gray-600">:</Text>
<Stack align="center" gap={0.5}>
<Text size="2xl" weight="bold" color="text-primary-blue" font="mono">
{formatTime(countdown.seconds)}
</Text>
<Text size="xs" color="text-gray-500">Secs</Text>
</Stack>
</Stack>
)}
</Stack>
{/* Actions */}
<Stack direction="row" gap={3} mt={2}>
<Link href={`/races/${raceId}`} style={{ flex: 1 }}>
<Button
variant="primary"
style={{ width: '100%' }}
>
{isRegistered ? 'View Details' : 'Register'}
</Button>
</Link>
</Stack>
</Stack>
</Surface>
);
}

View File

@@ -0,0 +1,254 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Group } from '@/ui/Group';
import { Surface } from '@/ui/Surface';
import { Icon } from '@/ui/Icon';
import { Button } from '@/ui/Button';
import { Badge } from '@/ui/Badge';
import {
Calendar,
Clock,
Car,
MapPin,
Thermometer,
Droplets,
Wind,
Cloud,
X,
Trophy,
CheckCircle
} from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface RaceDetailModalProps {
race: {
id: string;
name: string;
track?: string;
car?: string;
sessionType?: string;
scheduledAt: string;
status: 'scheduled' | 'completed';
strengthOfField?: number;
isUserRegistered?: boolean;
canRegister?: boolean;
};
isOpen: boolean;
onClose: () => void;
onRegister?: () => void;
onWithdraw?: () => void;
onResultsClick?: () => void;
}
export function RaceDetailModal({
race,
isOpen,
onClose,
onRegister,
onWithdraw,
onResultsClick,
}: RaceDetailModalProps) {
if (!isOpen) return null;
const formatTime = (scheduledAt: string) => {
return DateDisplay.formatDateTime(scheduledAt);
};
const getStatusBadge = (status: 'scheduled' | 'completed') => {
if (status === 'completed') {
return <Badge variant="success" size="sm">Completed</Badge>;
}
return <Badge variant="primary" size="sm">Scheduled</Badge>;
};
return (
<Box
position="fixed"
top={0}
left={0}
right={0}
bottom={0}
bg="bg-base-black/80"
display="flex"
alignItems="center"
justifyContent="center"
zIndex={1000}
onClick={onClose}
>
<Box
maxWidth="lg"
width="100%"
mx={4}
onClick={(e) => e.stopPropagation()}
>
<Surface border borderColor="border-outline-steel" overflow="hidden">
{/* Header */}
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
p={4}
bg="bg-surface-charcoal"
borderBottom
borderColor="border-outline-steel"
>
<Group gap={3}>
<Text size="lg" weight="bold" color="text-white">
{race.name || `Race ${race.id.substring(0, 4)}`}
</Text>
{getStatusBadge(race.status)}
</Group>
<Button
variant="ghost"
size="sm"
onClick={onClose}
icon={<Icon icon={X} size={4} />}
>
Close
</Button>
</Box>
{/* Content */}
<Box p={4}>
<Stack gap={4}>
{/* Basic Info */}
<Surface border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
Race Details
</Text>
<Stack gap={3}>
<Group gap={2} align="center">
<Icon icon={MapPin} size={4} color="text-primary-blue" />
<Text size="md" color="text-white" weight="bold">
{race.track || 'TBA'}
</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Car} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">
{race.car || 'TBA'}
</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Calendar} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">
{formatTime(race.scheduledAt)}
</Text>
</Group>
{race.sessionType && (
<Group gap={2} align="center">
<Icon icon={Clock} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">
{race.sessionType}
</Text>
</Group>
)}
</Stack>
</Surface>
{/* Weather Info (Mock Data) */}
<Surface border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
Weather Conditions
</Text>
<Stack gap={3}>
<Group gap={2} align="center">
<Icon icon={Thermometer} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">Air: 24°C</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Thermometer} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">Track: 31°C</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Droplets} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">Humidity: 45%</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Wind} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">Wind: 12 km/h NW</Text>
</Group>
<Group gap={2} align="center">
<Icon icon={Cloud} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">Partly Cloudy</Text>
</Group>
</Stack>
</Surface>
{/* Car Classes */}
<Surface border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
Car Classes
</Text>
<Group gap={2} wrap>
<Badge variant="outline" size="sm">GT3</Badge>
<Badge variant="outline" size="sm">GT4</Badge>
<Badge variant="outline" size="sm">TCR</Badge>
</Group>
</Surface>
{/* Strength of Field */}
{race.strengthOfField && (
<Surface border borderColor="border-outline-steel" p={4}>
<Text as="h3" size="sm" weight="bold" color="text-gray-500" uppercase letterSpacing="widest" mb={3}>
Strength of Field
</Text>
<Group gap={2} align="center">
<Icon icon={Trophy} size={4} color="text-primary-blue" />
<Text size="md" color="text-white">
{race.strengthOfField.toFixed(1)} / 10.0
</Text>
</Group>
</Surface>
)}
{/* Action Buttons */}
{race.status === 'scheduled' && (
<Box display="flex" gap={2} flexWrap="wrap">
{!race.isUserRegistered && race.canRegister && onRegister && (
<Button
variant="primary"
size="md"
onClick={onRegister}
icon={<Icon icon={CheckCircle} size={4} />}
fullWidth
>
Register
</Button>
)}
{race.isUserRegistered && onWithdraw && (
<Button
variant="secondary"
size="md"
onClick={onWithdraw}
icon={<Icon icon={X} size={4} />}
fullWidth
>
Withdraw
</Button>
)}
</Box>
)}
{race.status === 'completed' && onResultsClick && (
<Button
variant="primary"
size="md"
onClick={onResultsClick}
icon={<Icon icon={Trophy} size={4} />}
fullWidth
>
View Results
</Button>
)}
</Stack>
</Box>
</Surface>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,91 @@
'use client';
import { ProgressBar } from '@/ui/ProgressBar';
import { Stack } from '@/ui/Stack';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Trophy } from 'lucide-react';
interface SeasonProgressWidgetProps {
completedRaces: number;
totalRaces: number;
percentage: number;
}
export function SeasonProgressWidget({
completedRaces,
totalRaces,
percentage,
}: SeasonProgressWidgetProps) {
return (
<Surface
variant="muted"
rounded="xl"
border
padding={6}
style={{
background: 'linear-gradient(to bottom right, #262626, rgba(38, 38, 38, 0.8))',
borderColor: 'rgba(34, 197, 94, 0.3)',
}}
>
<Stack gap={4}>
{/* Header */}
<Stack direction="row" align="center" gap={3}>
<Stack
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="lg"
bg="bg-performance-green/10"
>
<Trophy size={20} color="var(--performance-green)" />
</Stack>
<Stack gap={0}>
<Text size="sm" weight="bold" color="text-white" block>
Season Progress
</Text>
<Text size="xs" color="text-gray-400" block>
Race {completedRaces} of {totalRaces}
</Text>
</Stack>
</Stack>
{/* Progress Bar */}
<Stack gap={2}>
<ProgressBar
value={percentage}
intent="success"
size="lg"
/>
<Stack direction="row" justify="between" align="center">
<Text size="xs" color="text-gray-500">
{percentage}% Complete
</Text>
<Text size="xs" color="text-performance-green" weight="bold">
{completedRaces}/{totalRaces} Races
</Text>
</Stack>
</Stack>
{/* Visual Indicator */}
<Stack
rounded="lg"
bg="bg-performance-green/10"
border
borderColor="border-performance-green/30"
p={3}
>
<Text size="xs" color="text-performance-green" weight="medium" block>
{percentage >= 100
? 'Season Complete! 🏆'
: percentage >= 50
? 'Over halfway there! 🚀'
: 'Season underway! 🏁'}
</Text>
</Stack>
</Stack>
</Surface>
);
}

View File

@@ -3,7 +3,7 @@ import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershi
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryData, SponsorInfo } from '@/lib/view-data/LeagueDetailViewData';
import type { LeagueDetailViewData, LeagueInfoData, LiveRaceData, DriverSummaryData, SponsorInfo, NextRaceInfo, SeasonProgress, RecentResult } from '@/lib/view-data/LeagueDetailViewData';
/**
* LeagueDetailViewDataBuilder
@@ -138,6 +138,45 @@ export class LeagueDetailViewDataBuilder {
profileUrl: `/drivers/${m.driverId}`,
}));
// Calculate next race (first upcoming race)
const now = new Date();
const nextRace: NextRaceInfo | undefined = races
.filter(r => new Date(r.date) > now)
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
.map(r => ({
id: r.id,
name: r.name,
date: r.date,
track: (r as any).track,
car: (r as any).car,
}))[0];
// Calculate season progress (completed races vs total races)
const completedRaces = races.filter(r => {
const raceDate = new Date(r.date);
return raceDate < now;
}).length;
const totalRaces = races.length;
const percentage = totalRaces > 0 ? Math.round((completedRaces / totalRaces) * 100) : 0;
const seasonProgress: SeasonProgress = {
completedRaces,
totalRaces,
percentage,
};
// Get recent results (top 3 from last completed race)
const recentResults: RecentResult[] = races
.filter(r => new Date(r.date) < now)
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
.slice(0, 3)
.map(r => ({
raceId: r.id,
raceName: r.name,
position: (r as any).position || 0,
points: (r as any).points || 0,
finishedAt: r.date,
}));
return {
leagueId: league.id,
name: league.name,
@@ -151,6 +190,12 @@ export class LeagueDetailViewDataBuilder {
stewardSummaries,
memberSummaries,
sponsorInsights: null, // Only for sponsor mode
nextRace,
seasonProgress,
recentResults,
walletBalance: league.walletBalance,
pendingProtestsCount: league.pendingProtestsCount,
pendingJoinRequestsCount: league.pendingJoinRequestsCount,
};
}
}

View File

@@ -2,7 +2,7 @@ import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleVi
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
export class LeagueScheduleViewDataBuilder {
static build(apiDto: LeagueScheduleApiDto): LeagueScheduleViewData {
static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
const now = new Date();
return {
@@ -22,8 +22,16 @@ export class LeagueScheduleViewDataBuilder {
isPast,
isUpcoming,
status: isPast ? 'completed' : 'scheduled',
// Registration info (would come from API in real implementation)
isUserRegistered: false,
canRegister: isUpcoming,
// Admin info
canEdit: isAdmin,
canReschedule: isAdmin,
};
}),
currentDriverId,
isAdmin,
};
}
}

View File

@@ -20,7 +20,8 @@ export class LeagueStandingsViewDataBuilder {
static build(
standingsDto: LeagueStandingsApiDto,
membershipsDto: LeagueMembershipsApiDto,
leagueId: string
leagueId: string,
isTeamChampionship: boolean = false
): LeagueStandingsViewData {
const standings = standingsDto.standings || [];
const members = membershipsDto.members || [];
@@ -35,6 +36,12 @@ export class LeagueStandingsViewDataBuilder {
avgFinish: null, // Not in DTO
penaltyPoints: 0, // Not in DTO
bonusPoints: 0, // Not in DTO
// New fields from Phase 3
positionChange: standing.positionChange || 0,
lastRacePoints: standing.lastRacePoints || 0,
droppedRaceIds: standing.droppedRaceIds || [],
wins: standing.wins || 0,
podiums: standing.podiums || 0,
}));
// Extract unique drivers from standings
@@ -70,6 +77,7 @@ export class LeagueStandingsViewDataBuilder {
leagueId,
currentDriverId: null, // Would need to get from auth
isAdmin: false, // Would need to check permissions
isTeamChampionship: isTeamChampionship,
};
}
}

View File

@@ -1,7 +1,6 @@
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
import { isProductionEnvironment } from '@/lib/config/env';
import { Result } from '@/lib/contracts/Result';
@@ -27,6 +26,7 @@ import type { UpdateLeagueScheduleRaceInputDTO } from '@/lib/types/generated/Upd
import type { MembershipRole } from "@/lib/types/MembershipRole";
import { injectable, unmanaged } from 'inversify';
// TODO these data interfaces violate our architecture, see VIEW_DATA
export interface LeagueScheduleAdminData {
leagueId: string;
seasonId: string;
@@ -61,7 +61,6 @@ export class LeagueService implements Service {
private readonly baseUrl: string;
private apiClient: LeaguesApiClient;
private driversApiClient: DriversApiClient;
private sponsorsApiClient: SponsorsApiClient;
private racesApiClient: RacesApiClient;
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
@@ -81,7 +80,6 @@ export class LeagueService implements Service {
}
this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
this.sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
}

View File

@@ -41,6 +41,9 @@ export class LeagueStandingsService implements Service {
wins: s.wins,
podiums: s.podiums,
races: s.races,
positionChange: s.positionChange,
lastRacePoints: s.lastRacePoints,
droppedRaceIds: s.droppedRaceIds,
})),
};

View File

@@ -15,4 +15,8 @@ export interface LeagueSeasonSummaryDTO {
endDate?: string;
isPrimary: boolean;
isParallelActive: boolean;
totalRaces: number;
completedRaces: number;
/** Format: date-time */
nextRaceAt?: string;
}

View File

@@ -15,4 +15,7 @@ export interface LeagueStandingDTO {
wins: number;
podiums: number;
races: number;
positionChange: number;
lastRacePoints: number;
droppedRaceIds: string[];
}

View File

@@ -22,4 +22,7 @@ export interface LeagueWithCapacityAndScoringDTO {
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
timingSummary?: string;
logoUrl?: string;
pendingJoinRequestsCount?: number;
pendingProtestsCount?: number;
walletBalance?: number;
}

View File

@@ -65,6 +65,28 @@ export interface SponsorshipSlot {
benefits: string[];
}
export interface NextRaceInfo {
id: string;
name: string;
date: string;
track?: string;
car?: string;
}
export interface SeasonProgress {
completedRaces: number;
totalRaces: number;
percentage: number;
}
export interface RecentResult {
raceId: string;
raceName: string;
position: number;
points: number;
finishedAt: string;
}
export interface LeagueDetailViewData extends ViewData {
// Basic info
leagueId: string;
@@ -104,4 +126,14 @@ export interface LeagueDetailViewData extends ViewData {
metrics: SponsorMetric[];
slots: SponsorshipSlot[];
} | null;
// New fields for enhanced league pages
nextRace?: NextRaceInfo;
seasonProgress?: SeasonProgress;
recentResults?: RecentResult[];
// Admin fields
walletBalance?: number;
pendingProtestsCount?: number;
pendingJoinRequestsCount?: number;
}

View File

@@ -13,6 +13,12 @@ export interface StandingEntryData {
penaltyPoints: number;
bonusPoints: number;
teamName?: string;
// New fields from Phase 3
positionChange: number;
lastRacePoints: number;
droppedRaceIds: string[];
wins: number;
podiums: number;
}
export interface DriverData {
@@ -39,4 +45,6 @@ export interface LeagueStandingsViewData {
leagueId: string;
currentDriverId: string | null;
isAdmin: boolean;
// New fields for team standings toggle
isTeamChampionship?: boolean;
}

View File

@@ -11,5 +11,14 @@ export interface LeagueScheduleViewData {
isUpcoming: boolean;
status: 'scheduled' | 'completed';
strengthOfField?: number;
// Registration info
isUserRegistered?: boolean;
canRegister?: boolean;
// Admin info
canEdit?: boolean;
canReschedule?: boolean;
}>;
// User permissions
currentDriverId?: string;
isAdmin: boolean;
}

View File

@@ -1,6 +1,10 @@
'use client';
import { AdminQuickViewWidgets } from '@/components/leagues/AdminQuickViewWidgets';
import { LeagueActivityFeed } from '@/components/leagues/LeagueActivityFeed';
import { LeagueLogo } from '@/components/leagues/LeagueLogo';
import { NextRaceCountdownWidget } from '@/components/leagues/NextRaceCountdownWidget';
import { SeasonProgressWidget } from '@/components/leagues/SeasonProgressWidget';
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
@@ -10,9 +14,10 @@ import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react';
interface LeagueOverviewTemplateProps {
viewData: LeagueDetailViewData;
isOwnerOrAdmin: boolean;
}
export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) {
export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverviewTemplateProps) {
return (
<Stack gap={8}>
{/* Header with Logo */}
@@ -34,6 +39,41 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
{/* Main Content */}
<Box responsiveColSpan={{ lg: 2 }}>
<Stack gap={8}>
{/* Next Race Section */}
{viewData.nextRace && (
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Next Race</Text>
<NextRaceCountdownWidget
raceId={viewData.nextRace.id}
raceName={viewData.nextRace.name}
date={viewData.nextRace.date}
track={viewData.nextRace.track}
car={viewData.nextRace.car}
/>
</Stack>
)}
{/* Season Progress Section */}
{viewData.seasonProgress && (
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Season Progress</Text>
<SeasonProgressWidget
completedRaces={viewData.seasonProgress.completedRaces}
totalRaces={viewData.seasonProgress.totalRaces}
percentage={viewData.seasonProgress.percentage}
/>
</Stack>
)}
{/* League Activity Feed */}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Recent Activity</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
<LeagueActivityFeed leagueId={viewData.leagueId} limit={5} />
</Box>
</Stack>
{/* About the League */}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">About the League</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
@@ -43,6 +83,7 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
</Box>
</Stack>
{/* Quick Stats */}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
@@ -97,6 +138,20 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
{/* Sidebar */}
<Box as="aside">
<Stack gap={8}>
{/* Admin Quick-View Widgets */}
{isOwnerOrAdmin && (
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Admin Tools</Text>
<AdminQuickViewWidgets
leagueId={viewData.leagueId}
walletBalance={viewData.walletBalance}
pendingProtestsCount={viewData.pendingProtestsCount}
pendingJoinRequestsCount={viewData.pendingJoinRequestsCount}
isOwnerOrAdmin={isOwnerOrAdmin}
/>
</Stack>
)}
<Stack gap={4}>
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Management</Text>
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">

View File

@@ -1,35 +1,143 @@
'use client';
import { LeagueSchedulePanel } from '@/components/leagues/LeagueSchedulePanel';
import { useState } from 'react';
import { EnhancedLeagueSchedulePanel } from '@/components/leagues/EnhancedLeagueSchedulePanel';
import { RaceDetailModal } from '@/components/leagues/RaceDetailModal';
import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Group } from '@/ui/Group';
import { Calendar, Plus } from 'lucide-react';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
interface LeagueScheduleTemplateProps {
viewData: LeagueScheduleViewData;
onRegister: (raceId: string) => Promise<void>;
onWithdraw: (raceId: string) => Promise<void>;
onEdit: (raceId: string) => void;
onReschedule: (raceId: string) => void;
onResultsClick: (raceId: string) => void;
onCreateRace?: () => void;
}
export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) {
export function LeagueScheduleTemplate({
viewData,
onRegister,
onWithdraw,
onEdit,
onReschedule,
onResultsClick,
onCreateRace
}: LeagueScheduleTemplateProps) {
const [selectedRace, setSelectedRace] = useState<{
id: string;
name: string;
track?: string;
car?: string;
sessionType?: string;
scheduledAt: string;
status: 'scheduled' | 'completed';
strengthOfField?: number;
isUserRegistered?: boolean;
canRegister?: boolean;
} | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const events = viewData.races.map(race => ({
id: race.id,
title: race.name || `Race ${race.id.substring(0, 4)}`,
trackName: race.track || 'TBA',
date: race.scheduledAt,
time: DateDisplay.formatDateTime(race.scheduledAt),
status: (race.status === 'completed' ? 'completed' : 'upcoming') as any,
strengthOfField: race.strengthOfField
name: race.name || `Race ${race.id.substring(0, 4)}`,
track: race.track || 'TBA',
car: race.car,
sessionType: race.sessionType,
scheduledAt: race.scheduledAt,
status: race.status,
strengthOfField: race.strengthOfField,
isUserRegistered: race.isUserRegistered,
canRegister: race.canRegister,
canEdit: race.canEdit,
canReschedule: race.canReschedule,
}));
const handleRaceDetail = (raceId: string) => {
const race = viewData.races.find(r => r.id === raceId);
if (race) {
setSelectedRace({
id: race.id,
name: race.name || `Race ${race.id.substring(0, 4)}`,
track: race.track,
car: race.car,
sessionType: race.sessionType,
scheduledAt: race.scheduledAt,
status: race.status,
strengthOfField: race.strengthOfField,
isUserRegistered: race.isUserRegistered,
canRegister: race.canRegister,
});
setModalOpen(true);
}
};
const handleCloseModal = () => {
setModalOpen(false);
setSelectedRace(null);
};
const handleRegister = async (raceId: string) => {
await onRegister(raceId);
setModalOpen(false);
};
const handleWithdraw = async (raceId: string) => {
await onWithdraw(raceId);
setModalOpen(false);
};
return (
<Box display="flex" flexDirection="col" gap={8}>
<Box as="header" display="flex" flexDirection="col" gap={2}>
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">Race Schedule</Text>
<Group gap={3} align="center">
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">
Race Schedule
</Text>
{viewData.isAdmin && onCreateRace && (
<Button
variant="primary"
size="sm"
onClick={onCreateRace}
icon={<Icon icon={Plus} size={3} />}
>
Add Race
</Button>
)}
</Group>
<Text size="sm" color="text-zinc-500">Upcoming and past events for this season.</Text>
</Box>
<LeagueSchedulePanel events={events} />
<EnhancedLeagueSchedulePanel
events={events}
leagueId={viewData.leagueId}
currentDriverId={viewData.currentDriverId}
isAdmin={viewData.isAdmin}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onEdit={onEdit}
onReschedule={onReschedule}
onRaceDetail={handleRaceDetail}
onResultsClick={onResultsClick}
/>
{selectedRace && (
<RaceDetailModal
race={selectedRace}
isOpen={modalOpen}
onClose={handleCloseModal}
onRegister={() => handleRegister(selectedRace.id)}
onWithdraw={() => handleWithdraw(selectedRace.id)}
onResultsClick={() => onResultsClick(selectedRace.id)}
/>
)}
</Box>
);
}

View File

@@ -1,19 +1,29 @@
'use client';
import { useState } from 'react';
import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable';
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Group } from '@/ui/Group';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
import { Trophy, Users, Calendar, Award } from 'lucide-react';
interface LeagueStandingsTemplateProps {
viewData: LeagueStandingsViewData;
loading?: boolean;
onToggleTeamChampionship?: () => void;
}
export function LeagueStandingsTemplate({
viewData,
loading = false,
onToggleTeamChampionship,
}: LeagueStandingsTemplateProps) {
const [showTeamStandings, setShowTeamStandings] = useState(false);
if (loading) {
return (
<Box display="flex" alignItems="center" justifyContent="center" py={24}>
@@ -31,21 +41,89 @@ export function LeagueStandingsTemplate({
driverName: driver?.name || 'Unknown Driver',
driverId: entry.driverId,
points: entry.totalPoints,
wins: 0, // Placeholder
podiums: 0, // Placeholder
wins: entry.wins,
podiums: entry.podiums,
races: entry.racesStarted,
avgFinish: entry.avgFinish,
gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}`
gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}`,
positionChange: entry.positionChange,
lastRacePoints: entry.lastRacePoints,
droppedRaceIds: entry.droppedRaceIds,
};
});
// Calculate championship stats
const championshipStats = {
totalRaces: viewData.standings[0]?.racesStarted || 0,
totalDrivers: viewData.standings.length,
topWins: Math.max(...viewData.standings.map(s => s.wins)),
topPodiums: Math.max(...viewData.standings.map(s => s.podiums)),
};
return (
<Box display="flex" flexDirection="col" gap={8}>
<Box as="header" display="flex" flexDirection="col" gap={2}>
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">Championship Standings</Text>
<Group gap={3} align="center">
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">
Championship Standings
</Text>
{viewData.isTeamChampionship && onToggleTeamChampionship && (
<Button
variant="secondary"
size="sm"
onClick={() => {
setShowTeamStandings(!showTeamStandings);
onToggleTeamChampionship();
}}
icon={<Icon icon={Users} size={3} />}
>
{showTeamStandings ? 'Show Driver Standings' : 'Show Team Standings'}
</Button>
)}
</Group>
<Text size="sm" color="text-zinc-500">Official points classification for the current season.</Text>
</Box>
{/* Championship Stats */}
<Box display="flex" gap={4} flexWrap="wrap">
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
<Group gap={2} align="center">
<Icon icon={Trophy} size={4} color="text-primary-blue" />
<Box>
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">Total Races</Text>
<Text size="lg" weight="bold" color="text-white">{championshipStats.totalRaces}</Text>
</Box>
</Group>
</Surface>
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
<Group gap={2} align="center">
<Icon icon={Users} size={4} color="text-primary-blue" />
<Box>
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">Total Drivers</Text>
<Text size="lg" weight="bold" color="text-white">{championshipStats.totalDrivers}</Text>
</Box>
</Group>
</Surface>
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
<Group gap={2} align="center">
<Icon icon={Award} size={4} color="text-primary-blue" />
<Box>
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">Most Wins</Text>
<Text size="lg" weight="bold" color="text-white">{championshipStats.topWins}</Text>
</Box>
</Group>
</Surface>
<Surface border borderColor="border-outline-steel" p={4} flex={1} minWidth="200px">
<Group gap={2} align="center">
<Icon icon={Calendar} size={4} color="text-primary-blue" />
<Box>
<Text size="xs" color="text-zinc-500" uppercase letterSpacing="widest">Most Podiums</Text>
<Text size="lg" weight="bold" color="text-white">{championshipStats.topPodiums}</Text>
</Box>
</Group>
</Surface>
</Box>
<LeagueStandingsTable standings={standings} />
</Box>
);

View File

@@ -0,0 +1,144 @@
# Plan: League Pages Enhancement
## 1. Analysis of Current State
### Leagues Discovery Page (`/leagues`)
- **Current**: Basic grid of league cards with search and category filters.
- **Gaps**:
- No "Featured" or "Promoted" leagues section.
- Limited metadata in cards (missing next race info, active season status).
- No "Quick Join" or "Follow" actions directly from the list.
- Categories are hardcoded in the client.
### League Detail Page (`/leagues/[id]`)
- **Current**: Tabbed layout (Overview, Schedule, Standings, Roster, Rulebook).
- **Gaps**:
- **Overview**: Very static. Missing "Next Race" countdown, "Recent Results" snippet, and "Active Season" progress.
- **Schedule**: Simple list. Missing "Register" buttons for upcoming races, "View Results" for past races.
- **Standings**: Basic table. Missing "Trend" indicators (up/down positions), "Last Race" points.
- **Roster**: Simple list. Missing driver stats (rating, safety, performance).
- **Functionality**: No "Join League" flow visible in the current barebones implementation (though components exist).
## 2. Proposed Enhancements
### A. Leagues Discovery Page
- **Featured Section**: Add a top section for high-utilization or promoted infrastructure.
- **Enhanced Cards**: Include "Next Race" date and "Active Drivers" count.
- **Dynamic Categories**: Move category definitions to the API or a shared config.
### B. League Overview (The "Command Center")
- **Next Race Widget**: High-visibility countdown to the next scheduled event with a "Register" button.
- **Season Progress**: Visual bar showing how far along the current season is (e.g., "Race 4 of 12").
- **Recent Results Snippet**: Top 3 from the last completed race.
- **Activity Feed**: Integration of `LeagueActivityFeed` component to show recent joins, results, and announcements.
- **Wallet & Sponsorship Preview**: For admins, show a summary of league funds and active sponsorship slots using `LeagueSponsorshipsSection`.
- **Stewarding Quick-View**: Show pending protests count and a link to the stewarding queue.
### C. League Schedule
- **Interactive Timeline**: Group races by month or season.
- **Actionable Items**: "Register" for upcoming, "View Results" for completed.
- **Race Detail Modals**: Use `RaceDetailTemplate` or a modal to show track details, weather, and car classes.
- **Admin Controls**: Inline "Edit" or "Reschedule" buttons for authorized users.
### D. League Standings
- **Trend Indicators**: Show position changes since the last race.
- **Championship Stats**: Integrate `LeagueChampionshipStats` for wins, podiums, and average finish.
- **Team Standings**: Ensure team-based championships are togglable.
- **Drop Weeks Visualization**: Clearly mark which races are currently being dropped from a driver's total.
### E. League Roster
- **Driver Cards**: Use a more detailed card format showing GridPilot Rating and recent form.
- **Admin Actions**: Quick access to "Promote", "Remove", or "Message" for authorized users.
- **Join Requests**: Integrated `JoinRequestsPanel` for admins to manage pending applications.
### F. Rulebook & Governance
- **Structured Rules**: Use `LeagueRulesPanel` to display code of conduct and sporting regulations.
- **Governance Transparency**: Show the stewarding decision mode (Committee vs Single Steward) using `LeagueStewardingSection`.
## 3. UI/UX & Design Streamlining
- **Telemetry Aesthetic**: Align all league pages with the "Modern Precision" theme (Deep Graphite, Primary Blue, Performance Green).
- **Consistent Primitives**: Ensure all components use the established UI primitives (`Surface`, `Stack`, `Text`, `Icon`).
- **Responsive Density**: Maintain high data density on desktop while ensuring readability on mobile.
## 4. Data & API Requirements
### Core/API Additions
- **Next Race Info**: API should return the single next upcoming race for a league in the summary/detail DTO.
- **Season Progress**: Add `totalRaces` and `completedRaces` to the `LeagueSeasonSummaryDTO`.
- **Standings Trends**: Add `positionChange` (number) and `lastRacePoints` (number) to `LeagueStandingDTO`.
- **Activity Feed**: Ensure the `/leagues/[id]/activity` endpoint is fully functional.
### View Model Updates
- **LeagueSummaryViewModel**: Add `nextRaceAt`, `activeDriversCount`.
- **LeagueDetailViewData**: Add `nextRace`, `seasonProgress`, `recentResults`.
## 5. Proposed DTO Changes (Technical)
### `LeagueSeasonSummaryDTO`
```typescript
export class LeagueSeasonSummaryDTO {
// ... existing fields
@ApiProperty()
totalRaces!: number;
@ApiProperty()
completedRaces!: number;
@ApiProperty({ required: false })
nextRaceAt?: Date;
}
```
### `LeagueStandingDTO`
```typescript
export class LeagueStandingDTO {
// ... existing fields
@ApiProperty({ description: 'Position change since last race' })
positionChange!: number;
@ApiProperty({ description: 'Points earned in the last race' })
lastRacePoints!: number;
@ApiProperty({ type: [String], description: 'IDs of races that were dropped' })
droppedRaceIds!: string[];
}
```
### `LeagueSummaryDTO` (or `LeagueWithCapacityAndScoringDTO`)
```typescript
export class LeagueWithCapacityAndScoringDTO {
// ... existing fields
@ApiProperty({ required: false })
pendingJoinRequestsCount?: number;
@ApiProperty({ required: false })
pendingProtestsCount?: number;
@ApiProperty({ required: false })
walletBalance?: number;
}
```
## 6. Implementation Steps (Todo List)
- [ ] **Phase 1: Data Foundation**
- [ ] Update `LeagueDetailData` and `LeagueDetailViewData` interfaces.
- [ ] Enhance `LeagueDetailViewDataBuilder` to compute next race and season progress.
- [ ] (Optional) Add mock data to `LeagueService` for new fields if API isn't ready.
- [ ] **Phase 2: Overview Page Overhaul**
- [ ] Implement "Next Race" countdown widget.
- [ ] Add "Season Progress" component.
- [ ] Integrate `LeagueActivityFeed`.
- [ ] **Phase 3: Schedule & Standings Polish**
- [ ] Update `LeagueScheduleTemplate` with registration actions.
- [ ] Enhance `LeagueStandingsTemplate` with trend indicators and stats.
- [ ] **Phase 4: Discovery Page Refinement**
- [ ] Update `LeaguesTemplate` with a "Featured" section.
- [ ] Enhance `LeagueCard` with more metadata.
- [ ] **Phase 5: Final Streamlining**
- [ ] Audit all league pages for theme consistency.
- [ ] Ensure mobile responsiveness across all new widgets.