website refactor
This commit is contained in:
@@ -121,6 +121,21 @@ export class LeagueWithCapacityAndScoringDTO {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
logoUrl?: string | null;
|
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 {
|
export class AllLeaguesWithCapacityAndScoringDTO {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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 {
|
export class LeagueSeasonSummaryDTO {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@@ -34,4 +34,18 @@ export class LeagueSeasonSummaryDTO {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isParallelActive!: boolean;
|
isParallelActive!: boolean;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
totalRaces!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
completedRaces!: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false })
|
||||||
|
@IsOptional()
|
||||||
|
@IsDate()
|
||||||
|
@Type(() => Date)
|
||||||
|
nextRaceAt?: Date;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Type } from 'class-transformer';
|
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';
|
import { DriverDTO } from '../../driver/dtos/DriverDTO';
|
||||||
|
|
||||||
export class LeagueStandingDTO {
|
export class LeagueStandingDTO {
|
||||||
@@ -32,4 +32,17 @@ export class LeagueStandingDTO {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@IsNumber()
|
@IsNumber()
|
||||||
races!: number;
|
races!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
positionChange!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsNumber()
|
||||||
|
lastRacePoints!: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
droppedRaceIds!: string[];
|
||||||
}
|
}
|
||||||
@@ -87,6 +87,10 @@ export class AllLeaguesWithCapacityAndScoringPresenter
|
|||||||
: {}),
|
: {}),
|
||||||
...(timingSummary ? { timingSummary } : {}),
|
...(timingSummary ? { timingSummary } : {}),
|
||||||
...(logoUrl !== undefined ? { logoUrl } : {}),
|
...(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),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -22,6 +22,54 @@ export class GetLeagueSeasonsPresenter implements Presenter<GetLeagueSeasonsResu
|
|||||||
dto.isPrimary = seasonSummary.isPrimary;
|
dto.isPrimary = seasonSummary.isPrimary;
|
||||||
dto.isParallelActive = seasonSummary.isParallelActive;
|
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;
|
return dto;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export class LeagueStandingsPresenter implements Presenter<GetLeagueStandingsRes
|
|||||||
wins: 0,
|
wins: 0,
|
||||||
podiums: 0,
|
podiums: 0,
|
||||||
races: 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 };
|
this.result = { standings };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { ErrorBanner } from '@/ui/ErrorBanner';
|
|||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
|
import { MetadataHelper } from '@/lib/seo/MetadataHelper';
|
||||||
import { JsonLd } from '@/ui/JsonLd';
|
import { JsonLd } from '@/ui/JsonLd';
|
||||||
|
import { SessionGateway } from '@/lib/gateways/SessionGateway';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
@@ -35,6 +36,12 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|||||||
|
|
||||||
export default async function Page({ params }: Props) {
|
export default async function Page({ params }: Props) {
|
||||||
const { id } = await params;
|
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
|
// Execute the PageQuery
|
||||||
const result = await LeagueDetailPageQuery.execute(id);
|
const result = await LeagueDetailPageQuery.execute(id);
|
||||||
|
|
||||||
@@ -63,6 +70,12 @@ export default async function Page({ params }: Props) {
|
|||||||
const data = result.unwrap();
|
const data = result.unwrap();
|
||||||
const league = data.league;
|
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
|
// Build ViewData using the builder
|
||||||
const viewData = LeagueDetailViewDataBuilder.build({
|
const viewData = LeagueDetailViewDataBuilder.build({
|
||||||
league: data.league,
|
league: data.league,
|
||||||
@@ -84,7 +97,7 @@ export default async function Page({ params }: Props) {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<JsonLd data={jsonLd} />
|
<JsonLd data={jsonLd} />
|
||||||
<LeagueOverviewTemplate viewData={viewData} />
|
<LeagueOverviewTemplate viewData={viewData} isOwnerOrAdmin={isOwnerOrAdmin} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,29 @@ export default async function LeagueSchedulePage({ params }: Props) {
|
|||||||
notFound();
|
notFound();
|
||||||
}
|
}
|
||||||
// For serverError, show the template with empty data
|
// For serverError, show the template with empty data
|
||||||
return <LeagueScheduleTemplate viewData={{
|
return <LeagueScheduleTemplate
|
||||||
leagueId,
|
viewData={{
|
||||||
races: [],
|
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={() => {}}
|
||||||
|
/>;
|
||||||
}
|
}
|
||||||
182
apps/website/components/leagues/AdminQuickViewWidgets.tsx
Normal file
182
apps/website/components/leagues/AdminQuickViewWidgets.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
283
apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx
Normal file
283
apps/website/components/leagues/EnhancedLeagueSchedulePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,10 @@ import { useRouter } from 'next/navigation';
|
|||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Text } from '@/ui/Text';
|
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 {
|
interface StandingEntry {
|
||||||
position: number;
|
position: number;
|
||||||
@@ -19,6 +23,9 @@ interface StandingEntry {
|
|||||||
races: number;
|
races: number;
|
||||||
avgFinish: number | null;
|
avgFinish: number | null;
|
||||||
gap: string;
|
gap: string;
|
||||||
|
positionChange: number;
|
||||||
|
lastRacePoints: number;
|
||||||
|
droppedRaceIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LeagueStandingsTableProps {
|
interface LeagueStandingsTableProps {
|
||||||
@@ -44,6 +51,7 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
|||||||
key={entry.driverId || entry.driverName}
|
key={entry.driverId || entry.driverName}
|
||||||
id={entry.driverId || ''}
|
id={entry.driverId || ''}
|
||||||
rank={entry.position}
|
rank={entry.position}
|
||||||
|
rankDelta={entry.positionChange}
|
||||||
name={entry.driverName}
|
name={entry.driverName}
|
||||||
avatarUrl="" // Not provided in StandingEntry
|
avatarUrl="" // Not provided in StandingEntry
|
||||||
nationality="INT"
|
nationality="INT"
|
||||||
|
|||||||
185
apps/website/components/leagues/NextRaceCountdownWidget.tsx
Normal file
185
apps/website/components/leagues/NextRaceCountdownWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
254
apps/website/components/leagues/RaceDetailModal.tsx
Normal file
254
apps/website/components/leagues/RaceDetailModal.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
apps/website/components/leagues/SeasonProgressWidget.tsx
Normal file
91
apps/website/components/leagues/SeasonProgressWidget.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershi
|
|||||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||||
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
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
|
* LeagueDetailViewDataBuilder
|
||||||
@@ -138,6 +138,45 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
profileUrl: `/drivers/${m.driverId}`,
|
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 {
|
return {
|
||||||
leagueId: league.id,
|
leagueId: league.id,
|
||||||
name: league.name,
|
name: league.name,
|
||||||
@@ -151,6 +190,12 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
stewardSummaries,
|
stewardSummaries,
|
||||||
memberSummaries,
|
memberSummaries,
|
||||||
sponsorInsights: null, // Only for sponsor mode
|
sponsorInsights: null, // Only for sponsor mode
|
||||||
|
nextRace,
|
||||||
|
seasonProgress,
|
||||||
|
recentResults,
|
||||||
|
walletBalance: league.walletBalance,
|
||||||
|
pendingProtestsCount: league.pendingProtestsCount,
|
||||||
|
pendingJoinRequestsCount: league.pendingJoinRequestsCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,7 +2,7 @@ import { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleVi
|
|||||||
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
||||||
|
|
||||||
export class LeagueScheduleViewDataBuilder {
|
export class LeagueScheduleViewDataBuilder {
|
||||||
static build(apiDto: LeagueScheduleApiDto): LeagueScheduleViewData {
|
static build(apiDto: LeagueScheduleApiDto, currentDriverId?: string, isAdmin: boolean = false): LeagueScheduleViewData {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -22,8 +22,16 @@ export class LeagueScheduleViewDataBuilder {
|
|||||||
isPast,
|
isPast,
|
||||||
isUpcoming,
|
isUpcoming,
|
||||||
status: isPast ? 'completed' : 'scheduled',
|
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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,8 @@ export class LeagueStandingsViewDataBuilder {
|
|||||||
static build(
|
static build(
|
||||||
standingsDto: LeagueStandingsApiDto,
|
standingsDto: LeagueStandingsApiDto,
|
||||||
membershipsDto: LeagueMembershipsApiDto,
|
membershipsDto: LeagueMembershipsApiDto,
|
||||||
leagueId: string
|
leagueId: string,
|
||||||
|
isTeamChampionship: boolean = false
|
||||||
): LeagueStandingsViewData {
|
): LeagueStandingsViewData {
|
||||||
const standings = standingsDto.standings || [];
|
const standings = standingsDto.standings || [];
|
||||||
const members = membershipsDto.members || [];
|
const members = membershipsDto.members || [];
|
||||||
@@ -35,6 +36,12 @@ export class LeagueStandingsViewDataBuilder {
|
|||||||
avgFinish: null, // Not in DTO
|
avgFinish: null, // Not in DTO
|
||||||
penaltyPoints: 0, // Not in DTO
|
penaltyPoints: 0, // Not in DTO
|
||||||
bonusPoints: 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
|
// Extract unique drivers from standings
|
||||||
@@ -70,6 +77,7 @@ export class LeagueStandingsViewDataBuilder {
|
|||||||
leagueId,
|
leagueId,
|
||||||
currentDriverId: null, // Would need to get from auth
|
currentDriverId: null, // Would need to get from auth
|
||||||
isAdmin: false, // Would need to check permissions
|
isAdmin: false, // Would need to check permissions
|
||||||
|
isTeamChampionship: isTeamChampionship,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
import { DriversApiClient } from "@/lib/api/drivers/DriversApiClient";
|
||||||
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
import { LeaguesApiClient } from "@/lib/api/leagues/LeaguesApiClient";
|
||||||
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
|
import { RacesApiClient } from "@/lib/api/races/RacesApiClient";
|
||||||
import { SponsorsApiClient } from "@/lib/api/sponsors/SponsorsApiClient";
|
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
import { isProductionEnvironment } from '@/lib/config/env';
|
import { isProductionEnvironment } from '@/lib/config/env';
|
||||||
import { Result } from '@/lib/contracts/Result';
|
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 type { MembershipRole } from "@/lib/types/MembershipRole";
|
||||||
import { injectable, unmanaged } from 'inversify';
|
import { injectable, unmanaged } from 'inversify';
|
||||||
|
|
||||||
|
// TODO these data interfaces violate our architecture, see VIEW_DATA
|
||||||
export interface LeagueScheduleAdminData {
|
export interface LeagueScheduleAdminData {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
seasonId: string;
|
seasonId: string;
|
||||||
@@ -61,7 +61,6 @@ export class LeagueService implements Service {
|
|||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private apiClient: LeaguesApiClient;
|
private apiClient: LeaguesApiClient;
|
||||||
private driversApiClient: DriversApiClient;
|
private driversApiClient: DriversApiClient;
|
||||||
private sponsorsApiClient: SponsorsApiClient;
|
|
||||||
private racesApiClient: RacesApiClient;
|
private racesApiClient: RacesApiClient;
|
||||||
|
|
||||||
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
||||||
@@ -81,7 +80,6 @@ export class LeagueService implements Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||||
this.sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
|
|
||||||
this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ export class LeagueStandingsService implements Service {
|
|||||||
wins: s.wins,
|
wins: s.wins,
|
||||||
podiums: s.podiums,
|
podiums: s.podiums,
|
||||||
races: s.races,
|
races: s.races,
|
||||||
|
positionChange: s.positionChange,
|
||||||
|
lastRacePoints: s.lastRacePoints,
|
||||||
|
droppedRaceIds: s.droppedRaceIds,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ export interface LeagueSeasonSummaryDTO {
|
|||||||
endDate?: string;
|
endDate?: string;
|
||||||
isPrimary: boolean;
|
isPrimary: boolean;
|
||||||
isParallelActive: boolean;
|
isParallelActive: boolean;
|
||||||
|
totalRaces: number;
|
||||||
|
completedRaces: number;
|
||||||
|
/** Format: date-time */
|
||||||
|
nextRaceAt?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,4 +15,7 @@ export interface LeagueStandingDTO {
|
|||||||
wins: number;
|
wins: number;
|
||||||
podiums: number;
|
podiums: number;
|
||||||
races: number;
|
races: number;
|
||||||
|
positionChange: number;
|
||||||
|
lastRacePoints: number;
|
||||||
|
droppedRaceIds: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,4 +22,7 @@ export interface LeagueWithCapacityAndScoringDTO {
|
|||||||
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
|
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
|
||||||
timingSummary?: string;
|
timingSummary?: string;
|
||||||
logoUrl?: string;
|
logoUrl?: string;
|
||||||
|
pendingJoinRequestsCount?: number;
|
||||||
|
pendingProtestsCount?: number;
|
||||||
|
walletBalance?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,28 @@ export interface SponsorshipSlot {
|
|||||||
benefits: string[];
|
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 {
|
export interface LeagueDetailViewData extends ViewData {
|
||||||
// Basic info
|
// Basic info
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
@@ -104,4 +126,14 @@ export interface LeagueDetailViewData extends ViewData {
|
|||||||
metrics: SponsorMetric[];
|
metrics: SponsorMetric[];
|
||||||
slots: SponsorshipSlot[];
|
slots: SponsorshipSlot[];
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
// New fields for enhanced league pages
|
||||||
|
nextRace?: NextRaceInfo;
|
||||||
|
seasonProgress?: SeasonProgress;
|
||||||
|
recentResults?: RecentResult[];
|
||||||
|
|
||||||
|
// Admin fields
|
||||||
|
walletBalance?: number;
|
||||||
|
pendingProtestsCount?: number;
|
||||||
|
pendingJoinRequestsCount?: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ export interface StandingEntryData {
|
|||||||
penaltyPoints: number;
|
penaltyPoints: number;
|
||||||
bonusPoints: number;
|
bonusPoints: number;
|
||||||
teamName?: string;
|
teamName?: string;
|
||||||
|
// New fields from Phase 3
|
||||||
|
positionChange: number;
|
||||||
|
lastRacePoints: number;
|
||||||
|
droppedRaceIds: string[];
|
||||||
|
wins: number;
|
||||||
|
podiums: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DriverData {
|
export interface DriverData {
|
||||||
@@ -39,4 +45,6 @@ export interface LeagueStandingsViewData {
|
|||||||
leagueId: string;
|
leagueId: string;
|
||||||
currentDriverId: string | null;
|
currentDriverId: string | null;
|
||||||
isAdmin: boolean;
|
isAdmin: boolean;
|
||||||
|
// New fields for team standings toggle
|
||||||
|
isTeamChampionship?: boolean;
|
||||||
}
|
}
|
||||||
@@ -11,5 +11,14 @@ export interface LeagueScheduleViewData {
|
|||||||
isUpcoming: boolean;
|
isUpcoming: boolean;
|
||||||
status: 'scheduled' | 'completed';
|
status: 'scheduled' | 'completed';
|
||||||
strengthOfField?: number;
|
strengthOfField?: number;
|
||||||
|
// Registration info
|
||||||
|
isUserRegistered?: boolean;
|
||||||
|
canRegister?: boolean;
|
||||||
|
// Admin info
|
||||||
|
canEdit?: boolean;
|
||||||
|
canReschedule?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
// User permissions
|
||||||
|
currentDriverId?: string;
|
||||||
|
isAdmin: boolean;
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { AdminQuickViewWidgets } from '@/components/leagues/AdminQuickViewWidgets';
|
||||||
|
import { LeagueActivityFeed } from '@/components/leagues/LeagueActivityFeed';
|
||||||
import { LeagueLogo } from '@/components/leagues/LeagueLogo';
|
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 type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Stack } from '@/ui/Stack';
|
import { Stack } from '@/ui/Stack';
|
||||||
@@ -10,18 +14,19 @@ import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react';
|
|||||||
|
|
||||||
interface LeagueOverviewTemplateProps {
|
interface LeagueOverviewTemplateProps {
|
||||||
viewData: LeagueDetailViewData;
|
viewData: LeagueDetailViewData;
|
||||||
|
isOwnerOrAdmin: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) {
|
export function LeagueOverviewTemplate({ viewData, isOwnerOrAdmin }: LeagueOverviewTemplateProps) {
|
||||||
return (
|
return (
|
||||||
<Stack gap={8}>
|
<Stack gap={8}>
|
||||||
{/* Header with Logo */}
|
{/* Header with Logo */}
|
||||||
<Box display="flex" alignItems="center" gap={6} pb={8} borderBottom borderColor="zinc-800">
|
<Box display="flex" alignItems="center" gap={6} pb={8} borderBottom borderColor="zinc-800">
|
||||||
<LeagueLogo
|
<LeagueLogo
|
||||||
leagueId={viewData.leagueId}
|
leagueId={viewData.leagueId}
|
||||||
src={viewData.logoUrl}
|
src={viewData.logoUrl}
|
||||||
alt={viewData.name}
|
alt={viewData.name}
|
||||||
size={96}
|
size={96}
|
||||||
rounded="lg"
|
rounded="lg"
|
||||||
/>
|
/>
|
||||||
<Stack gap={2}>
|
<Stack gap={2}>
|
||||||
@@ -34,6 +39,41 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
|
|||||||
{/* Main Content */}
|
{/* Main Content */}
|
||||||
<Box responsiveColSpan={{ lg: 2 }}>
|
<Box responsiveColSpan={{ lg: 2 }}>
|
||||||
<Stack gap={8}>
|
<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}>
|
<Stack gap={4}>
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">About the League</Text>
|
<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">
|
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||||
@@ -43,6 +83,7 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
|
|||||||
</Box>
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{/* Quick Stats */}
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
|
<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}>
|
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
|
||||||
@@ -97,6 +138,20 @@ export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps
|
|||||||
{/* Sidebar */}
|
{/* Sidebar */}
|
||||||
<Box as="aside">
|
<Box as="aside">
|
||||||
<Stack gap={8}>
|
<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}>
|
<Stack gap={4}>
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Management</Text>
|
<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">
|
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||||
|
|||||||
@@ -1,35 +1,143 @@
|
|||||||
'use client';
|
'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 type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Text } from '@/ui/Text';
|
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';
|
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
|
||||||
|
|
||||||
interface LeagueScheduleTemplateProps {
|
interface LeagueScheduleTemplateProps {
|
||||||
viewData: LeagueScheduleViewData;
|
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 => ({
|
const events = viewData.races.map(race => ({
|
||||||
id: race.id,
|
id: race.id,
|
||||||
title: race.name || `Race ${race.id.substring(0, 4)}`,
|
name: race.name || `Race ${race.id.substring(0, 4)}`,
|
||||||
trackName: race.track || 'TBA',
|
track: race.track || 'TBA',
|
||||||
date: race.scheduledAt,
|
car: race.car,
|
||||||
time: DateDisplay.formatDateTime(race.scheduledAt),
|
sessionType: race.sessionType,
|
||||||
status: (race.status === 'completed' ? 'completed' : 'upcoming') as any,
|
scheduledAt: race.scheduledAt,
|
||||||
strengthOfField: race.strengthOfField
|
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 (
|
return (
|
||||||
<Box display="flex" flexDirection="col" gap={8}>
|
<Box display="flex" flexDirection="col" gap={8}>
|
||||||
<Box as="header" display="flex" flexDirection="col" gap={2}>
|
<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>
|
<Text size="sm" color="text-zinc-500">Upcoming and past events for this season.</Text>
|
||||||
</Box>
|
</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>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,29 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable';
|
import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable';
|
||||||
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
|
import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData';
|
||||||
import { Box } from '@/ui/Box';
|
import { Box } from '@/ui/Box';
|
||||||
import { Text } from '@/ui/Text';
|
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 {
|
interface LeagueStandingsTemplateProps {
|
||||||
viewData: LeagueStandingsViewData;
|
viewData: LeagueStandingsViewData;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
|
onToggleTeamChampionship?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function LeagueStandingsTemplate({
|
export function LeagueStandingsTemplate({
|
||||||
viewData,
|
viewData,
|
||||||
loading = false,
|
loading = false,
|
||||||
|
onToggleTeamChampionship,
|
||||||
}: LeagueStandingsTemplateProps) {
|
}: LeagueStandingsTemplateProps) {
|
||||||
|
const [showTeamStandings, setShowTeamStandings] = useState(false);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<Box display="flex" alignItems="center" justifyContent="center" py={24}>
|
<Box display="flex" alignItems="center" justifyContent="center" py={24}>
|
||||||
@@ -31,21 +41,89 @@ export function LeagueStandingsTemplate({
|
|||||||
driverName: driver?.name || 'Unknown Driver',
|
driverName: driver?.name || 'Unknown Driver',
|
||||||
driverId: entry.driverId,
|
driverId: entry.driverId,
|
||||||
points: entry.totalPoints,
|
points: entry.totalPoints,
|
||||||
wins: 0, // Placeholder
|
wins: entry.wins,
|
||||||
podiums: 0, // Placeholder
|
podiums: entry.podiums,
|
||||||
races: entry.racesStarted,
|
races: entry.racesStarted,
|
||||||
avgFinish: entry.avgFinish,
|
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 (
|
return (
|
||||||
<Box display="flex" flexDirection="col" gap={8}>
|
<Box display="flex" flexDirection="col" gap={8}>
|
||||||
<Box as="header" display="flex" flexDirection="col" gap={2}>
|
<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>
|
<Text size="sm" color="text-zinc-500">Official points classification for the current season.</Text>
|
||||||
</Box>
|
</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} />
|
<LeagueStandingsTable standings={standings} />
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
144
plans/league-pages-enhancement.md
Normal file
144
plans/league-pages-enhancement.md
Normal 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.
|
||||||
Reference in New Issue
Block a user