website refactor
This commit is contained in:
@@ -2,6 +2,7 @@ import { notFound } from 'next/navigation';
|
|||||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||||
|
import { DriverService } from '@/lib/services/drivers/DriverService';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
|
|
||||||
export default async function LeagueLayout({
|
export default async function LeagueLayout({
|
||||||
@@ -14,7 +15,10 @@ export default async function LeagueLayout({
|
|||||||
const { id: leagueId } = await params;
|
const { id: leagueId } = await params;
|
||||||
|
|
||||||
// Execute PageQuery to get league data
|
// Execute PageQuery to get league data
|
||||||
const result = await LeagueDetailPageQuery.execute(leagueId);
|
const [result, currentDriver] = await Promise.all([
|
||||||
|
LeagueDetailPageQuery.execute(leagueId),
|
||||||
|
new DriverService().getCurrentDriver(),
|
||||||
|
]);
|
||||||
|
|
||||||
if (result.isErr()) {
|
if (result.isErr()) {
|
||||||
const error = result.getError();
|
const error = result.getError();
|
||||||
@@ -34,6 +38,7 @@ export default async function LeagueLayout({
|
|||||||
ownerSummary: null,
|
ownerSummary: null,
|
||||||
adminSummaries: [],
|
adminSummaries: [],
|
||||||
stewardSummaries: [],
|
stewardSummaries: [],
|
||||||
|
memberSummaries: [],
|
||||||
sponsorInsights: null
|
sponsorInsights: null
|
||||||
}}
|
}}
|
||||||
tabs={[]}
|
tabs={[]}
|
||||||
@@ -47,11 +52,11 @@ export default async function LeagueLayout({
|
|||||||
|
|
||||||
const viewData = LeagueDetailViewDataBuilder.build({
|
const viewData = LeagueDetailViewDataBuilder.build({
|
||||||
league: data.league,
|
league: data.league,
|
||||||
owner: null,
|
owner: data.owner,
|
||||||
scoringConfig: null,
|
scoringConfig: data.scoringConfig,
|
||||||
memberships: { members: [] },
|
memberships: data.memberships,
|
||||||
races: [],
|
races: data.races,
|
||||||
sponsors: [],
|
sponsors: data.sponsors,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Define tab configuration
|
// Define tab configuration
|
||||||
@@ -59,19 +64,23 @@ export default async function LeagueLayout({
|
|||||||
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
|
{ label: 'Overview', href: `/leagues/${leagueId}`, exact: true },
|
||||||
{ label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false },
|
{ label: 'Schedule', href: `/leagues/${leagueId}/schedule`, exact: false },
|
||||||
{ label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false },
|
{ label: 'Standings', href: `/leagues/${leagueId}/standings`, exact: false },
|
||||||
|
{ label: 'Roster', href: `/leagues/${leagueId}/roster`, exact: false },
|
||||||
{ label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false },
|
{ label: 'Rulebook', href: `/leagues/${leagueId}/rulebook`, exact: false },
|
||||||
];
|
];
|
||||||
|
|
||||||
const adminTabs = [
|
// Check if user is admin or owner
|
||||||
|
const isOwner = currentDriver && data.league.ownerId === currentDriver.id;
|
||||||
|
const isAdmin = currentDriver && data.memberships.members?.some(m => m.driverId === currentDriver.id && m.role === 'admin');
|
||||||
|
const hasAdminAccess = isOwner || isAdmin;
|
||||||
|
|
||||||
|
const adminTabs = hasAdminAccess ? [
|
||||||
{ label: 'Schedule Admin', href: `/leagues/${leagueId}/schedule/admin`, exact: false },
|
{ label: 'Schedule Admin', href: `/leagues/${leagueId}/schedule/admin`, exact: false },
|
||||||
{ label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false },
|
{ label: 'Sponsorships', href: `/leagues/${leagueId}/sponsorships`, exact: false },
|
||||||
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
|
{ label: 'Stewarding', href: `/leagues/${leagueId}/stewarding`, exact: false },
|
||||||
{ label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false },
|
{ label: 'Wallet', href: `/leagues/${leagueId}/wallet`, exact: false },
|
||||||
{ label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false },
|
{ label: 'Settings', href: `/leagues/${leagueId}/settings`, exact: false },
|
||||||
];
|
] : [];
|
||||||
|
|
||||||
// TODO: Admin check needs to be implemented properly
|
|
||||||
// For now, show admin tabs if user is logged in
|
|
||||||
const tabs = [...baseTabs, ...adminTabs];
|
const tabs = [...baseTabs, ...adminTabs];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -64,14 +64,13 @@ export default async function Page({ params }: Props) {
|
|||||||
const league = data.league;
|
const league = data.league;
|
||||||
|
|
||||||
// Build ViewData using the builder
|
// Build ViewData using the builder
|
||||||
// Note: This would need additional data (owner, scoring config, etc.) in real implementation
|
|
||||||
const viewData = LeagueDetailViewDataBuilder.build({
|
const viewData = LeagueDetailViewDataBuilder.build({
|
||||||
league: data.league,
|
league: data.league,
|
||||||
owner: null,
|
owner: data.owner,
|
||||||
scoringConfig: null,
|
scoringConfig: data.scoringConfig,
|
||||||
memberships: { members: [] },
|
memberships: data.memberships,
|
||||||
races: [],
|
races: data.races,
|
||||||
sponsors: [],
|
sponsors: data.sponsors,
|
||||||
});
|
});
|
||||||
|
|
||||||
const jsonLd = {
|
const jsonLd = {
|
||||||
|
|||||||
67
apps/website/app/leagues/[id]/roster/page.tsx
Normal file
67
apps/website/app/leagues/[id]/roster/page.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||||
|
import { notFound } from 'next/navigation';
|
||||||
|
import { Box } from '@/ui/Box';
|
||||||
|
import { Text } from '@/ui/Text';
|
||||||
|
import { Stack } from '@/ui/Stack';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
params: Promise<{ id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function LeagueRosterPage({ params }: Props) {
|
||||||
|
const { id: leagueId } = await params;
|
||||||
|
const result = await LeagueDetailPageQuery.execute(leagueId);
|
||||||
|
|
||||||
|
if (result.isErr()) {
|
||||||
|
notFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = result.unwrap();
|
||||||
|
const members = data.memberships.members || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack gap={8}>
|
||||||
|
<Box as="header" display="flex" flexDirection="col" gap={2}>
|
||||||
|
<Text as="h2" size="xl" weight="bold" color="text-white" uppercase letterSpacing="tight">League Roster</Text>
|
||||||
|
<Text size="sm" color="text-zinc-500">All drivers currently registered in this league.</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box border borderColor="zinc-800" bg="zinc-900/30" overflow="hidden">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800 bg-zinc-900/50">
|
||||||
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-zinc-500">Driver</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-zinc-500">Role</th>
|
||||||
|
<th className="px-6 py-4 text-xs font-bold uppercase tracking-widest text-zinc-500">Joined</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{members.map((member) => (
|
||||||
|
<tr key={member.driverId} className="border-b border-zinc-800/50 hover:bg-zinc-800/30 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Box display="flex" alignItems="center" gap={3}>
|
||||||
|
<Box w="8" h="8" bg="zinc-800" rounded="full" />
|
||||||
|
<Text weight="bold" color="text-white">{member.driver.name}</Text>
|
||||||
|
</Box>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Text size="xs" color="text-zinc-400" uppercase weight="bold">{member.role}</Text>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<Text size="sm" color="text-zinc-500">{new Date(member.joinedAt).toLocaleDateString()}</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={3} className="px-6 py-12 text-center">
|
||||||
|
<Text color="text-zinc-600" italic>No members found in this league.</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
import { Heading } from '@/ui/Heading';
|
|
||||||
import { Image } from '@/ui/Image';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { ReactNode } from 'react';
|
|
||||||
|
|
||||||
interface LeagueHeaderProps {
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
logoUrl: string;
|
|
||||||
sponsorContent?: ReactNode;
|
|
||||||
statusContent?: ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeagueHeader({
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
logoUrl,
|
|
||||||
sponsorContent,
|
|
||||||
statusContent,
|
|
||||||
}: LeagueHeaderProps) {
|
|
||||||
return (
|
|
||||||
<Stack mb={8}>
|
|
||||||
<Stack display="flex" alignItems="center" justifyContent="between" mb={6}>
|
|
||||||
<Stack direction="row" align="center" gap={4}>
|
|
||||||
<Stack h="16" w="16" rounded="xl" overflow="hidden" border style={{ borderColor: 'rgba(38, 38, 38, 0.8)', backgroundColor: '#1a1d23' }} shadow="lg">
|
|
||||||
<Image
|
|
||||||
src={logoUrl}
|
|
||||||
alt={`${name} logo`}
|
|
||||||
width={64}
|
|
||||||
height={64}
|
|
||||||
fullWidth
|
|
||||||
fullHeight
|
|
||||||
objectFit="cover"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
<Stack>
|
|
||||||
<Stack display="flex" alignItems="center" gap={3} mb={1}>
|
|
||||||
<Heading level={1}>
|
|
||||||
{name}
|
|
||||||
{sponsorContent && (
|
|
||||||
<Text color="text-gray-400" weight="normal" size="lg" ml={2}>
|
|
||||||
by {sponsorContent}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Heading>
|
|
||||||
{statusContent}
|
|
||||||
</Stack>
|
|
||||||
{description && (
|
|
||||||
<Text color="text-gray-400" size="sm" maxWidth="xl" block>
|
|
||||||
{description}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
|
||||||
import { Card } from '@/ui/Card';
|
|
||||||
import { Heading } from '@/ui/Heading';
|
|
||||||
import { Icon } from '@/ui/Icon';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
import { Activity, Timer, Trophy, Users, type LucideIcon } from 'lucide-react';
|
|
||||||
|
|
||||||
interface LeagueHeaderPanelProps {
|
|
||||||
viewData: LeagueDetailViewData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) {
|
|
||||||
return (
|
|
||||||
<Card variant="outline" p={6} position="relative" overflow="hidden" className="bg-graphite-black">
|
|
||||||
{/* Background Accent */}
|
|
||||||
<Stack
|
|
||||||
position="absolute"
|
|
||||||
top={0}
|
|
||||||
right={0}
|
|
||||||
w="300px"
|
|
||||||
h="full"
|
|
||||||
bg="bg-gradient-to-l from-primary-blue/5 to-transparent"
|
|
||||||
pointerEvents="none"
|
|
||||||
>{null}</Stack>
|
|
||||||
|
|
||||||
<Stack direction={{ base: 'col', md: 'row' }} justify="between" align="center" gap={6}>
|
|
||||||
<Stack gap={2}>
|
|
||||||
<Stack direction="row" align="center" gap={3}>
|
|
||||||
<Stack p={2} bg="bg-primary-blue/10" rounded="md" border borderColor="border-primary-blue/20">
|
|
||||||
<Icon icon={Trophy} size={6} color="text-primary-blue" />
|
|
||||||
</Stack>
|
|
||||||
<Heading level={1} letterSpacing="tight">
|
|
||||||
{viewData.name}
|
|
||||||
</Heading>
|
|
||||||
</Stack>
|
|
||||||
<Text color="text-gray-400" size="sm" maxWidth="42rem">
|
|
||||||
{viewData.description}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
|
|
||||||
<Stack direction="row" gap={8} wrap>
|
|
||||||
<StatItem
|
|
||||||
icon={Users}
|
|
||||||
label="Members"
|
|
||||||
value={viewData.info.membersCount.toString()}
|
|
||||||
color="text-primary-blue"
|
|
||||||
/>
|
|
||||||
<StatItem
|
|
||||||
icon={Timer}
|
|
||||||
label="Races"
|
|
||||||
value={viewData.info.racesCount.toString()}
|
|
||||||
color="text-neon-aqua"
|
|
||||||
/>
|
|
||||||
<StatItem
|
|
||||||
icon={Activity}
|
|
||||||
label="Avg SOF"
|
|
||||||
value={(viewData.info.avgSOF ?? 0).toString()}
|
|
||||||
color="text-performance-green"
|
|
||||||
/>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) {
|
|
||||||
return (
|
|
||||||
<Stack gap={1}>
|
|
||||||
<Stack direction="row" align="center" gap={1.5}>
|
|
||||||
<Icon icon={icon} size={3.5} color="text-gray-500" />
|
|
||||||
<Text size="xs" color="text-gray-500" weight="medium" letterSpacing="wider" block>
|
|
||||||
{label.toUpperCase()}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
<Text size="xl" weight="bold" color={color} lineHeight="none">
|
|
||||||
{value}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { LeagueLogo as UiLeagueLogo } from '@/components/leagues/LeagueLogo';
|
|
||||||
|
|
||||||
export interface LeagueLogoProps {
|
|
||||||
leagueId: string;
|
|
||||||
alt: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeagueLogo({ leagueId, alt }: LeagueLogoProps) {
|
|
||||||
return (
|
|
||||||
<UiLeagueLogo
|
|
||||||
leagueId={leagueId}
|
|
||||||
alt={alt}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Link } from '@/ui/Link';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
exact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeagueNavTabsProps {
|
|
||||||
tabs: Tab[];
|
|
||||||
currentPathname: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) {
|
|
||||||
return (
|
|
||||||
<Stack as="nav" borderBottom borderColor="zinc-800" mb={6}>
|
|
||||||
<Stack as="ul" direction="row" gap={8} overflow="auto" hideScrollbar>
|
|
||||||
{tabs.map((tab) => {
|
|
||||||
const isActive = tab.exact
|
|
||||||
? currentPathname === tab.href
|
|
||||||
: currentPathname.startsWith(tab.href);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Stack as="li" key={tab.href} position="relative">
|
|
||||||
<Link
|
|
||||||
href={tab.href}
|
|
||||||
variant="ghost"
|
|
||||||
pb={4}
|
|
||||||
display="block"
|
|
||||||
size="sm"
|
|
||||||
weight="medium"
|
|
||||||
color={isActive ? 'text-blue-500' : 'text-zinc-400'}
|
|
||||||
hoverTextColor={isActive ? 'text-blue-500' : 'text-zinc-200'}
|
|
||||||
transition
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Link>
|
|
||||||
{isActive && (
|
|
||||||
<Stack
|
|
||||||
position="absolute"
|
|
||||||
bottom="0"
|
|
||||||
left="0"
|
|
||||||
right="0"
|
|
||||||
h="0.5"
|
|
||||||
bg="bg-blue-500"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -12,6 +12,7 @@ interface RaceEvent {
|
|||||||
date: string;
|
date: string;
|
||||||
time: string;
|
time: string;
|
||||||
status: 'upcoming' | 'live' | 'completed';
|
status: 'upcoming' | 'live' | 'completed';
|
||||||
|
strengthOfField?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface LeagueSchedulePanelProps {
|
interface LeagueSchedulePanelProps {
|
||||||
@@ -56,6 +57,12 @@ export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) {
|
|||||||
<Stack color="text-zinc-600"><Clock size={14} /></Stack>
|
<Stack color="text-zinc-600"><Clock size={14} /></Stack>
|
||||||
<Text size="sm" color="text-zinc-400">{event.time}</Text>
|
<Text size="sm" color="text-zinc-400">{event.time}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
{event.strengthOfField && (
|
||||||
|
<Stack display="flex" alignItems="center" gap={1.5}>
|
||||||
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase>SOF</Text>
|
||||||
|
<Text size="sm" color="text-zinc-300" font="mono">{event.strengthOfField}</Text>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ interface StandingEntry {
|
|||||||
points: number;
|
points: number;
|
||||||
wins: number;
|
wins: number;
|
||||||
podiums: number;
|
podiums: number;
|
||||||
|
races: number;
|
||||||
|
avgFinish: number | null;
|
||||||
gap: string;
|
gap: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,10 +36,10 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
|||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Team</Text>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableHeader className="text-center px-4 py-3">
|
<TableHeader className="text-center px-4 py-3">
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Wins</Text>
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Races</Text>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableHeader className="text-center px-4 py-3">
|
<TableHeader className="text-center px-4 py-3">
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Podiums</Text>
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Avg</Text>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableHeader className="text-right px-4 py-3">
|
<TableHeader className="text-right px-4 py-3">
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Points</Text>
|
||||||
@@ -60,10 +62,10 @@ export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) {
|
|||||||
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
|
<Text size="sm" color="text-zinc-500">{entry.teamName || '—'}</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center px-4 py-3">
|
<TableCell className="text-center px-4 py-3">
|
||||||
<Text size="sm" color="text-zinc-400">{entry.wins}</Text>
|
<Text size="sm" color="text-zinc-400">{entry.races}</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-center px-4 py-3">
|
<TableCell className="text-center px-4 py-3">
|
||||||
<Text size="sm" color="text-zinc-400">{entry.podiums}</Text>
|
<Text size="sm" color="text-zinc-400">{entry.avgFinish?.toFixed(1) || '—'}</Text>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right px-4 py-3">
|
<TableCell className="text-right px-4 py-3">
|
||||||
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
|
<Text size="sm" weight="bold" color="text-white">{entry.points}</Text>
|
||||||
|
|||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Link } from '@/ui/Link';
|
|
||||||
import { Stack } from '@/ui/Stack';
|
|
||||||
import { Text } from '@/ui/Text';
|
|
||||||
|
|
||||||
interface Tab {
|
|
||||||
label: string;
|
|
||||||
href: string;
|
|
||||||
exact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeagueTabsProps {
|
|
||||||
tabs: Tab[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LeagueTabs({ tabs }: LeagueTabsProps) {
|
|
||||||
return (
|
|
||||||
<Stack borderBottom borderColor="border-charcoal-outline">
|
|
||||||
<Stack direction="row" gap={6} overflow="auto">
|
|
||||||
{tabs.map((tab) => (
|
|
||||||
<Link
|
|
||||||
key={tab.href}
|
|
||||||
href={tab.href}
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<Stack pb={3} px={1}>
|
|
||||||
<Text weight="medium"
|
|
||||||
// eslint-disable-next-line gridpilot-rules/component-classification
|
|
||||||
className="whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{tab.label}
|
|
||||||
</Text>
|
|
||||||
</Stack>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -35,9 +35,12 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
|
|
||||||
// Calculate info data
|
// Calculate info data
|
||||||
const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0;
|
const membersCount = Array.isArray(memberships.members) ? memberships.members.length : 0;
|
||||||
const completedRacesCount = races.filter(r => r.name.includes('Completed')).length; // Placeholder
|
const completedRacesCount = races.filter(r => (r as any).status === 'completed').length;
|
||||||
const avgSOF = races.length > 0
|
|
||||||
? Math.round(races.reduce((sum, _r) => sum + 0, 0) / races.length)
|
// Compute real avgSOF from races
|
||||||
|
const racesWithSOF = races.filter(r => typeof (r as any).strengthOfField === 'number' && (r as any).strengthOfField > 0);
|
||||||
|
const avgSOF = racesWithSOF.length > 0
|
||||||
|
? Math.round(racesWithSOF.reduce((sum, r) => sum + ((r as any).strengthOfField || 0), 0) / racesWithSOF.length)
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const info: LeagueInfoData = {
|
const info: LeagueInfoData = {
|
||||||
@@ -76,16 +79,58 @@ export class LeagueDetailViewDataBuilder {
|
|||||||
tagline: s.tagline,
|
tagline: s.tagline,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Convert memberships to summaries
|
||||||
|
const adminSummaries: DriverSummaryData[] = (memberships.members || [])
|
||||||
|
.filter(m => m.role === 'admin')
|
||||||
|
.map(m => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
driverName: m.driver.name,
|
||||||
|
avatarUrl: (m.driver as any).avatarUrl || null,
|
||||||
|
rating: null,
|
||||||
|
rank: null,
|
||||||
|
roleBadgeText: 'Admin',
|
||||||
|
roleBadgeClasses: 'bg-blue-500/10 text-blue-500 border-blue-500/30',
|
||||||
|
profileUrl: `/drivers/${m.driverId}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stewardSummaries: DriverSummaryData[] = (memberships.members || [])
|
||||||
|
.filter(m => m.role === 'steward')
|
||||||
|
.map(m => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
driverName: m.driver.name,
|
||||||
|
avatarUrl: (m.driver as any).avatarUrl || null,
|
||||||
|
rating: null,
|
||||||
|
rank: null,
|
||||||
|
roleBadgeText: 'Steward',
|
||||||
|
roleBadgeClasses: 'bg-purple-500/10 text-purple-500 border-purple-500/30',
|
||||||
|
profileUrl: `/drivers/${m.driverId}`,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const memberSummaries: DriverSummaryData[] = (memberships.members || [])
|
||||||
|
.filter(m => m.role === 'member')
|
||||||
|
.map(m => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
driverName: m.driver.name,
|
||||||
|
avatarUrl: (m.driver as any).avatarUrl || null,
|
||||||
|
rating: null,
|
||||||
|
rank: null,
|
||||||
|
roleBadgeText: 'Member',
|
||||||
|
roleBadgeClasses: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/30',
|
||||||
|
profileUrl: `/drivers/${m.driverId}`,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
leagueId: league.id,
|
leagueId: league.id,
|
||||||
name: league.name,
|
name: league.name,
|
||||||
description: league.description || '',
|
description: league.description || '',
|
||||||
|
logoUrl: league.logoUrl,
|
||||||
info,
|
info,
|
||||||
runningRaces,
|
runningRaces,
|
||||||
sponsors: sponsorInfo,
|
sponsors: sponsorInfo,
|
||||||
ownerSummary,
|
ownerSummary,
|
||||||
adminSummaries: [], // Would need additional data
|
adminSummaries,
|
||||||
stewardSummaries: [], // Would need additional data
|
stewardSummaries,
|
||||||
|
memberSummaries,
|
||||||
sponsorInsights: null, // Only for sponsor mode
|
sponsorInsights: null, // Only for sponsor mode
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,44 @@
|
|||||||
import { Result } from '@/lib/contracts/Result';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
||||||
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
import { RulebookApiDto } from '@/lib/types/tbd/RulebookApiDto';
|
||||||
|
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||||
|
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||||
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
|
|
||||||
export class LeagueRulebookService implements Service {
|
export class LeagueRulebookService implements Service {
|
||||||
|
private apiClient: LeaguesApiClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
this.apiClient = new LeaguesApiClient(
|
||||||
|
baseUrl,
|
||||||
|
new ConsoleErrorReporter(),
|
||||||
|
new ConsoleLogger()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getRulebookData(leagueId: string): Promise<Result<RulebookApiDto, DomainError>> {
|
async getRulebookData(leagueId: string): Promise<Result<RulebookApiDto, DomainError>> {
|
||||||
// Mock data since backend not implemented
|
try {
|
||||||
const mockData: RulebookApiDto = {
|
const config = await this.apiClient.getLeagueConfig(leagueId);
|
||||||
leagueId,
|
|
||||||
scoringConfig: {
|
const mockData: RulebookApiDto = {
|
||||||
gameName: 'iRacing',
|
leagueId,
|
||||||
scoringPresetName: 'Custom Rules',
|
scoringConfig: {
|
||||||
championships: [
|
gameName: 'iRacing',
|
||||||
{
|
scoringPresetName: config.form?.scoring?.type || 'Standard',
|
||||||
type: 'driver',
|
championships: (config.form?.championships || []).map((c: any) => ({
|
||||||
|
type: c.type || 'driver',
|
||||||
sessionTypes: ['Race'],
|
sessionTypes: ['Race'],
|
||||||
pointsPreview: [
|
pointsPreview: [],
|
||||||
{ sessionType: 'Race', position: 1, points: 25 },
|
bonusSummary: [],
|
||||||
{ sessionType: 'Race', position: 2, points: 20 },
|
})),
|
||||||
{ sessionType: 'Race', position: 3, points: 16 },
|
dropPolicySummary: config.form?.dropPolicy?.strategy || 'All results count',
|
||||||
],
|
},
|
||||||
bonusSummary: ['Pole Position: +1', 'Fastest Lap: +1'],
|
};
|
||||||
}
|
return Result.ok(mockData);
|
||||||
],
|
} catch (error: unknown) {
|
||||||
dropPolicySummary: 'All results count',
|
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch rulebook' });
|
||||||
},
|
}
|
||||||
};
|
|
||||||
return Result.ok(mockData);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,45 @@
|
|||||||
import { Result } from '@/lib/contracts/Result';
|
import { Result } from '@/lib/contracts/Result';
|
||||||
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
import { Service, DomainError } from '@/lib/contracts/services/Service';
|
||||||
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
import { LeagueScheduleApiDto } from '@/lib/types/tbd/LeagueScheduleApiDto';
|
||||||
|
import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient';
|
||||||
|
import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter';
|
||||||
|
import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger';
|
||||||
|
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
||||||
|
|
||||||
export class LeagueScheduleService implements Service {
|
export class LeagueScheduleService implements Service {
|
||||||
|
private apiClient: LeaguesApiClient;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
this.apiClient = new LeaguesApiClient(
|
||||||
|
baseUrl,
|
||||||
|
new ConsoleErrorReporter(),
|
||||||
|
new ConsoleLogger()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async getScheduleData(leagueId: string): Promise<Result<LeagueScheduleApiDto, DomainError>> {
|
async getScheduleData(leagueId: string): Promise<Result<LeagueScheduleApiDto, DomainError>> {
|
||||||
// Mock data since backend not implemented
|
try {
|
||||||
const mockData: LeagueScheduleApiDto = {
|
const data = await this.apiClient.getSchedule(leagueId);
|
||||||
leagueId,
|
|
||||||
races: [
|
// Map LeagueScheduleDTO to LeagueScheduleApiDto
|
||||||
{
|
const apiDto: LeagueScheduleApiDto = {
|
||||||
id: 'race-1',
|
leagueId,
|
||||||
name: 'Round 1 - Monza',
|
races: data.races.map(race => ({
|
||||||
date: '2024-10-15T14:00:00Z',
|
id: race.id,
|
||||||
track: 'Monza Circuit',
|
name: race.name,
|
||||||
car: 'Ferrari SF90',
|
date: race.date,
|
||||||
sessionType: 'Race',
|
track: (race as any).track || 'TBA',
|
||||||
},
|
car: (race as any).car || 'TBA',
|
||||||
{
|
sessionType: (race as any).sessionType || 'Race',
|
||||||
id: 'race-2',
|
status: (race as any).status || 'scheduled',
|
||||||
name: 'Round 2 - Silverstone',
|
strengthOfField: (race as any).strengthOfField,
|
||||||
date: '2024-10-22T13:00:00Z',
|
})),
|
||||||
track: 'Silverstone Circuit',
|
};
|
||||||
car: 'Mercedes W10',
|
|
||||||
sessionType: 'Race',
|
return Result.ok(apiDto);
|
||||||
},
|
} catch (error: unknown) {
|
||||||
{
|
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch schedule' });
|
||||||
id: 'race-3',
|
}
|
||||||
name: 'Round 3 - Spa-Francorchamps',
|
|
||||||
date: '2024-10-29T12:00:00Z',
|
|
||||||
track: 'Circuit de Spa-Francorchamps',
|
|
||||||
car: 'Red Bull RB15',
|
|
||||||
sessionType: 'Race',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
return Result.ok(mockData);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import type { LeagueSeasonSchedulePublishOutputDTO } from '@/lib/types/generated
|
|||||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||||
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
import type { LeagueMembershipsDTO } from '@/lib/types/generated/LeagueMembershipsDTO';
|
||||||
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
|
import type { GetTeamDetailsOutputDTO } from '@/lib/types/generated/GetTeamDetailsOutputDTO';
|
||||||
|
import type { GetDriverOutputDTO } from '@/lib/types/generated/GetDriverOutputDTO';
|
||||||
|
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||||
|
import type { LeagueScoringConfigDTO } from '@/lib/types/generated/LeagueScoringConfigDTO';
|
||||||
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
|
||||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
|
||||||
@@ -45,7 +48,11 @@ export interface LeagueRosterAdminData {
|
|||||||
|
|
||||||
export interface LeagueDetailData {
|
export interface LeagueDetailData {
|
||||||
league: LeagueWithCapacityAndScoringDTO;
|
league: LeagueWithCapacityAndScoringDTO;
|
||||||
apiDto: AllLeaguesWithCapacityAndScoringDTO;
|
owner: GetDriverOutputDTO | null;
|
||||||
|
scoringConfig: LeagueScoringConfigDTO | null;
|
||||||
|
memberships: LeagueMembershipsDTO;
|
||||||
|
races: RaceDTO[];
|
||||||
|
sponsors: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,24 +65,28 @@ export interface LeagueDetailData {
|
|||||||
@injectable()
|
@injectable()
|
||||||
export class LeagueService implements Service {
|
export class LeagueService implements Service {
|
||||||
private apiClient: LeaguesApiClient;
|
private apiClient: LeaguesApiClient;
|
||||||
private driversApiClient?: DriversApiClient;
|
private driversApiClient: DriversApiClient;
|
||||||
private sponsorsApiClient?: SponsorsApiClient;
|
private sponsorsApiClient: SponsorsApiClient;
|
||||||
private racesApiClient?: RacesApiClient;
|
private racesApiClient: RacesApiClient;
|
||||||
|
|
||||||
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
constructor(@unmanaged() apiClient?: LeaguesApiClient) {
|
||||||
|
const baseUrl = getWebsiteApiBaseUrl();
|
||||||
|
const logger = new ConsoleLogger();
|
||||||
|
const errorReporter = new EnhancedErrorReporter(logger, {
|
||||||
|
showUserNotifications: false,
|
||||||
|
logToConsole: true,
|
||||||
|
reportToExternal: isProductionEnvironment(),
|
||||||
|
});
|
||||||
|
|
||||||
if (apiClient) {
|
if (apiClient) {
|
||||||
this.apiClient = apiClient;
|
this.apiClient = apiClient;
|
||||||
} else {
|
} else {
|
||||||
const baseUrl = getWebsiteApiBaseUrl();
|
|
||||||
const logger = new ConsoleLogger();
|
|
||||||
const errorReporter = new EnhancedErrorReporter(logger, {
|
|
||||||
showUserNotifications: false,
|
|
||||||
logToConsole: true,
|
|
||||||
reportToExternal: isProductionEnvironment(),
|
|
||||||
});
|
|
||||||
this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger);
|
||||||
}
|
}
|
||||||
// Optional clients can be initialized if needed
|
|
||||||
|
this.driversApiClient = new DriversApiClient(baseUrl, errorReporter, logger);
|
||||||
|
this.sponsorsApiClient = new SponsorsApiClient(baseUrl, errorReporter, logger);
|
||||||
|
this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getLeagueStandings(leagueId: string): Promise<any> {
|
async getLeagueStandings(leagueId: string): Promise<any> {
|
||||||
@@ -143,7 +154,11 @@ export class LeagueService implements Service {
|
|||||||
|
|
||||||
async getLeagueDetailData(leagueId: string): Promise<Result<LeagueDetailData, DomainError>> {
|
async getLeagueDetailData(leagueId: string): Promise<Result<LeagueDetailData, DomainError>> {
|
||||||
try {
|
try {
|
||||||
const apiDto = await this.apiClient.getAllWithCapacityAndScoring();
|
const [apiDto, memberships, racesResponse] = await Promise.all([
|
||||||
|
this.apiClient.getAllWithCapacityAndScoring(),
|
||||||
|
this.apiClient.getMemberships(leagueId),
|
||||||
|
this.apiClient.getRaces(leagueId),
|
||||||
|
]);
|
||||||
|
|
||||||
if (!apiDto || !apiDto.leagues) {
|
if (!apiDto || !apiDto.leagues) {
|
||||||
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
return Result.err({ type: 'notFound', message: 'Leagues not found' });
|
||||||
@@ -153,10 +168,40 @@ export class LeagueService implements Service {
|
|||||||
if (!league) {
|
if (!league) {
|
||||||
return Result.err({ type: 'notFound', message: 'League not found' });
|
return Result.err({ type: 'notFound', message: 'League not found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch owner if ownerId exists
|
||||||
|
let owner: GetDriverOutputDTO | null = null;
|
||||||
|
if (league.ownerId) {
|
||||||
|
owner = await this.driversApiClient.getDriver(league.ownerId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch scoring config if available
|
||||||
|
let scoringConfig: LeagueScoringConfigDTO | null = null;
|
||||||
|
try {
|
||||||
|
const config = await this.apiClient.getLeagueConfig(leagueId);
|
||||||
|
if (config.form?.scoring) {
|
||||||
|
// Map form scoring to LeagueScoringConfigDTO if possible, or just use partial
|
||||||
|
scoringConfig = {
|
||||||
|
leagueId,
|
||||||
|
seasonId: '', // Not available in this context
|
||||||
|
gameId: '',
|
||||||
|
gameName: '',
|
||||||
|
scoringPresetId: (config.form.scoring as any).presetId,
|
||||||
|
dropPolicySummary: '',
|
||||||
|
championships: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Failed to fetch league scoring config', e);
|
||||||
|
}
|
||||||
|
|
||||||
return Result.ok({
|
return Result.ok({
|
||||||
league,
|
league,
|
||||||
apiDto,
|
owner,
|
||||||
|
scoringConfig,
|
||||||
|
memberships,
|
||||||
|
races: racesResponse.races,
|
||||||
|
sponsors: [], // Sponsors integration can be added here
|
||||||
});
|
});
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
console.error('LeagueService.getLeagueDetailData failed:', error);
|
console.error('LeagueService.getLeagueDetailData failed:', error);
|
||||||
|
|||||||
@@ -18,72 +18,51 @@ export class LeagueStandingsService implements Service {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStandingsData(_: string): Promise<Result<{ standings: LeagueStandingsApiDto; memberships: LeagueMembershipsApiDto }, DomainError>> {
|
async getStandingsData(leagueId: string): Promise<Result<{ standings: LeagueStandingsApiDto; memberships: LeagueMembershipsApiDto }, DomainError>> {
|
||||||
// Mock data since backend may not be implemented
|
try {
|
||||||
const mockStandings: LeagueStandingsApiDto = {
|
const [standingsData, membershipsData] = await Promise.all([
|
||||||
standings: [
|
this.apiClient.getStandings(leagueId),
|
||||||
{
|
this.apiClient.getMemberships(leagueId),
|
||||||
driverId: 'driver1',
|
]);
|
||||||
driver: {
|
|
||||||
id: 'driver1',
|
|
||||||
name: 'John Doe',
|
|
||||||
iracingId: '12345',
|
|
||||||
country: 'US',
|
|
||||||
joinedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
points: 100,
|
|
||||||
position: 1,
|
|
||||||
wins: 2,
|
|
||||||
podiums: 3,
|
|
||||||
races: 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
driverId: 'driver2',
|
|
||||||
driver: {
|
|
||||||
id: 'driver2',
|
|
||||||
name: 'Jane Smith',
|
|
||||||
iracingId: '67890',
|
|
||||||
country: 'UK',
|
|
||||||
joinedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
points: 80,
|
|
||||||
position: 2,
|
|
||||||
wins: 1,
|
|
||||||
podiums: 2,
|
|
||||||
races: 5,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockMemberships: LeagueMembershipsApiDto = {
|
// Map LeagueStandingsDTO to LeagueStandingsApiDto
|
||||||
members: [
|
const standings: LeagueStandingsApiDto = {
|
||||||
{
|
standings: standingsData.standings.map(s => ({
|
||||||
driverId: 'driver1',
|
driverId: s.driverId,
|
||||||
driver: {
|
driver: {
|
||||||
id: 'driver1',
|
id: s.driver.id,
|
||||||
name: 'John Doe',
|
name: s.driver.name,
|
||||||
iracingId: '12345',
|
iracingId: s.driver.iracingId,
|
||||||
country: 'US',
|
country: s.driver.country,
|
||||||
joinedAt: new Date().toISOString(),
|
joinedAt: s.driver.joinedAt,
|
||||||
},
|
},
|
||||||
role: 'member',
|
points: s.points,
|
||||||
joinedAt: new Date().toISOString(),
|
position: s.position,
|
||||||
},
|
wins: s.wins,
|
||||||
{
|
podiums: s.podiums,
|
||||||
driverId: 'driver2',
|
races: s.races,
|
||||||
driver: {
|
})),
|
||||||
id: 'driver2',
|
};
|
||||||
name: 'Jane Smith',
|
|
||||||
iracingId: '67890',
|
|
||||||
country: 'UK',
|
|
||||||
joinedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
role: 'member',
|
|
||||||
joinedAt: new Date().toISOString(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
return Result.ok({ standings: mockStandings, memberships: mockMemberships });
|
// Map LeagueMembershipsDTO to LeagueMembershipsApiDto
|
||||||
|
const memberships: LeagueMembershipsApiDto = {
|
||||||
|
members: (membershipsData.members || []).map(m => ({
|
||||||
|
driverId: m.driverId,
|
||||||
|
driver: {
|
||||||
|
id: m.driver.id,
|
||||||
|
name: m.driver.name,
|
||||||
|
iracingId: m.driver.iracingId,
|
||||||
|
country: m.driver.country,
|
||||||
|
joinedAt: m.driver.joinedAt,
|
||||||
|
},
|
||||||
|
role: m.role as any,
|
||||||
|
joinedAt: m.joinedAt,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
|
||||||
|
return Result.ok({ standings, memberships });
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch standings' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export interface LeagueDetailViewData extends ViewData {
|
|||||||
leagueId: string;
|
leagueId: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
|
logoUrl?: string;
|
||||||
|
|
||||||
// Info card data
|
// Info card data
|
||||||
info: LeagueInfoData;
|
info: LeagueInfoData;
|
||||||
@@ -84,6 +85,7 @@ export interface LeagueDetailViewData extends ViewData {
|
|||||||
ownerSummary: DriverSummaryData | null;
|
ownerSummary: DriverSummaryData | null;
|
||||||
adminSummaries: DriverSummaryData[];
|
adminSummaries: DriverSummaryData[];
|
||||||
stewardSummaries: DriverSummaryData[];
|
stewardSummaries: DriverSummaryData[];
|
||||||
|
memberSummaries: DriverSummaryData[];
|
||||||
|
|
||||||
// Sponsor insights (for sponsor mode)
|
// Sponsor insights (for sponsor mode)
|
||||||
sponsorInsights: {
|
sponsorInsights: {
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export interface LeagueScheduleViewData {
|
|||||||
isPast: boolean;
|
isPast: boolean;
|
||||||
isUpcoming: boolean;
|
isUpcoming: boolean;
|
||||||
status: 'scheduled' | 'completed';
|
status: 'scheduled' | 'completed';
|
||||||
|
strengthOfField?: number;
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||||
import { routes } from '@/lib/routing/RouteConfig';
|
import { routes } from '@/lib/routing/RouteConfig';
|
||||||
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData';
|
||||||
@@ -13,6 +14,8 @@ import { ChevronRight } from 'lucide-react';
|
|||||||
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
import { TemplateProps } from '@/lib/contracts/components/ComponentContracts';
|
||||||
|
|
||||||
export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps<LeagueDetailViewData> & { children?: React.ReactNode, tabs?: any[] }) {
|
export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps<LeagueDetailViewData> & { children?: React.ReactNode, tabs?: any[] }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="lg">
|
<Container size="lg">
|
||||||
<Box paddingY={8}>
|
<Box paddingY={8}>
|
||||||
@@ -26,8 +29,43 @@ export function LeagueDetailTemplate({ viewData, children, tabs }: TemplateProps
|
|||||||
<Text size="sm" color="text-white">{viewData.name}</Text>
|
<Text size="sm" color="text-white">{viewData.name}</Text>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
{children}
|
|
||||||
{/* ... rest of the template ... */}
|
{/* Tabs */}
|
||||||
|
{tabs && tabs.length > 0 && (
|
||||||
|
<Box borderBottom borderColor="zinc-800">
|
||||||
|
<Box display="flex" gap={8}>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.exact
|
||||||
|
? pathname === tab.href
|
||||||
|
: pathname.startsWith(tab.href);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link key={tab.href} href={tab.href}>
|
||||||
|
<Box
|
||||||
|
pb={4}
|
||||||
|
borderBottom={isActive}
|
||||||
|
borderWidth={isActive ? 2 : 0}
|
||||||
|
borderColor={isActive ? "blue-500" : "transparent"}
|
||||||
|
>
|
||||||
|
<Text
|
||||||
|
size="sm"
|
||||||
|
weight={isActive ? "bold" : "medium"}
|
||||||
|
color={isActive ? "text-white" : "text-zinc-500"}
|
||||||
|
className="transition-colors hover:text-white"
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
{children}
|
||||||
|
</Box>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
|
import { LeagueLogo } from '@/components/leagues/LeagueLogo';
|
||||||
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';
|
||||||
import { Text } from '@/ui/Text';
|
import { Text } from '@/ui/Text';
|
||||||
|
import { Link } from '@/ui/Link';
|
||||||
import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react';
|
import { Calendar, Shield, Trophy, Users, type LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
interface LeagueOverviewTemplateProps {
|
interface LeagueOverviewTemplateProps {
|
||||||
@@ -12,86 +14,139 @@ interface LeagueOverviewTemplateProps {
|
|||||||
|
|
||||||
export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) {
|
export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) {
|
||||||
return (
|
return (
|
||||||
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={8}>
|
<Stack gap={8}>
|
||||||
{/* Main Content */}
|
{/* Header with Logo */}
|
||||||
<Box responsiveColSpan={{ lg: 2 }}>
|
<Box display="flex" alignItems="center" gap={6} pb={8} borderBottom borderColor="zinc-800">
|
||||||
<Stack gap={8}>
|
<LeagueLogo
|
||||||
<Stack gap={4}>
|
leagueId={viewData.leagueId}
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">About the League</Text>
|
src={viewData.logoUrl}
|
||||||
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
alt={viewData.name}
|
||||||
<Text color="text-zinc-300" leading="relaxed">
|
size={96}
|
||||||
{viewData.description || 'No description provided for this league.'}
|
rounded="lg"
|
||||||
</Text>
|
/>
|
||||||
</Box>
|
<Stack gap={2}>
|
||||||
</Stack>
|
<Text size="3xl" weight="bold" color="text-white">{viewData.name}</Text>
|
||||||
|
<Text color="text-zinc-400">{viewData.info.structure} • Created {new Date(viewData.info.createdAt).toLocaleDateString()}</Text>
|
||||||
<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}>
|
|
||||||
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} />
|
|
||||||
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} />
|
|
||||||
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} />
|
|
||||||
<StatCard icon={Shield} label="Structure" value={viewData.info.structure} />
|
|
||||||
</Box>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Sidebar */}
|
<Box display="grid" responsiveGridCols={{ base: 1, lg: 3 }} gap={8}>
|
||||||
<Box as="aside">
|
{/* Main Content */}
|
||||||
<Stack gap={8}>
|
<Box responsiveColSpan={{ lg: 2 }}>
|
||||||
<Stack gap={4}>
|
<Stack gap={8}>
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Management</Text>
|
<Stack gap={4}>
|
||||||
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">About the League</Text>
|
||||||
<Stack gap={4}>
|
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||||
{viewData.ownerSummary && (
|
<Text color="text-zinc-300" leading="relaxed">
|
||||||
<Box display="flex" alignItems="center" gap={3}>
|
{viewData.description || 'No description provided for this league.'}
|
||||||
<Box w="10" h="10" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-zinc-500">
|
</Text>
|
||||||
<Users size={20} />
|
</Box>
|
||||||
</Box>
|
</Stack>
|
||||||
<Stack gap={0}>
|
|
||||||
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Owner</Text>
|
<Stack gap={4}>
|
||||||
<Text size="sm" weight="bold" color="text-white">{viewData.ownerSummary.driverName}</Text>
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Quick Stats</Text>
|
||||||
</Stack>
|
<Box display="grid" responsiveGridCols={{ base: 2, md: 4 }} gap={4}>
|
||||||
</Box>
|
<StatCard icon={Users} label="Members" value={viewData.info.membersCount} />
|
||||||
)}
|
<StatCard icon={Calendar} label="Races" value={viewData.info.racesCount} />
|
||||||
<Stack gap={2}>
|
<StatCard icon={Trophy} label="Avg SOF" value={viewData.info.avgSOF || '—'} />
|
||||||
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Admins</Text>
|
<StatCard icon={Shield} label="Structure" value={viewData.info.structure} />
|
||||||
<Box display="flex" flexWrap="wrap" gap={2}>
|
</Box>
|
||||||
{viewData.adminSummaries.map(admin => (
|
</Stack>
|
||||||
<Box key={admin.driverId} px={2} py={1} bg="zinc-800" color="text-zinc-400" border borderColor="zinc-700">
|
|
||||||
<Text size="xs" weight="bold" uppercase fontSize="10px">{admin.driverName}</Text>
|
{/* Roster Preview */}
|
||||||
</Box>
|
<Stack gap={4}>
|
||||||
|
<Box display="flex" justifyContent="between" alignItems="center">
|
||||||
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Roster Preview</Text>
|
||||||
|
<Link href={`/leagues/${viewData.leagueId}/roster`}>
|
||||||
|
<Text size="xs" color="text-blue-500" weight="bold" uppercase>View All</Text>
|
||||||
|
</Link>
|
||||||
|
</Box>
|
||||||
|
<Box border borderColor="zinc-800" bg="zinc-900/30" overflow="hidden">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<tbody>
|
||||||
|
{viewData.adminSummaries.concat(viewData.stewardSummaries).concat(viewData.memberSummaries).slice(0, 5).map((member) => (
|
||||||
|
<tr key={member.driverId} className="border-b border-zinc-800/50">
|
||||||
|
<td className="px-6 py-3">
|
||||||
|
<Box display="flex" alignItems="center" gap={3}>
|
||||||
|
<Box w="6" h="6" bg="zinc-800" rounded="full" />
|
||||||
|
<Text size="sm" weight="bold" color="text-white">{member.driverName}</Text>
|
||||||
|
</Box>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-3 text-right">
|
||||||
|
<Text size="xs" color="text-zinc-500" uppercase weight="bold">{member.roleBadgeText}</Text>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
{viewData.adminSummaries.length === 0 && <Text size="xs" color="text-zinc-600" italic>No admins assigned</Text>}
|
{viewData.adminSummaries.length === 0 && viewData.stewardSummaries.length === 0 && viewData.memberSummaries.length === 0 && (
|
||||||
</Box>
|
<tr>
|
||||||
</Stack>
|
<td className="px-6 py-8 text-center">
|
||||||
</Stack>
|
<Text size="sm" color="text-zinc-600" italic>No members to display</Text>
|
||||||
</Box>
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
|
||||||
<Stack gap={4}>
|
{/* Sidebar */}
|
||||||
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Sponsors</Text>
|
<Box as="aside">
|
||||||
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
<Stack gap={8}>
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{viewData.sponsors.length > 0 ? (
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Management</Text>
|
||||||
viewData.sponsors.map(sponsor => (
|
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||||
<Box key={sponsor.id} display="flex" alignItems="center" gap={3}>
|
<Stack gap={4}>
|
||||||
<Box w="8" h="8" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-blue-500">
|
{viewData.ownerSummary && (
|
||||||
<Trophy size={16} />
|
<Box display="flex" alignItems="center" gap={3}>
|
||||||
|
<Box w="10" h="10" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-zinc-500">
|
||||||
|
<Users size={20} />
|
||||||
</Box>
|
</Box>
|
||||||
<Text size="sm" weight="bold" color="text-zinc-300">{sponsor.name}</Text>
|
<Stack gap={0}>
|
||||||
|
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Owner</Text>
|
||||||
|
<Text size="sm" weight="bold" color="text-white">{viewData.ownerSummary.driverName}</Text>
|
||||||
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
))
|
)}
|
||||||
) : (
|
<Stack gap={2}>
|
||||||
<Text size="xs" color="text-zinc-600" italic>No active sponsors</Text>
|
<Text size="xs" color="text-zinc-500" weight="bold" uppercase letterSpacing="0.05em">Admins</Text>
|
||||||
)}
|
<Box display="flex" flexWrap="wrap" gap={2}>
|
||||||
</Stack>
|
{viewData.adminSummaries.map(admin => (
|
||||||
</Box>
|
<Box key={admin.driverId} px={2} py={1} bg="zinc-800" color="text-zinc-400" border borderColor="zinc-700">
|
||||||
|
<Text size="xs" weight="bold" uppercase fontSize="10px">{admin.driverName}</Text>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
{viewData.adminSummaries.length === 0 && <Text size="xs" color="text-zinc-600" italic>No admins assigned</Text>}
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
|
|
||||||
|
<Stack gap={4}>
|
||||||
|
<Text size="xs" weight="bold" color="text-zinc-500" uppercase letterSpacing="widest">Sponsors</Text>
|
||||||
|
<Box p={6} border borderColor="zinc-800" bg="zinc-900/30">
|
||||||
|
<Stack gap={4}>
|
||||||
|
{viewData.sponsors.length > 0 ? (
|
||||||
|
viewData.sponsors.map(sponsor => (
|
||||||
|
<Box key={sponsor.id} display="flex" alignItems="center" gap={3}>
|
||||||
|
<Box w="8" h="8" bg="zinc-800" border borderColor="zinc-700" display="flex" alignItems="center" justifyContent="center" color="text-blue-500">
|
||||||
|
<Trophy size={16} />
|
||||||
|
</Box>
|
||||||
|
<Text size="sm" weight="bold" color="text-zinc-300">{sponsor.name}</Text>
|
||||||
|
</Box>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<Text size="xs" color="text-zinc-600" italic>No active sponsors</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,4 +162,4 @@ function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: strin
|
|||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,8 @@ export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps
|
|||||||
trackName: race.track || 'TBA',
|
trackName: race.track || 'TBA',
|
||||||
date: race.scheduledAt,
|
date: race.scheduledAt,
|
||||||
time: new Date(race.scheduledAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
time: new Date(race.scheduledAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }),
|
||||||
status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming'
|
status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming',
|
||||||
|
strengthOfField: race.strengthOfField
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -32,8 +32,10 @@ export function LeagueStandingsTemplate({
|
|||||||
position: entry.position,
|
position: entry.position,
|
||||||
driverName: driver?.name || 'Unknown Driver',
|
driverName: driver?.name || 'Unknown Driver',
|
||||||
points: entry.totalPoints,
|
points: entry.totalPoints,
|
||||||
wins: 0,
|
wins: 0, // Placeholder
|
||||||
podiums: 0,
|
podiums: 0, // Placeholder
|
||||||
|
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}`
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
74
plans/league-detail-restoration.md
Normal file
74
plans/league-detail-restoration.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# Detailed Restoration Plan for League Detail Pages
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
Restore `/leagues/:id` to full functionality per [`ALPHA_PLAN.md`](docs/ALPHA_PLAN.md:123):
|
||||||
|
- Logo display.
|
||||||
|
- Team members (roster).
|
||||||
|
- Races (schedule).
|
||||||
|
- SoF (avg Strength of Field).
|
||||||
|
- All tabs/subpages with data.
|
||||||
|
|
||||||
|
**Timeline**: Surgical fixes (data queries, templates, cleanup). Production-ready: Data from bootstrap adapters, no mocks.
|
||||||
|
|
||||||
|
## Current State (From Tools)
|
||||||
|
- Routes: Tabs via layout.tsx (Overview, Schedule, Standings, Rulebook + admin).
|
||||||
|
- page.tsx: [`LeagueDetailPageQuery`](apps/website/lib/page-queries/LeagueDetailPageQuery.ts:1) → LeagueService → partial DTO → hardcoded empty ViewData.
|
||||||
|
- Templates: Overview shows stats (0s), sidebar empty. Detail has breadcrumb/tabs.
|
||||||
|
- Subpages: schedule/standings exist (empty fallbacks), no roster/page.tsx.
|
||||||
|
|
||||||
|
## Target State
|
||||||
|
```
|
||||||
|
Data: Full relations from seeded adapters.
|
||||||
|
UI: Logo header, roster preview, races summary, SoF stat, populated tabs.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Mermaid Flow
|
||||||
|
```mermaid
|
||||||
|
graph LR
|
||||||
|
PQ[LeagueDetailPageQuery]
|
||||||
|
LS[LeagueService.getLeagueDetailData<br/>FIX: +includes(relations)]
|
||||||
|
VB[ViewDataBuilder<br/>FIX: real inputs, avgSOF]
|
||||||
|
LT[LeagueDetailTemplate<br/>Tabs active]
|
||||||
|
OV[OverviewTemplate<br/>Logo/stats/roster]
|
||||||
|
SP[Sub Queries/Templates]
|
||||||
|
|
||||||
|
Bootstrap[Adapters/Bootstrap Seed] -.-> LS
|
||||||
|
```
|
||||||
|
|
||||||
|
## Step-by-Step Execution (Todo)
|
||||||
|
1. **Data (1-3)**:
|
||||||
|
- Read/extend LeagueService: Repo.findById({include: ['memberships', 'races', 'sponsors', 'logoUrl']}).
|
||||||
|
- Bootstrap: Seed demo-league w/ logo/members/races.sof.
|
||||||
|
- VB: Pass query data, compute SOF avg.
|
||||||
|
|
||||||
|
2. **UI (4-8)**:
|
||||||
|
- Logo: Add to DTO/template header (img fallback icon).
|
||||||
|
- Roster preview in overview (members.slice(0,5)).
|
||||||
|
- Create roster/page.tsx (members table).
|
||||||
|
- schedule/page.tsx: Races list (date/track/sof/status).
|
||||||
|
- standings/page.tsx: Standings + per-race SOF.
|
||||||
|
|
||||||
|
3. **Tabs (9)**:
|
||||||
|
- DetailTemplate: usePathname for active tab.
|
||||||
|
|
||||||
|
4. **Cleanup (10-11)**:
|
||||||
|
- search_files unused exports.
|
||||||
|
- Delete.
|
||||||
|
|
||||||
|
5. **Verify (12-13)**:
|
||||||
|
- eslint/tsc/test.
|
||||||
|
- Manual demo-league test.
|
||||||
|
|
||||||
|
## Files to Edit (Clickable)
|
||||||
|
[`LeagueService`](lib/services/leagues/LeagueService.ts)
|
||||||
|
[`LeagueDetailPageQuery`](lib/page-queries/LeagueDetailPageQuery.ts)
|
||||||
|
[`LeagueDetailViewDataBuilder`](lib/builders/view-data/LeagueDetailViewDataBuilder.ts)
|
||||||
|
[`LeagueOverviewTemplate`](templates/LeagueOverviewTemplate.tsx)
|
||||||
|
[`roster/page.tsx`](app/leagues/[id]/roster/page.tsx) (new)
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
- Demo league: Logo shows, members>0, races list, SOF>0, tabs data.
|
||||||
|
- No empties/hardcodes.
|
||||||
|
- Tests pass.
|
||||||
|
|
||||||
|
Review/approve before code.
|
||||||
Reference in New Issue
Block a user