website refactor
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,11 +21,29 @@ export default async function LeagueSchedulePage({ params }: Props) {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <LeagueScheduleTemplate viewData={{
|
||||
leagueId,
|
||||
races: [],
|
||||
}} />;
|
||||
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={() => {}}
|
||||
/>;
|
||||
}
|
||||
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 { 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"
|
||||
|
||||
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 { 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
};
|
||||
|
||||
|
||||
@@ -15,4 +15,8 @@ export interface LeagueSeasonSummaryDTO {
|
||||
endDate?: string;
|
||||
isPrimary: boolean;
|
||||
isParallelActive: boolean;
|
||||
totalRaces: number;
|
||||
completedRaces: number;
|
||||
/** Format: date-time */
|
||||
nextRaceAt?: string;
|
||||
}
|
||||
|
||||
@@ -15,4 +15,7 @@ export interface LeagueStandingDTO {
|
||||
wins: number;
|
||||
podiums: number;
|
||||
races: number;
|
||||
positionChange: number;
|
||||
lastRacePoints: number;
|
||||
droppedRaceIds: string[];
|
||||
}
|
||||
|
||||
@@ -22,4 +22,7 @@ export interface LeagueWithCapacityAndScoringDTO {
|
||||
scoring?: LeagueCapacityAndScoringSummaryScoringDTO;
|
||||
timingSummary?: string;
|
||||
logoUrl?: string;
|
||||
pendingJoinRequestsCount?: number;
|
||||
pendingProtestsCount?: number;
|
||||
walletBalance?: number;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,18 +14,19 @@ 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 */}
|
||||
<Box display="flex" alignItems="center" gap={6} pb={8} borderBottom borderColor="zinc-800">
|
||||
<LeagueLogo
|
||||
leagueId={viewData.leagueId}
|
||||
src={viewData.logoUrl}
|
||||
alt={viewData.name}
|
||||
size={96}
|
||||
<LeagueLogo
|
||||
leagueId={viewData.leagueId}
|
||||
src={viewData.logoUrl}
|
||||
alt={viewData.name}
|
||||
size={96}
|
||||
rounded="lg"
|
||||
/>
|
||||
<Stack gap={2}>
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
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