website refactor

This commit is contained in:
2026-01-16 01:00:03 +01:00
parent ce7be39155
commit a98e3e3166
286 changed files with 5522 additions and 5261 deletions

View File

@@ -319,7 +319,14 @@
"rules": {
"@next/next/no-img-element": "error",
"@typescript-eslint/no-explicit-any": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
"argsIgnorePattern": "^_",
"varsIgnorePattern": "^_",
"caughtErrorsIgnorePattern": "^_"
}
],
"boundaries/element-types": [
2,
{

View File

@@ -24,7 +24,7 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
}
return (
<Section variant="default" className="min-h-screen">
<Section variant="default" minHeight="100vh">
{children}
</Section>
);

View File

@@ -1,5 +1,5 @@
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
import { AdminDashboardWrapper } from '@/components/admin/AdminDashboardWrapper';
import { AdminDashboardWrapper } from './AdminDashboardWrapper';
import { ErrorBanner } from '@/ui/ErrorBanner';
export default async function AdminPage() {

View File

@@ -1,5 +1,5 @@
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
import { AdminUsersWrapper } from '@/components/admin/AdminUsersWrapper';
import { AdminUsersWrapper } from './AdminUsersWrapper';
import { ErrorBanner } from '@/ui/ErrorBanner';
export default async function AdminUsersPage() {

View File

@@ -38,7 +38,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
...prev.formState,
fields: {
...prev.formState.fields,
[name]: {
[name as keyof typeof prev.formState.fields]: {
...prev.formState.fields[name as keyof typeof prev.formState.fields],
value,
touched: true,
@@ -71,7 +71,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
...validationErrors.reduce((acc, error) => ({
...acc,
[error.field]: {
...prev.formState.fields[error.field],
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
error: error.message,
touched: true,
},
@@ -147,15 +147,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
return (
<ResetPasswordTemplate
// Spread the viewData properties
token={templateViewData.token}
returnTo={templateViewData.returnTo}
showSuccess={templateViewData.showSuccess}
successMessage={templateViewData.successMessage}
formState={templateViewData.formState}
isSubmitting={templateViewData.isSubmitting}
submitError={templateViewData.submitError}
// Add the additional props
viewData={templateViewData}
formActions={{
handleChange,
handleSubmit,
@@ -178,4 +170,4 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
}}
/>
);
}
}

View File

@@ -1,5 +1,5 @@
import { notFound, redirect } from 'next/navigation';
import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery';
import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery';
import { DashboardTemplate } from '@/templates/DashboardTemplate';
export default async function DashboardPage() {

View File

@@ -1,11 +1,17 @@
'use client';
import React from 'react';
import React, { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { DriversTemplate } from '@/templates/DriversTemplate';
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
interface DriversPageClientProps {
pageDto: DriverLeaderboardViewModel | null;
viewData: DriversViewData | null;
error?: string;
empty?: {
title: string;
@@ -17,34 +23,67 @@ interface DriversPageClientProps {
* DriversPageClient
*
* Client component that:
* 1. Passes ViewModel directly to Template
*
* No business logic, filtering, or sorting here.
* All data transformation happens in the PageQuery and ViewModelBuilder.
* 1. Manages search state
* 2. Filters drivers based on search
* 3. Passes ViewData to Template
*/
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
const [searchQuery, setSearchQuery] = useState('');
const router = useRouter();
const filteredDrivers = useMemo(() => {
if (!viewData) return [];
if (!searchQuery) return viewData.drivers;
const query = searchQuery.toLowerCase();
return viewData.drivers.filter(driver =>
driver.name.toLowerCase().includes(query) ||
driver.nationality.toLowerCase().includes(query)
);
}, [viewData, searchQuery]);
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleViewLeaderboard = () => {
router.push(routes.leaderboards.drivers);
};
// Handle error/empty states
if (error) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<div className="text-red-400 mb-4">Error loading drivers</div>
<p className="text-gray-400">Please try again later</p>
</div>
<Container size="lg" py={12}>
<Stack align="center" gap={4}>
<Text color="text-red-400">Error loading drivers</Text>
<Text color="text-gray-400">Please try again later</Text>
</Stack>
</Container>
);
}
if (!pageDto || pageDto.drivers.length === 0) {
if (!viewData || viewData.drivers.length === 0) {
if (empty) {
return (
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
<p className="text-gray-400">{empty.description}</p>
</div>
<Container size="lg" py={12}>
<Stack align="center" gap={2}>
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
<Text color="text-gray-400">{empty.description}</Text>
</Stack>
</Container>
);
}
return null;
}
// Pass ViewModel directly to template
return <DriversTemplate data={pageDto} />;
return (
<DriversTemplate
viewData={viewData}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
filteredDrivers={filteredDrivers}
onDriverClick={handleDriverClick}
onViewLeaderboard={handleViewLeaderboard}
/>
);
}

View File

@@ -3,10 +3,14 @@
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
import type { ProfileTab } from '@/ui/ProfileTabs';
import { Container } from '@/ui/Container';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
interface DriverProfilePageClientProps {
pageDto: DriverProfileViewModel | null;
viewData: DriverProfileViewData | null;
error?: string;
empty?: {
title: string;
@@ -19,16 +23,13 @@ interface DriverProfilePageClientProps {
*
* Client component that:
* 1. Handles UI state (tabs, friend requests)
* 2. Passes ViewModel directly to Template
*
* No business logic or data transformation here.
* All data transformation happens in the PageQuery and ViewModelBuilder.
* 2. Passes ViewData directly to Template
*/
export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) {
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
const router = useRouter();
// UI State (UI-only concerns)
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [activeTab, setActiveTab] = useState<ProfileTab>('overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Event handlers (UI-only concerns)
@@ -43,46 +44,38 @@ export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfile
// Handle error/empty states
if (error) {
return (
<div className="max-w-6xl mx-auto px-4 py-12 text-center">
<div className="text-red-400 mb-4">Error loading driver profile</div>
<p className="text-gray-400">Please try again later</p>
</div>
<Container size="lg" py={12}>
<Stack align="center" gap={4}>
<Text color="text-red-400">Error loading driver profile</Text>
<Text color="text-gray-400">Please try again later</Text>
</Stack>
</Container>
);
}
if (!pageDto || !pageDto.currentDriver) {
if (!viewData || !viewData.currentDriver) {
if (empty) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center py-12">
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
<p className="text-gray-400">{empty.description}</p>
</div>
</div>
<Container size="lg" py={12}>
<Stack align="center" gap={2}>
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
<Text color="text-gray-400">{empty.description}</Text>
</Stack>
</Container>
);
}
return null;
}
// Pass ViewModel directly to template
// Pass ViewData directly to template
return (
<DriverProfileTemplate
driverProfile={pageDto}
allTeamMemberships={pageDto.teamMemberships.map(m => ({
team: {
id: m.teamId,
name: m.teamName,
},
role: m.role,
joinedAt: new Date(m.joinedAt),
}))}
isLoading={false}
error={null}
viewData={viewData}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
onTabChange={setActiveTab}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { DriverProfilePageQuery } from '@/lib/page-queries/page-queries/DriverProfilePageQuery';
import { DriverProfilePageClient } from '@/components/drivers/DriverProfilePageClient';
import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery';
import { DriverProfilePageClient } from './DriverProfilePageClient';
export default async function DriverProfilePage({ params }: { params: { id: string } }) {
const result = await DriverProfilePageQuery.execute(params.id);
@@ -13,7 +13,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
}
return (
<DriverProfilePageClient
pageDto={null}
viewData={null}
error={error}
/>
);
@@ -22,7 +22,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
const viewData = result.unwrap();
return (
<DriverProfilePageClient
pageDto={viewData}
viewData={viewData}
/>
);
}

View File

@@ -1,7 +1,7 @@
import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery';
import { DriversPageClient } from '@/components/drivers/DriversPageClient';
import { DriversPageQuery } from '@/lib/page-queries/DriversPageQuery';
import { DriversPageClient } from './DriversPageClient';
export default async function Page() {
const result = await DriversPageQuery.execute();
@@ -13,7 +13,7 @@ export default async function Page() {
}
return (
<DriversPageClient
pageDto={null}
viewData={null}
error={error}
/>
);
@@ -22,7 +22,7 @@ export default async function Page() {
const viewData = result.unwrap();
return (
<DriversPageClient
pageDto={viewData}
viewData={viewData}
/>
);
}

View File

@@ -3,8 +3,8 @@
import React from 'react';
import { useRouter } from 'next/navigation';
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
interface LeaderboardsPageClientProps {
viewData: LeaderboardsViewData;
@@ -13,12 +13,12 @@ interface LeaderboardsPageClientProps {
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
const router = useRouter();
const handleDriverClick = (driverId: string) => {
router.push(routes.driver.detail(driverId));
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleTeamClick = (teamId: string) => {
router.push(routes.team.detail(teamId));
const handleTeamClick = (id: string) => {
router.push(routes.team.detail(id));
};
const handleNavigateToDrivers = () => {

View File

@@ -1,5 +1,5 @@
import { notFound, redirect } from 'next/navigation';
import { DriverRankingsPageQuery } from '@/lib/page-queries/page-queries/DriverRankingsPageQuery';
import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig';

View File

@@ -1,6 +1,6 @@
import { notFound, redirect } from 'next/navigation';
import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery';
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
import { LeaderboardsPageQuery } from '@/lib/page-queries/LeaderboardsPageQuery';
import { LeaderboardsPageClient } from './LeaderboardsPageClient';
import { routes } from '@/lib/routing/RouteConfig';
export default async function LeaderboardsPage() {
@@ -23,5 +23,5 @@ export default async function LeaderboardsPage() {
// Success
const viewData = result.unwrap();
return <LeaderboardsTemplate viewData={viewData} />;
return <LeaderboardsPageClient viewData={viewData} />;
}

View File

@@ -1,37 +1,42 @@
'use client';
import { useState, useRef, useCallback } from 'react';
import {
Trophy,
Users,
Globe,
Award,
Search,
Plus,
ChevronLeft,
ChevronRight,
Sparkles,
Flag,
Filter,
Flame,
Clock,
Target,
Timer,
} from 'lucide-react';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Input } from '@/ui/Input';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { PageHero } from '@/ui/PageHero';
import { routes } from '@/lib/routing/RouteConfig';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Heading } from '@/ui/Heading';
import { Icon as UIIcon } from '@/ui/Icon';
import { Input } from '@/ui/Input';
import { Link as UILink } from '@/ui/Link';
import { PageHero } from '@/ui/PageHero';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import {
Award,
ChevronLeft,
ChevronRight,
Clock,
Filter,
Flag,
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Timer,
Trophy,
Users,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useRef, useState } from 'react';
// ============================================================================
// TYPES
@@ -55,7 +60,7 @@ type CategoryId =
interface Category {
id: CategoryId;
label: string;
icon: React.ElementType;
icon: LucideIcon;
description: string;
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
color?: string;
@@ -63,7 +68,7 @@ interface Category {
interface LeagueSliderProps {
title: string;
icon: React.ElementType;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
@@ -306,74 +311,71 @@ function LeagueSlider({
if (leagues.length === 0) return null;
return (
<div className="mb-10">
<Box mb={10}>
{/* Section header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
<Icon className={`w-5 h-5 ${iconColor}`} />
</div>
<div>
<h2 className="text-lg font-semibold text-white">{title}</h2>
<p className="text-xs text-gray-500">{description}</p>
</div>
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
<UIIcon icon={Icon} size={5} color={iconColor} />
</Box>
<Box>
<Heading level={2}>{title}</Heading>
<Text size="xs" color="text-gray-500">{description}</Text>
</Box>
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
{leagues.length}
</span>
</div>
</Box>
</Stack>
{/* Navigation arrows */}
<div className="flex items-center gap-2">
<button
<Stack direction="row" align="center" gap={2}>
<Button
type="button"
variant="secondary"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollLeft
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<ChevronLeft className="w-4 h-4" />
</button>
<button
<UIIcon icon={ChevronLeft} size={4} />
</Button>
<Button
type="button"
variant="secondary"
onClick={() => scroll('right')}
disabled={!canScrollRight}
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
canScrollRight
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
}`}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
</div>
<UIIcon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
{/* Scrollable container with fade edges */}
<div className="relative">
<Box position="relative">
{/* Left fade gradient */}
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
<Box position="absolute" top={0} bottom={4} left={0} w="3rem" bg="bg-gradient-to-r from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
{/* Right fade gradient */}
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
<Box position="absolute" top={0} bottom={4} right={0} w="3rem" bg="bg-gradient-to-l from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
<div
<Box
ref={scrollRef}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
className="flex gap-4 overflow-x-auto pb-4 px-4"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none',
}}
display="flex"
gap={4}
overflow="auto"
pb={4}
px={4}
hideScrollbar
>
<style jsx>{`
div::-webkit-scrollbar {
display: none;
}
`}</style>
{leagues.map((league) => {
// TODO wtf we have builders for this
// Convert ViewData to ViewModel for LeagueCard
const viewModel: LeagueSummaryViewModel = {
id: league.id,
@@ -396,16 +398,16 @@ function LeagueSlider({
};
return (
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
<a href={`/leagues/${league.id}`} className="block h-full">
<Box key={league.id} flexShrink={0} w="320px" h="full">
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</a>
</div>
</UILink>
</Box>
);
})}
</div>
</div>
</div>
</Box>
</Box>
</Box>
);
}
@@ -413,9 +415,10 @@ function LeagueSlider({
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeaguesClient({
export function LeaguesPageClient({
viewData,
}: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
const [showFilters, setShowFilters] = useState(false);
@@ -480,7 +483,7 @@ export function LeaguesClient({
actions={[
{
label: 'Create League',
onClick: () => { window.location.href = '/leagues/create'; },
onClick: () => { router.push(routes.league.create); },
icon: Plus,
description: 'Set up your own racing series'
}
@@ -491,60 +494,57 @@ export function LeaguesClient({
<Box mb={6}>
<Stack direction="row" gap={4} wrap>
{/* Search */}
<Box display="flex" position="relative" style={{ flex: 1 }}>
<Box position="absolute" style={{ left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
<Search className="w-5 h-5 text-gray-500" />
</Box>
<Box display="flex" position="relative" flexGrow={1}>
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
className="pl-11"
icon={<UIIcon icon={Search} size={5} color="text-gray-500" />}
/>
</Box>
{/* Filter toggle (mobile) */}
<Box display="none" className="lg:hidden">
<Box display={{ base: 'block', lg: 'none' }}>
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
className="flex items-center gap-2"
>
<Filter className="w-4 h-4" />
Filters
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Filter} size={4} />
<Text>Filters</Text>
</Stack>
</Button>
</Box>
</Stack>
{/* Category Tabs */}
<Box mt={4} display={showFilters ? 'block' : 'none'} className="lg:block">
<Box mt={4} display={showFilters ? 'block' : { base: 'none', lg: 'block' }}>
<Stack direction="row" gap={2} wrap>
{CATEGORIES.map((category) => {
const Icon = category.icon;
const count = leaguesByCategory[category.id].length;
const isActive = activeCategory === category.id;
return (
<button
<Button
key={category.id}
type="button"
variant={isActive ? 'primary' : 'secondary'}
onClick={() => setActiveCategory(category.id)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-200 ${
isActive
? 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.3)]'
: 'bg-iron-gray/60 text-gray-400 border border-charcoal-outline hover:border-gray-500 hover:text-white'
}`}
size="sm"
rounded="full"
>
<Icon className={`w-3.5 h-3.5 ${!isActive && category.color ? category.color : ''}`} />
<span>{category.label}</span>
{count > 0 && (
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}`}>
{count}
</span>
)}
</button>
<Stack direction="row" align="center" gap={1.5}>
<UIIcon icon={category.icon} size={3.5} color={!isActive && category.color ? category.color : undefined} />
<Text size="xs" weight="medium">{category.label}</Text>
{count > 0 && (
<Box as="span" px={1.5} py={0.5} rounded="full" fontSize="10px" bg={isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}>
{count}
</Box>
)}
</Stack>
</Button>
);
})}
</Stack>
@@ -554,24 +554,29 @@ export function LeaguesClient({
{/* Content */}
{viewData.leagues.length === 0 ? (
/* Empty State */
<Card className="text-center py-16">
<Box maxWidth="28rem" mx="auto">
<Box display="flex" center mb={6} rounded="2xl" p={4} className="bg-primary-blue/10 border border-primary-blue/20 mx-auto w-16 h-16">
<Trophy className="w-8 h-8 text-primary-blue" />
<Card>
<Box py={16} textAlign="center">
<Box maxWidth="28rem" mx="auto">
<Box display="flex" center mb={6} rounded="2xl" p={4} bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" mx="auto" w="4rem" h="4rem">
<UIIcon icon={Trophy} size={8} color="text-primary-blue" />
</Box>
<Heading level={2}>
No leagues yet
</Heading>
<Box mt={3} mb={8}>
<Text color="text-gray-400">
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
</Text>
</Box>
<Button
onClick={() => { router.push(routes.league.create); }}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Sparkles} size={4} />
<Text>Create Your First League</Text>
</Stack>
</Button>
</Box>
<Heading level={2} className="text-2xl mb-3">
No leagues yet
</Heading>
<Text color="text-gray-400" mb={8} block>
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
</Text>
<Button
onClick={() => { window.location.href = '/leagues/create'; }}
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors"
>
<Sparkles className="w-4 h-4" />
Create Your First League
</Button>
</Box>
</Card>
) : activeCategory === 'all' && !searchQuery ? (
@@ -602,15 +607,15 @@ export function LeaguesClient({
<Box>
{categoryFilteredLeagues.length > 0 ? (
<>
<Box display="flex" align="center" justify="between" mb={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Text size="sm" color="text-gray-400">
Showing <Text color="text-white" weight="medium">{categoryFilteredLeagues.length}</Text>{' '}
{categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'}
{searchQuery && (
<span>
<Box as="span">
{' '}
for "<Text color="text-primary-blue">{searchQuery}</Text>"
</span>
for &quot;<Text color="text-primary-blue">{searchQuery}</Text>&quot;
</Box>
)}
</Text>
</Box>
@@ -639,31 +644,33 @@ export function LeaguesClient({
return (
<GridItem key={league.id}>
<a href={`/leagues/${league.id}`} className="block h-full">
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</a>
</UILink>
</GridItem>
);
})}
</Grid>
</>
) : (
<Card className="text-center py-12">
<Stack align="center" gap={4}>
<Search className="w-10 h-10 text-gray-600" />
<Text color="text-gray-400">
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
</Text>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setActiveCategory('all');
}}
>
Clear filters
</Button>
</Stack>
<Card>
<Box py={12} textAlign="center">
<Stack align="center" gap={4}>
<UIIcon icon={Search} size={10} color="text-gray-600" />
<Text color="text-gray-400">
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
</Text>
<Button
variant="secondary"
onClick={() => {
setSearchQuery('');
setActiveCategory('all');
}}
>
Clear filters
</Button>
</Stack>
</Box>
</Card>
)}
</Box>

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { Text } from '@/ui/Text';

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { ErrorBanner } from '@/ui/ErrorBanner';
@@ -22,8 +22,6 @@ export default async function Page({ params }: Props) {
case 'redirect':
// In a real app, this would redirect to login
notFound();
case 'LEAGUE_FETCH_FAILED':
case 'UNKNOWN_ERROR':
default:
// Return error state
return (
@@ -49,5 +47,9 @@ export default async function Page({ params }: Props) {
sponsors: [],
});
return <LeagueDetailTemplate viewData={viewData} tabs={[]} children={null} />;
}
return (
<LeagueDetailTemplate viewData={viewData} tabs={[]}>
{null}
</LeagueDetailTemplate>
);
}

View File

@@ -10,8 +10,11 @@ import {
useRejectJoinRequest,
useUpdateMemberRole,
useRemoveMember,
} from "@/lib/hooks/league/useLeagueRosterAdmin";
} from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
@@ -71,10 +74,25 @@ export function RosterAdminPage() {
await removeMemberMutation.mutateAsync({ leagueId, driverId });
};
const viewData = useMemo(() => ({
leagueId,
joinRequests: joinRequests.map((req: LeagueRosterJoinRequestDTO): JoinRequestData => ({
id: req.id,
driver: req.driver as { id: string; name: string },
requestedAt: req.requestedAt,
message: req.message || undefined,
})),
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
driverId: m.driverId,
driver: m.driver as { id: string; name: string },
role: m.role,
joinedAt: m.joinedAt,
})),
}), [leagueId, joinRequests, members]);
return (
<RosterAdminTemplate
joinRequests={joinRequests}
members={members}
viewData={viewData}
loading={loading}
pendingCountLabel={pendingCountLabel}
onApprove={handleApprove}
@@ -84,4 +102,4 @@ export function RosterAdminPage() {
roleOptions={ROLE_OPTIONS}
/>
);
}
}

View File

@@ -1,4 +1,4 @@
import { LeagueRulebookPageQuery } from '@/lib/page-queries/page-queries/LeagueRulebookPageQuery';
import { LeagueRulebookPageQuery } from '@/lib/page-queries/LeagueRulebookPageQuery';
import { RulebookTemplate } from '@/templates/RulebookTemplate';
import { notFound } from 'next/navigation';
@@ -17,18 +17,21 @@ export default async function Page({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <RulebookTemplate viewData={{
leagueId,
scoringConfig: {
gameName: 'Unknown',
scoringPresetName: 'Unknown',
championships: [],
dropPolicySummary: 'Unknown',
},
gameName: 'Unknown',
scoringPresetName: 'Unknown',
championshipsCount: 0,
sessionTypes: 'None',
dropPolicySummary: 'Unknown',
hasActiveDropPolicy: false,
positionPoints: [],
bonusPoints: [],
hasBonusPoints: false,
}} />;
}

View File

@@ -0,0 +1,224 @@
'use client';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from "@/hooks/league/useLeagueScheduleAdminPageData";
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
export function LeagueAdminSchedulePageClient() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
},
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
deleteMutation.mutate(raceId);
};
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
published: schedule.published,
races: schedule.races.map(r => ({
id: r.id,
name: r.name,
track: r.track || '',
car: r.car || '',
scheduledAt: r.scheduledAt.toISOString(),
})),
seasons: seasonsData.map(s => ({
seasonId: s.seasonId,
name: s.name,
})),
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<Stack gap={6}>
<Card>
<Box p={6} textAlign="center">
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">Only league admins can manage the schedule.</Text>
</Box>
</Box>
</Card>
</Stack>
);
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}

View File

@@ -1,205 +1,25 @@
'use client';
import { notFound } from 'next/navigation';
import { LeagueScheduleAdminPageQuery } from '@/lib/page-queries/LeagueScheduleAdminPageQuery';
import { LeagueAdminSchedulePageClient } from './LeagueAdminSchedulePageClient';
import { useState } from 'react';
import { useParams } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
import {
useLeagueAdminStatus,
useLeagueSeasons,
useLeagueAdminSchedule
} from "@/lib/hooks/league/useLeagueScheduleAdminPageData";
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
interface Props {
params: Promise<{ id: string }>;
}
export default function LeagueAdminSchedulePage() {
const params = useParams();
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
export default async function Page({ params }: Props) {
const { id } = await params;
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
// Form state
const [seasonId, setSeasonId] = useState<string>('');
const [track, setTrack] = useState('');
const [car, setCar] = useState('');
const [scheduledAtIso, setScheduledAtIso] = useState('');
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
// Load seasons using domain hook
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
: '');
// Load schedule using domain hook
const { data: schedule, isLoading: scheduleLoading, refetch: refetchSchedule } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
// Mutations
const publishMutation = useMutation({
mutationFn: async () => {
if (!schedule || !selectedSeasonId) return null;
return schedule.published
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
const saveMutation = useMutation({
mutationFn: async () => {
if (!selectedSeasonId || !scheduledAtIso) return null;
if (!editingRaceId) {
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
track,
car,
scheduledAtIso,
});
} else {
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
...(track ? { track } : {}),
...(car ? { car } : {}),
...(scheduledAtIso ? { scheduledAtIso } : {}),
});
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
// Reset form
setTrack('');
setCar('');
setScheduledAtIso('');
setEditingRaceId(null);
},
});
const deleteMutation = useMutation({
mutationFn: async (raceId: string) => {
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
},
});
// Derived states
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
const isPublishing = publishMutation.isPending;
const isSaving = saveMutation.isPending;
const isDeleting = deleteMutation.variables || null;
// Handlers
const handleSeasonChange = (newSeasonId: string) => {
setSeasonId(newSeasonId);
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
const handlePublishToggle = () => {
publishMutation.mutate();
};
const handleAddOrSave = () => {
if (!scheduledAtIso) return;
saveMutation.mutate();
};
const handleEdit = (raceId: string) => {
if (!schedule) return;
const race = schedule.races.find((r) => r.id === raceId);
if (!race) return;
setEditingRaceId(raceId);
setTrack('');
setCar('');
setScheduledAtIso(race.scheduledAt.toISOString());
};
const handleDelete = (raceId: string) => {
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
deleteMutation.mutate(raceId);
};
const handleCancelEdit = () => {
setEditingRaceId(null);
setTrack('');
setCar('');
setScheduledAtIso('');
};
// Prepare template data
const templateData = schedule && seasonsData && selectedSeasonId
? {
schedule,
seasons: seasonsData,
seasonId: selectedSeasonId,
}
: undefined;
// Render admin access required if not admin
if (!isLoading && !isAdmin) {
return (
<div className="space-y-6">
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-6 text-center">
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
</div>
</div>
);
// Execute the PageQuery
const result = await LeagueScheduleAdminPageQuery.execute({ leagueId: id });
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
// For other errors, we still render the client component which handles its own loading/error states
// or we could render an error banner here.
}
// Template component that wraps the actual template with all props
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
data={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={track}
car={car}
scheduledAtIso={scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={isDeleting}
setTrack={setTrack}
setCar={setCar}
setScheduledAtIso={setScheduledAtIso}
/>
);
};
return (
<PageWrapper
data={templateData}
isLoading={isLoading}
error={null}
Template={TemplateWrapper}
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
empty={{
title: 'No schedule data available',
description: 'Unable to load schedule administration data',
}}
/>
);
}
return <LeagueAdminSchedulePageClient />;
}

View File

@@ -1,4 +1,4 @@
import { LeagueSchedulePageQuery } from '@/lib/page-queries/page-queries/LeagueSchedulePageQuery';
import { LeagueSchedulePageQuery } from '@/lib/page-queries/LeagueSchedulePageQuery';
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSchedulePage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data

View File

@@ -1,4 +1,4 @@
import { LeagueSettingsPageQuery } from '@/lib/page-queries/page-queries/LeagueSettingsPageQuery';
import { LeagueSettingsPageQuery } from '@/lib/page-queries/LeagueSettingsPageQuery';
import { LeagueSettingsTemplate } from '@/templates/LeagueSettingsTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSettingsPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
@@ -29,8 +29,8 @@ export default async function LeagueSettingsPage({ params }: Props) {
description: 'League information unavailable',
visibility: 'private',
ownerId: 'unknown',
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
createdAt: '1970-01-01T00:00:00Z',
updatedAt: '1970-01-01T00:00:00Z',
},
config: {
maxDrivers: 0,

View File

@@ -1,4 +1,4 @@
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/page-queries/LeagueSponsorshipsPageQuery';
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/LeagueSponsorshipsPageQuery';
import { LeagueSponsorshipsTemplate } from '@/templates/LeagueSponsorshipsTemplate';
import { notFound } from 'next/navigation';
@@ -17,12 +17,14 @@ export default async function LeagueSponsorshipsPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <LeagueSponsorshipsTemplate viewData={{
leagueId,
activeTab: 'overview',
onTabChange: () => {},
league: {
id: leagueId,
name: 'Unknown League',

View File

@@ -1,4 +1,4 @@
import { LeagueStandingsPageQuery } from '@/lib/page-queries/page-queries/LeagueStandingsPageQuery';
import { LeagueStandingsPageQuery } from '@/lib/page-queries/LeagueStandingsPageQuery';
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function Page({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data

View File

@@ -0,0 +1,243 @@
'use client';
import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import { StewardingStats } from '@/components/leagues/StewardingStats';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
import { DriverViewModel } from '@/lib/view-models/DriverViewModel';
interface StewardingTemplateProps {
data: StewardingViewData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.races.flatMap(r => r.pendingProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
description: p.incident.description,
submittedAt: p.filedAt,
status: p.status,
raceId: r.id,
incident: p.incident,
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const racesMap = useMemo(() => {
const map: Record<string, RaceViewModel> = {};
data.races.forEach(r => {
map[r.id] = new RaceViewModel({
id: r.id,
name: '',
date: r.scheduledAt,
track: r.track,
} as never);
});
return map;
}, [data.races]);
const driverMap = useMemo(() => {
const map: Record<string, DriverViewModel> = {};
data.drivers.forEach(d => {
map[d.id] = new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
});
});
return map;
}, [data.drivers]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: { raceId: string; accusedDriverId: string; incident: { description: string } } | undefined;
data.races.forEach((raceData) => {
const p = raceData.pendingProtests.find((pr) => pr.id === protestId) ||
raceData.resolvedProtests.find((pr) => pr.id === protestId);
if (p) foundProtest = {
raceId: raceData.id,
accusedDriverId: p.accusedDriverId,
incident: { description: p.incident.description }
};
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
return (
<Stack gap={6}>
<Card>
<Box p={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
<Box>
<Heading level={2}>Stewarding</Heading>
<Box mt={1}>
<Text size="sm" color="text-gray-400">
Quick overview of protests and penalties across all races
</Text>
</Box>
</Box>
</Box>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<Box borderBottom borderColor="border-charcoal-outline" mb={6}>
<Stack direction="row" gap={4}>
<Box
borderBottom={activeTab === 'pending'}
borderColor={activeTab === 'pending' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('pending')}
rounded="none"
>
<Stack direction="row" align="center" gap={2}>
<Text weight="medium" color={activeTab === 'pending' ? 'text-primary-blue' : undefined}>Pending Protests</Text>
{data.totalPending > 0 && (
<Box px={2} py={0.5} fontSize="0.75rem" bg="bg-warning-amber/20" color="text-warning-amber" rounded="full">
{data.totalPending}
</Box>
)}
</Stack>
</Button>
</Box>
<Box
borderBottom={activeTab === 'history'}
borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
>
<Button
variant="ghost"
onClick={() => setActiveTab('history')}
rounded="none"
>
<Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
</Button>
</Box>
</Stack>
</Box>
{/* Content */}
{activeTab === 'pending' ? (
<PendingProtestsList
protests={allPendingProtests}
races={racesMap}
drivers={driverMap}
leagueId={leagueId}
onReviewProtest={setSelectedProtest}
onProtestReviewed={onRefetch}
/>
) : (
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={driverMap}
/>
)}
</Box>
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.drivers.map(d => new DriverViewModel({
id: d.id,
name: d.name,
iracingId: '',
country: '',
joinedAt: '',
avatarUrl: null,
}))}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.races.map(r => ({ id: r.id, track: r.track, scheduledAt: new Date(r.scheduledAt) }))}
/>
)}
</Stack>
);
}

View File

@@ -1,197 +0,0 @@
'use client';
import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
import { StewardingStats } from '@/components/leagues/StewardingStats';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import {
AlertCircle,
AlertTriangle,
Calendar,
ChevronRight,
Flag,
Gavel,
MapPin,
Video
} from 'lucide-react';
import Link from 'next/link';
import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
interface StewardingData {
totalPending: number;
totalResolved: number;
totalPenalties: number;
racesWithData: Array<{
race: { id: string; track: string; scheduledAt: Date; car?: string };
pendingProtests: any[];
resolvedProtests: any[];
penalties: any[];
}>;
allDrivers: any[];
driverMap: Record<string, any>;
}
interface StewardingTemplateProps {
data: StewardingData;
leagueId: string;
currentDriverId: string;
onRefetch: () => void;
}
export function StewardingTemplate({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
// Mutations using domain hook
const { acceptProtestMutation, rejectProtestMutation } = useLeagueStewardingMutations(onRefetch);
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.pendingProtests);
}, [data]);
const allResolvedProtests = useMemo(() => {
return data.racesWithData.flatMap(r => r.resolvedProtests);
}, [data]);
const racesMap = useMemo(() => {
const map: Record<string, any> = {};
data.racesWithData.forEach(r => {
map[r.race.id] = r.race;
});
return map;
}, [data]);
const handleAcceptProtest = async (
protestId: string,
penaltyType: string,
penaltyValue: number,
stewardNotes: string
) => {
// Find the protest to get details for penalty
let foundProtest: any | undefined;
data.racesWithData.forEach(raceData => {
const p = raceData.pendingProtests.find(pr => pr.id === protestId) ||
raceData.resolvedProtests.find(pr => pr.id === protestId);
if (p) foundProtest = { ...p, raceId: raceData.race.id };
});
if (foundProtest) {
acceptProtestMutation.mutate({
protestId,
penaltyType,
penaltyValue,
stewardNotes,
raceId: foundProtest.raceId,
accusedDriverId: foundProtest.accusedDriverId,
reason: foundProtest.incident.description,
});
}
};
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
rejectProtestMutation.mutate({
protestId,
stewardNotes,
});
};
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{activeTab === 'pending' ? (
<PendingProtestsList
protests={allPendingProtests}
races={racesMap}
drivers={data.driverMap}
leagueId={leagueId}
onReviewProtest={setSelectedProtest}
onProtestReviewed={onRefetch}
/>
) : (
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={data.driverMap}
/>
)}
</Card>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && (
<QuickPenaltyModal
drivers={data.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId || ''}
races={data.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { LeagueStewardingPageQuery } from '@/lib/page-queries/page-queries/LeagueStewardingPageQuery';
import { StewardingTemplate } from '@/templates/StewardingTemplate';
import { LeagueStewardingPageQuery } from '@/lib/page-queries/LeagueStewardingPageQuery';
import { StewardingPageClient } from './StewardingPageClient';
import { notFound } from 'next/navigation';
interface Props {
@@ -17,19 +17,31 @@ export default async function LeagueStewardingPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <StewardingTemplate viewData={{
leagueId,
totalPending: 0,
totalResolved: 0,
totalPenalties: 0,
races: [],
drivers: []
}} />;
return <StewardingPageClient
leagueId={leagueId}
currentDriverId=""
onRefetch={() => {}}
data={{
leagueId,
totalPending: 0,
totalResolved: 0,
totalPenalties: 0,
races: [],
drivers: []
}}
/>;
}
return <StewardingTemplate viewData={result.unwrap()} />;
const data = result.unwrap();
return <StewardingPageClient
data={data}
leagueId={leagueId}
currentDriverId="" // Should be fetched or passed
onRefetch={() => {}} // Should be handled
/>;
}

View File

@@ -0,0 +1,808 @@
'use client';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useEffectiveDriverId } from "@/hooks/useEffectiveDriverId";
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import {
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle,
AlertCircle,
type LucideIcon
} from 'lucide-react';
import { useParams, useRouter } from 'next/navigation';
import { useMemo, useState, useEffect } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useLeagueAdminStatus } from "@/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/hooks/league/useProtestDetail";
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { GridItem } from '@/ui/GridItem';
import { Icon as UIIcon } from '@/ui/Icon';
import { Link as UILink } from '@/ui/Link';
import { routes } from '@/lib/routing/RouteConfig';
type PenaltyUiConfig = {
label: string;
description: string;
icon: LucideIcon;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
defaultValue: 5,
},
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
defaultValue: 3,
},
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
defaultValue: 5,
},
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
defaultValue: 0,
},
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
defaultValue: 0,
},
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
defaultValue: 2,
},
};
export function ProtestDetailPageClient({ initialViewData }: { initialViewData: unknown }) {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status using hook
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load protest detail using hook
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
// Use initial data if available
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const protestDetail = (detail || initialViewData) as any;
// Set initial penalty values when data loads
useEffect(() => {
if (protestDetail?.initialPenaltyType) {
setPenaltyType(protestDetail.initialPenaltyType);
setPenaltyValue(protestDetail.initialPenaltyValue);
}
}, [protestDetail]);
const penaltyTypes = useMemo(() => {
const referenceItems = protestDetail?.penaltyTypes ?? [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return referenceItems.map((ref: any) => {
const ui = PENALTY_UI[ref.type] ?? {
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
};
return {
...ref,
icon: ui.icon,
color: ui.color,
};
});
}, [protestDetail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return penaltyTypes.find((p: any) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !protestDetail || !currentDriverId) return;
setSubmitting(true);
try {
const protest = protestDetail.protest || protestDetail;
const defaultUpheldReason = protestDetail.defaultReasons?.upheld;
const defaultDismissedReason = protestDetail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
throw new Error(result.getError().message);
}
} else {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const warningRef = protestDetail.penaltyTypes.find((p: any) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId || protestDetail.race?.id,
protest.accusedDriverId || protestDetail.accusedDriver?.id,
currentDriverId,
protest.id || protestDetail.protestId,
options,
);
const result = await protestService.applyPenalty(penaltyCommand);
if (result.isErr()) {
throw new Error(result.getError().message);
}
}
router.push(routes.league.stewarding(leagueId));
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!protestDetail || !currentDriverId) return;
try {
// Request defense
const result = await protestService.requestDefense({
protestId: protestDetail.protest?.id || protestDetail.protestId,
stewardId: currentDriverId,
});
if (result.isErr()) {
throw new Error(result.getError().message);
}
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', bg: 'bg-warning-amber/20', color: 'text-warning-amber', borderColor: 'border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', bg: 'bg-blue-500/20', color: 'text-blue-400', borderColor: 'border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', bg: 'bg-purple-500/20', color: 'text-purple-400', borderColor: 'border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', bg: 'bg-red-500/20', color: 'text-red-400', borderColor: 'border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: XCircle };
default:
return { label: status, bg: 'bg-gray-500/20', color: 'text-gray-400', borderColor: 'border-gray-500/30', icon: AlertCircle };
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
<Box p={12} textAlign="center">
<Box w={16} h={16} mx="auto" mb={4} rounded="full" bg="bg-iron-gray/50" display="flex" alignItems="center" justifyContent="center">
<UIIcon icon={AlertTriangle} size={8} color="text-warning-amber" />
</Box>
<Heading level={3}>Admin Access Required</Heading>
<Box mt={2}>
<Text size="sm" color="text-gray-400">
Only league admins can review protests.
</Text>
</Box>
</Box>
</Card>
);
}
return (
<StateContainer
data={protestDetail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{(pd: any) => {
if (!pd) return null;
const protest = pd.protest || pd;
const race = pd.race;
const protestingDriver = pd.protestingDriver;
const accusedDriver = pd.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const submittedAt = protest.submittedAt || pd.submittedAt;
const daysSinceFiled = Math.floor((Date.now() - new Date(submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<Box minHeight="100vh">
{/* Compact Header */}
<Box mb={6}>
<Stack direction="row" align="center" gap={3} mb={4}>
<UILink href={routes.league.stewarding(leagueId)}>
<UIIcon icon={ArrowLeft} size={5} color="text-gray-400" />
</UILink>
<Stack direction="row" align="center" gap={3} flexGrow={1}>
<Heading level={1}>Protest Review</Heading>
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" fontSize="0.75rem" weight="medium" border bg={statusConfig.bg} color={statusConfig.color} borderColor={statusConfig.borderColor}>
<UIIcon icon={StatusIcon} size={3} />
<Text>{statusConfig.label}</Text>
</Box>
{daysSinceFiled > 2 && isPending && (
<Box display="flex" alignItems="center" gap={1} px={2} py={0.5} fontSize="0.75rem" weight="medium" bg="bg-red-500/20" color="text-red-400" rounded="full">
<UIIcon icon={AlertTriangle} size={3} />
<Text>{daysSinceFiled}d old</Text>
</Box>
)}
</Stack>
</Stack>
</Box>
{/* Main Layout: Feed + Sidebar */}
<Grid cols={12} gap={6}>
{/* Left Sidebar - Incident Info */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{/* Drivers Involved */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Parties Involved</Heading>
<Stack gap={3}>
{/* Protesting Driver */}
<UILink href={routes.driver.detail(protestingDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-blue-500/50" hoverBg="bg-blue-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Text size="xs" color="text-blue-400" weight="medium" block>Protesting</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{protestingDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
{/* Accused Driver */}
<UILink href={routes.driver.detail(accusedDriver?.id || '')} block>
<Box display="flex" alignItems="center" gap={3} p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-orange-500/50" hoverBg="bg-orange-500/5" transition cursor="pointer">
<Box w={10} h={10} rounded="full" bg="bg-orange-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-orange-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Text size="xs" color="text-orange-400" weight="medium" block>Accused</Text>
<Text size="sm" weight="semibold" color="text-white" truncate block>{accusedDriver?.name || 'Unknown'}</Text>
</Box>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</UILink>
</Stack>
</Box>
</Card>
{/* Race Info */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Race Details</Heading>
<UILink
href={routes.race.detail(race?.id || '')}
block
mb={3}
>
<Box p={3} rounded="lg" bg="bg-deep-graphite" border borderColor="border-charcoal-outline" hoverBorderColor="border-primary-blue/50" hoverBg="bg-primary-blue/5" transition>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" weight="medium" color="text-white">{race?.name || 'Unknown Race'}</Text>
<UIIcon icon={ExternalLink} size={3} color="text-gray-500" />
</Box>
</Box>
</UILink>
<Stack gap={2}>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={MapPin} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.name || 'Unknown Track'}</Text>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Calendar} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">{race?.formattedDate || (race?.scheduledAt ? new Date(race.scheduledAt).toLocaleDateString() : 'Unknown Date')}</Text>
</Box>
{protest.incident?.lap && (
<Box display="flex" alignItems="center" gap={2}>
<UIIcon icon={Flag} size={4} color="text-gray-500" />
<Text size="sm" color="text-gray-300">Lap {protest.incident.lap}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
{protest.proofVideoUrl && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Evidence</Heading>
<UILink
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
block
>
<Box display="flex" alignItems="center" gap={2} p={3} rounded="lg" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" color="text-primary-blue" hoverBg="bg-primary-blue/20" transition>
<UIIcon icon={Video} size={4} />
<Text size="sm" weight="medium" flexGrow={1}>Watch Video</Text>
<UIIcon icon={ExternalLink} size={3} />
</Box>
</UILink>
</Box>
</Card>
)}
{/* Quick Stats */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Timeline</Heading>
<Stack gap={2}>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Filed</Text>
<Text size="sm" color="text-gray-300">{new Date(submittedAt).toLocaleDateString()}</Text>
</Box>
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Age</Text>
<Text size="sm" color={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</Text>
</Box>
{protest.reviewedAt && (
<Box display="flex" justifyContent="between">
<Text size="sm" color="text-gray-500">Resolved</Text>
<Text size="sm" color="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</Text>
</Box>
)}
</Stack>
</Box>
</Card>
</Stack>
</GridItem>
{/* Center - Discussion Feed */}
<GridItem colSpan={12} lgSpan={6}>
<Stack gap={4}>
{/* Timeline / Feed */}
<Card>
<Box borderBottom borderColor="border-charcoal-outline" bg="bg-iron-gray/30" p={4}>
<Heading level={2}>Discussion</Heading>
</Box>
<Stack gap={0}>
{/* Initial Protest Filing */}
<Box p={4}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-blue-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={AlertCircle} size={5} color="text-blue-400" />
</Box>
<Box flexGrow={1} minWidth={0}>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">{protestingDriver?.name || 'Unknown'}</Text>
<Text size="xs" color="text-blue-400" weight="medium">filed protest</Text>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(submittedAt).toLocaleString()}</Text>
</Box>
<Box bg="bg-deep-graphite" rounded="lg" p={4} border borderColor="border-charcoal-outline">
<Text size="sm" color="text-gray-300" block mb={3}>{protest.description || pd.incident?.description}</Text>
{(protest.comment || pd.comment) && (
<Box mt={3} pt={3} borderTop borderColor="border-charcoal-outline/50">
<Text size="xs" color="text-gray-500" block mb={1}>Additional details:</Text>
<Text size="sm" color="text-gray-400">{protest.comment || pd.comment}</Text>
</Box>
)}
</Box>
</Box>
</Box>
</Box>
{/* Defense placeholder */}
{protest.status === 'awaiting_defense' && (
<Box p={4} bg="bg-purple-500/5">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-purple-500/20" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={MessageCircle} size={5} color="text-purple-400" />
</Box>
<Box flexGrow={1}>
<Text size="sm" color="text-purple-400" weight="medium" block mb={1}>Defense Requested</Text>
<Text size="sm" color="text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</Text>
</Box>
</Box>
</Box>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<Box p={4} bg={protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}>
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" display="flex" alignItems="center" justifyContent="center" flexShrink={0} bg={protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'}>
<UIIcon icon={Gavel} size={5} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'} />
</Box>
<Box flexGrow={1} minWidth={0}>
<Box display="flex" alignItems="center" gap={2} mb={1}>
<Text weight="semibold" color="text-white" size="sm">Steward Decision</Text>
<Text size="xs" weight="medium" color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</Text>
{protest.reviewedAt && (
<>
<Text size="xs" color="text-gray-500"></Text>
<Text size="xs" color="text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</Text>
</>
)}
</Box>
<Box rounded="lg" p={4} border bg={protest.status === 'upheld' ? 'bg-red-500/10' : 'bg-gray-500/10'} borderColor={protest.status === 'upheld' ? 'border-red-500/20' : 'border-gray-500/20'}>
<Text size="sm" color="text-gray-300">{protest.decisionNotes}</Text>
</Box>
</Box>
</Box>
</Box>
)}
</Stack>
{/* Add Comment */}
{isPending && (
<Box p={4} borderTop borderColor="border-charcoal-outline" bg="bg-iron-gray/20">
<Box display="flex" gap={3}>
<Box w={10} h={10} rounded="full" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<UIIcon icon={User} size={5} color="text-gray-500" />
</Box>
<Box flexGrow={1}>
<Box as="textarea"
value={newComment}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
w="full"
px={4}
py={3}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
<Box display="flex" justifyContent="end" mt={2}>
<Button variant="secondary" disabled={!newComment.trim()}>
<UIIcon icon={Send} size={3} mr={1} />
Comment
</Button>
</Box>
</Box>
</Box>
</Box>
)}
</Card>
</Stack>
</GridItem>
{/* Right Sidebar - Actions */}
<GridItem colSpan={12} lgSpan={3}>
<Stack gap={4}>
{isPending && (
<>
{/* Quick Actions */}
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Actions</Heading>
<Stack gap={2}>
<Button
variant="secondary"
fullWidth
onClick={handleRequestDefense}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={MessageCircle} size={4} />
<Text>Request Defense</Text>
</Stack>
</Button>
<Button
variant="primary"
fullWidth
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Stack direction="row" align="center" gap={2} fullWidth>
<UIIcon icon={Gavel} size={4} />
<Text>Make Decision</Text>
<Box ml="auto" transition transform={showDecisionPanel ? 'rotate(180deg)' : 'none'}>
<UIIcon icon={ChevronDown} size={4} />
</Box>
</Stack>
</Button>
</Stack>
</Box>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card>
<Box p={4}>
<Heading level={3} fontSize="xs" weight="semibold" color="text-gray-500" mb={3}>Stewarding Decision</Heading>
{/* Decision Selection */}
<Grid cols={2} gap={2} mb={4}>
<Button
variant="ghost"
onClick={() => setDecision('uphold')}
p={3}
border
borderColor={decision === 'uphold' ? 'border-racing-red' : 'border-charcoal-outline'}
bg={decision === 'uphold' ? 'bg-racing-red/10' : 'transparent'}
>
<Stack align="center" gap={1}>
<UIIcon icon={CheckCircle} size={5} color={decision === 'uphold' ? 'text-red-400' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}>Uphold</Text>
</Stack>
</Button>
<Button
variant="ghost"
onClick={() => setDecision('dismiss')}
p={3}
border
borderColor={decision === 'dismiss' ? 'border-gray-500' : 'border-charcoal-outline'}
bg={decision === 'dismiss' ? 'bg-gray-500/10' : 'transparent'}
>
<Stack align="center" gap={1}>
<UIIcon icon={XCircle} size={5} color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'} />
<Text size="xs" weight="medium" color={decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}>Dismiss</Text>
</Stack>
</Button>
</Grid>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={2}>Penalty Type</Text>
{penaltyTypes.length === 0 ? (
<Text size="xs" color="text-gray-500">
Loading penalty types...
</Text>
) : (
<>
<Grid cols={2} gap={2}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
{penaltyTypes.map((penalty: any) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<Button
key={penalty.type}
variant="ghost"
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
p={2}
border
borderColor={isSelected ? undefined : 'border-charcoal-outline'}
bg={isSelected ? undefined : 'bg-iron-gray/30'}
color={isSelected ? penalty.color : undefined}
title={penalty.description}
>
<Stack align="start" gap={0.5}>
<UIIcon icon={Icon} size={3.5} color={isSelected ? undefined : 'text-gray-500'} />
<Text size="xs" weight="medium" fontSize="10px" color={isSelected ? undefined : 'text-gray-500'}>
{penalty.label}
</Text>
</Stack>
</Button>
);
})}
</Grid>
{selectedPenalty?.requiresValue && (
<Box mt={3}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>
Value ({selectedPenalty.valueLabel})
</Text>
<Box as="input"
type="number"
value={penaltyValue}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setPenaltyValue(Number(e.target.value))}
min="1"
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
)}
</>
)}
</Box>
)}
{/* Steward Notes */}
<Box mb={4}>
<Text as="label" size="xs" weight="medium" color="text-gray-400" block mb={1}>Decision Reasoning *</Text>
<Box as="textarea"
value={stewardNotes}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
w="full"
px={3}
py={2}
bg="bg-deep-graphite"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
fontSize="sm"
/>
</Box>
{/* Submit */}
<Button
variant="primary"
fullWidth
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Box>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card>
<Box p={4} textAlign="center">
<Box py={4} color={protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}>
<UIIcon icon={Gavel} size={8} mx="auto" mb={2} />
<Text weight="semibold" block>Case Closed</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</Text>
</Box>
</Box>
</Card>
)}
</Stack>
</GridItem>
</Grid>
</Box>
);
}}
</StateContainer>
);
}

View File

@@ -1,737 +1,25 @@
'use client';
import { notFound } from 'next/navigation';
import { LeagueProtestDetailPageQuery } from '@/lib/page-queries/LeagueProtestDetailPageQuery';
import { ProtestDetailPageClient } from './ProtestDetailPageClient';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { useInject } from '@/lib/di/hooks/useInject';
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
Calendar,
CheckCircle,
ChevronDown,
Clock,
ExternalLink,
Flag,
Gavel,
Grid3x3,
MapPin,
MessageCircle,
Send,
Shield,
ShieldAlert,
TrendingDown,
User,
Video,
XCircle
} from 'lucide-react';
import Link from 'next/link';
import { useParams, useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
// Shared state components
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";
// Timeline event types
interface TimelineEvent {
id: string;
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
timestamp: Date;
actor: ProtestDriverViewModel | null;
content: string;
metadata?: Record<string, unknown>;
interface Props {
params: Promise<{ id: string; protestId: string }>;
}
type PenaltyUiConfig = {
label: string;
description: string;
icon: typeof Gavel;
color: string;
defaultValue?: number;
};
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
time_penalty: {
label: 'Time Penalty',
description: 'Add seconds to race result',
icon: Clock,
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
defaultValue: 5,
},
grid_penalty: {
label: 'Grid Penalty',
description: 'Grid positions for next race',
icon: Grid3x3,
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
defaultValue: 3,
},
points_deduction: {
label: 'Points Deduction',
description: 'Deduct championship points',
icon: TrendingDown,
color: 'text-red-400 bg-red-500/10 border-red-500/20',
defaultValue: 5,
},
disqualification: {
label: 'Disqualification',
description: 'Disqualify from race',
icon: XCircle,
color: 'text-red-500 bg-red-500/10 border-red-500/20',
defaultValue: 0,
},
warning: {
label: 'Warning',
description: 'Official warning only',
icon: AlertTriangle,
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
defaultValue: 0,
},
license_points: {
label: 'License Points',
description: 'Safety rating penalty',
icon: ShieldAlert,
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
defaultValue: 2,
},
};
export default function ProtestReviewPage() {
const params = useParams();
const router = useRouter();
const leagueId = params.id as string;
const protestId = params.protestId as string;
const currentDriverId = useEffectiveDriverId();
const protestService = useInject(PROTEST_SERVICE_TOKEN);
// Decision state
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
const [penaltyValue, setPenaltyValue] = useState<number>(5);
const [stewardNotes, setStewardNotes] = useState('');
const [submitting, setSubmitting] = useState(false);
const [newComment, setNewComment] = useState('');
// Check admin status using hook
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
// Load protest detail using hook
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
// Set initial penalty values when data loads
useMemo(() => {
if (detail?.initialPenaltyType) {
setPenaltyType(detail.initialPenaltyType);
setPenaltyValue(detail.initialPenaltyValue);
export default async function Page({ params }: Props) {
const { id, protestId } = await params;
// Execute the PageQuery
const result = await LeagueProtestDetailPageQuery.execute({ leagueId: id, protestId });
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
}, [detail]);
const penaltyTypes = useMemo(() => {
const referenceItems = detail?.penaltyTypes ?? [];
return referenceItems.map((ref) => {
const ui = PENALTY_UI[ref.type] ?? {
icon: Gavel,
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
};
return {
...ref,
icon: ui.icon,
color: ui.color,
};
});
}, [detail?.penaltyTypes]);
const selectedPenalty = useMemo(() => {
return penaltyTypes.find((p) => p.type === penaltyType);
}, [penaltyTypes, penaltyType]);
const handleSubmitDecision = async () => {
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
setSubmitting(true);
try {
const protest = detail.protest;
const defaultUpheldReason = detail.defaultReasons?.upheld;
const defaultDismissedReason = detail.defaultReasons?.dismissed;
if (decision === 'uphold') {
const requiresValue = selectedPenalty?.requiresValue ?? true;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType,
penaltyValue,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
} else {
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
const requiresValue = warningRef?.requiresValue ?? false;
const commandModel = new ProtestDecisionCommandModel({
decision,
penaltyType: 'warning',
penaltyValue: 0,
stewardNotes,
});
const options: {
requiresValue?: boolean;
defaultUpheldReason?: string;
defaultDismissedReason?: string;
} = { requiresValue };
if (defaultUpheldReason) {
options.defaultUpheldReason = defaultUpheldReason;
}
if (defaultDismissedReason) {
options.defaultDismissedReason = defaultDismissedReason;
}
const penaltyCommand = commandModel.toApplyPenaltyCommand(
protest.raceId,
protest.accusedDriverId,
currentDriverId,
protest.id,
options,
);
await protestService.applyPenalty(penaltyCommand);
}
router.push(`/leagues/${leagueId}/stewarding`);
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to submit decision');
} finally {
setSubmitting(false);
}
};
const handleRequestDefense = async () => {
if (!detail || !currentDriverId) return;
try {
// Request defense
await protestService.requestDefense({
protestId: detail.protest.id,
stewardId: currentDriverId,
});
// Reload page to show updated status
window.location.reload();
} catch (err) {
alert(err instanceof Error ? err.message : 'Failed to request defense');
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
case 'under_review':
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
case 'awaiting_defense':
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
case 'upheld':
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
case 'dismissed':
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
default:
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
}
};
// Show loading for admin check
if (adminLoading) {
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
<AlertTriangle className="w-8 h-8 text-warning-amber" />
</div>
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
<p className="text-sm text-gray-400">
Only league admins can review protests.
</p>
</div>
</Card>
);
}
const viewData = result.isOk() ? result.unwrap() : null;
return (
<StateContainer
data={detail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{(protestDetail) => {
if (!protestDetail) return null;
const protest = protestDetail.protest;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
const statusConfig = getStatusConfig(protest.status);
const StatusIcon = statusConfig.icon;
const isPending = protest.status === 'pending';
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
return (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}}
</StateContainer>
);
return <ProtestDetailPageClient initialViewData={viewData} />;
}

View File

@@ -0,0 +1,374 @@
'use client';
import React, { useState, useMemo } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Icon as UIIcon } from '@/ui/Icon';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
viewData: LeagueWalletViewData;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = useMemo(() => {
if (filterType === 'all') return viewData.transactions;
return viewData.transactions.filter(t => t.type === filterType);
}, [viewData.transactions, filterType]);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
const canWithdraw = viewData.balance > 0;
const withdrawalBlockReason = !canWithdraw ? 'Balance is zero' : undefined;
return (
<Container size="lg" py={8}>
{/* Header */}
<Box display="flex" alignItems="center" justifyContent="between" mb={8}>
<Box>
<Heading level={1}>League Wallet</Heading>
<Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text>
</Box>
<Stack direction="row" align="center" gap={2}>
<Button variant="secondary" onClick={onExport}>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Download} size={4} />
<Text>Export</Text>
</Stack>
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!canWithdraw || !onWithdraw}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
</Box>
{/* Withdrawal Warning */}
{!canWithdraw && withdrawalBlockReason && (
<Box mb={6} p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30">
<Stack direction="row" align="start" gap={3}>
<UIIcon icon={AlertTriangle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Box>
<Text weight="medium" color="text-warning-amber" block>Withdrawals Temporarily Unavailable</Text>
<Text size="sm" color="text-gray-400" block mt={1}>{withdrawalBlockReason}</Text>
</Box>
</Stack>
</Box>
)}
{/* Stats Grid */}
<Grid cols={1} mdCols={2} lgCols={4} gap={4} mb={8}>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-performance-green/10">
<UIIcon icon={Wallet} size={6} color="text-performance-green" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedBalance}</Text>
<Text size="sm" color="text-gray-400" block>Available Balance</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<UIIcon icon={TrendingUp} size={6} color="text-primary-blue" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalRevenue}</Text>
<Text size="sm" color="text-gray-400" block>Total Revenue</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-warning-amber/10">
<UIIcon icon={DollarSign} size={6} color="text-warning-amber" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalFees}</Text>
<Text size="sm" color="text-gray-400" block>Platform Fees (10%)</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-purple-500/10">
<UIIcon icon={Clock} size={6} color="text-purple-400" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedPendingPayouts}</Text>
<Text size="sm" color="text-gray-400" block>Pending Payouts</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Transactions */}
<Card>
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline">
<Heading level={2}>Transaction History</Heading>
<Box as="select"
value={filterType}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFilterType(e.target.value as typeof filterType)}
p={1.5}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
fontSize="sm"
>
<Box as="option" value="all">All Transactions</Box>
<Box as="option" value="sponsorship">Sponsorships</Box>
<Box as="option" value="membership">Memberships</Box>
<Box as="option" value="withdrawal">Withdrawals</Box>
<Box as="option" value="prize">Prizes</Box>
</Box>
</Box>
{filteredTransactions.length === 0 ? (
<Box py={12} textAlign="center">
<Box display="flex" justifyContent="center" mb={4}>
<UIIcon icon={Wallet} size={12} color="text-gray-500" />
</Box>
<Heading level={3}>No Transactions</Heading>
<Box mt={2}>
<Text color="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</Text>
</Box>
</Box>
) : (
<Box>
{filteredTransactions.map((transaction) => (
<TransactionRow
key={transaction.id}
transaction={{
id: transaction.id,
type: transaction.type,
description: transaction.description,
formattedDate: transaction.formattedDate,
formattedAmount: transaction.formattedAmount,
typeColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
status: transaction.status,
statusColor: transaction.status === 'completed' ? 'text-performance-green' : 'text-warning-amber',
amountColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
}}
/>
))}
</Box>
)}
</Card>
{/* Revenue Breakdown */}
<Grid cols={1} lgCols={2} gap={6} mt={6}>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Revenue Breakdown</Heading>
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-primary-blue" />
<Text color="text-gray-400">Sponsorships</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-performance-green" />
<Text color="text-gray-400">Membership Fees</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-gray-300">Total Gross Revenue</Text>
<Text weight="bold" color="text-white">$3,200.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-warning-amber">Platform Fee (10%)</Text>
<Text size="sm" color="text-warning-amber">-$320.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-performance-green">Net Revenue</Text>
<Text weight="bold" color="text-performance-green">$2,880.00</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Payout Schedule</Heading>
<Stack gap={3}>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Season 2 Prize Pool</Text>
<Text size="sm" weight="medium" color="text-warning-amber">Pending</Text>
</Box>
<Text size="xs" color="text-gray-500">
Distributed after season completion to top 3 drivers
</Text>
</Box>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Available for Withdrawal</Text>
<Text size="sm" weight="medium" color="text-performance-green">{viewData.formattedBalance}</Text>
</Box>
<Text size="xs" color="text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<Box position="fixed" inset="0" bg="bg-black/50" display="flex" alignItems="center" justifyContent="center" zIndex={50}>
<Card>
<Box p={6} w="full" maxWidth="28rem">
<Heading level={2} mb={4}>Withdraw Funds</Heading>
{!canWithdraw ? (
<Box p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" mb={4}>
<Text size="sm" color="text-warning-amber">{withdrawalBlockReason}</Text>
</Box>
) : (
<Stack gap={4}>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Amount to Withdraw
</Text>
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)">
<Text color="text-gray-500">$</Text>
</Box>
<Box as="input"
type="number"
value={withdrawAmount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWithdrawAmount(e.target.value)}
max={viewData.balance}
w="full"
pl={8}
pr={4}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
placeholder="0.00"
/>
</Box>
<Text size="xs" color="text-gray-500" block mt={1}>
Available: {viewData.formattedBalance}
</Text>
</Box>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Destination
</Text>
<Box as="select"
w="full"
px={3}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
>
<Box as="option">Bank Account ***1234</Box>
</Box>
</Box>
</Stack>
)}
<Stack direction="row" gap={3} mt={6}>
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
fullWidth
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!canWithdraw || mutationLoading || !withdrawAmount}
fullWidth
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</Stack>
</Box>
</Card>
</Box>
)}
{/* Alpha Notice */}
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</Text>
</Box>
</Container>
);
}

View File

@@ -1,300 +0,0 @@
'use client';
import React, { useState } from 'react';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
import {
Wallet,
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react';
interface WalletTemplateProps {
data: LeagueWalletViewModel;
onWithdraw?: (amount: number) => void;
onExport?: () => void;
mutationLoading?: boolean;
}
export function WalletTemplate({ data, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState('');
const [showWithdrawModal, setShowWithdrawModal] = useState(false);
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all');
const filteredTransactions = data.getFilteredTransactions(filterType);
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
return (
<div className="max-w-6xl mx-auto py-8 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-white">League Wallet</h1>
<p className="text-gray-400">Manage your league's finances and payouts</p>
</div>
<div className="flex items-center gap-2">
<Button variant="secondary" onClick={onExport}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!data.canWithdraw || !onWithdraw}
>
<ArrowUpRight className="w-4 h-4 mr-2" />
Withdraw
</Button>
</div>
</div>
{/* Withdrawal Warning */}
{!data.canWithdraw && data.withdrawalBlockReason && (
<div className="mb-6 p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30">
<div className="flex items-start gap-3">
<AlertTriangle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<div>
<h3 className="font-medium text-warning-amber">Withdrawals Temporarily Unavailable</h3>
<p className="text-sm text-gray-400 mt-1">{data.withdrawalBlockReason}</p>
</div>
</div>
</div>
)}
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-performance-green/10">
<Wallet className="w-6 h-6 text-performance-green" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedBalance}</div>
<div className="text-sm text-gray-400">Available Balance</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<TrendingUp className="w-6 h-6 text-primary-blue" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalRevenue}</div>
<div className="text-sm text-gray-400">Total Revenue</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-warning-amber/10">
<DollarSign className="w-6 h-6 text-warning-amber" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedTotalFees}</div>
<div className="text-sm text-gray-400">Platform Fees (10%)</div>
</div>
</div>
</Card>
<Card className="p-4">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-purple-500/10">
<Clock className="w-6 h-6 text-purple-400" />
</div>
<div>
<div className="text-2xl font-bold text-white">{data.formattedPendingPayouts}</div>
<div className="text-sm text-gray-400">Pending Payouts</div>
</div>
</div>
</Card>
</div>
{/* Transactions */}
<Card>
<div className="flex items-center justify-between p-4 border-b border-charcoal-outline">
<h2 className="text-lg font-semibold text-white">Transaction History</h2>
<select
value={filterType}
onChange={(e) => setFilterType(e.target.value as typeof filterType)}
className="px-3 py-1.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white text-sm focus:border-primary-blue focus:outline-none"
>
<option value="all">All Transactions</option>
<option value="sponsorship">Sponsorships</option>
<option value="membership">Memberships</option>
<option value="withdrawal">Withdrawals</option>
<option value="prize">Prizes</option>
</select>
</div>
{filteredTransactions.length === 0 ? (
<div className="text-center py-12">
<Wallet className="w-12 h-12 text-gray-500 mx-auto mb-4" />
<h3 className="text-lg font-medium text-white mb-2">No Transactions</h3>
<p className="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</p>
</div>
) : (
<div>
{filteredTransactions.map((transaction) => (
<TransactionRow key={transaction.id} transaction={transaction} />
))}
</div>
)}
</Card>
{/* Revenue Breakdown */}
<div className="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Revenue Breakdown</h3>
<div className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-primary-blue" />
<span className="text-gray-400">Sponsorships</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-performance-green" />
<span className="text-gray-400">Membership Fees</span>
</div>
<span className="font-medium text-white">$1,600.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-gray-300 font-medium">Total Gross Revenue</span>
<span className="font-bold text-white">$3,200.00</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-warning-amber">Platform Fee (10%)</span>
<span className="text-warning-amber">-$320.00</span>
</div>
<div className="flex items-center justify-between pt-2 border-t border-charcoal-outline">
<span className="text-performance-green font-medium">Net Revenue</span>
<span className="font-bold text-performance-green">$2,880.00</span>
</div>
</div>
</Card>
<Card className="p-4">
<h3 className="text-lg font-semibold text-white mb-4">Payout Schedule</h3>
<div className="space-y-3">
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Season 2 Prize Pool</span>
<span className="text-sm font-medium text-warning-amber">Pending</span>
</div>
<p className="text-xs text-gray-500">
Distributed after season completion to top 3 drivers
</p>
</div>
<div className="p-3 rounded-lg bg-iron-gray/50 border border-charcoal-outline">
<div className="flex items-center justify-between mb-1">
<span className="text-sm font-medium text-white">Available for Withdrawal</span>
<span className="text-sm font-medium text-performance-green">{data.formattedBalance}</span>
</div>
<p className="text-xs text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</p>
</div>
</div>
</Card>
</div>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<Card className="w-full max-w-md p-6">
<h2 className="text-xl font-semibold text-white mb-4">Withdraw Funds</h2>
{!data.canWithdraw ? (
<div className="p-4 rounded-lg bg-warning-amber/10 border border-warning-amber/30 mb-4">
<p className="text-sm text-warning-amber">{data.withdrawalBlockReason}</p>
</div>
) : (
<>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Amount to Withdraw
</label>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500">$</span>
<input
type="number"
value={withdrawAmount}
onChange={(e) => setWithdrawAmount(e.target.value)}
max={data.balance}
className="w-full pl-8 pr-4 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none"
placeholder="0.00"
/>
</div>
<p className="text-xs text-gray-500 mt-1">
Available: {data.formattedBalance}
</p>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-gray-300 mb-2">
Destination
</label>
<select className="w-full px-3 py-2 rounded-lg border border-charcoal-outline bg-iron-gray text-white focus:border-primary-blue focus:outline-none">
<option>Bank Account ***1234</option>
</select>
</div>
</>
)}
<div className="flex gap-3 mt-6">
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
className="flex-1"
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!data.canWithdraw || mutationLoading || !withdrawAmount}
className="flex-1"
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</div>
</Card>
</div>
)}
{/* Alpha Notice */}
<div className="mt-6 rounded-lg bg-warning-amber/10 border border-warning-amber/30 p-4">
<p className="text-xs text-gray-400">
<strong className="text-warning-amber">Alpha Note:</strong> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</p>
</div>
</div>
);
}

View File

@@ -1,13 +1,13 @@
import { LeagueWalletPageQuery } from '@/lib/page-queries/page-queries/LeagueWalletPageQuery';
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
import { LeagueWalletPageQuery } from '@/lib/page-queries/LeagueWalletPageQuery';
import { LeagueWalletPageClient } from './LeagueWalletPageClient';
import { notFound } from 'next/navigation';
interface Props {
params: { id: string };
params: Promise<{ id: string }>;
}
export default async function LeagueWalletPage({ params }: Props) {
const leagueId = params.id;
const { id: leagueId } = await params;
if (!leagueId) {
notFound();
@@ -17,17 +17,24 @@ export default async function LeagueWalletPage({ params }: Props) {
if (result.isErr()) {
const error = result.getError();
if (error.type === 'notFound') {
if (error === 'notFound') {
notFound();
}
// For serverError, show the template with empty data
return <LeagueWalletTemplate viewData={{
return <LeagueWalletPageClient viewData={{
leagueId,
balance: 0,
formattedBalance: '$0.00',
totalRevenue: 0,
formattedTotalRevenue: '$0.00',
totalFees: 0,
formattedTotalFees: '$0.00',
pendingPayouts: 0,
formattedPendingPayouts: '$0.00',
currency: 'USD',
transactions: [],
}} />;
}
return <LeagueWalletTemplate viewData={result.unwrap()} />;
return <LeagueWalletPageClient viewData={result.unwrap()} />;
}

View File

@@ -5,6 +5,10 @@ import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { useAuth } from '@/lib/auth/AuthContext';
import {
AlertCircle,
@@ -22,22 +26,22 @@ import {
Users,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
import { FormEvent, useCallback, useEffect, useState } from 'react';
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService";
import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets";
import { LeagueBasicsSection } from './LeagueBasicsSection';
import { LeagueDropSection } from './LeagueDropSection';
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
import {
ChampionshipsSection,
ScoringPatternSection
} from './LeagueScoringSection';
import { LeagueStewardingSection } from './LeagueStewardingSection';
import { LeagueStructureSection } from './LeagueStructureSection';
import { LeagueTimingsSection } from './LeagueTimingsSection';
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
} from '@/components/leagues/LeagueScoringSection';
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
import type { Weekday } from '@/lib/types/Weekday';
@@ -237,7 +241,7 @@ function createDefaultForm(): LeagueWizardFormModel {
};
}
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
const router = useRouter();
const { session } = useAuth();
@@ -285,7 +289,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
// Sync presets from query to local state
useEffect(() => {
if (queryPresets) {
setPresets(queryPresets);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
setPresets(queryPresets as any);
const firstPreset = queryPresets[0];
if (firstPreset && !form.scoring?.patternId) {
setForm((prev) => ({
@@ -316,7 +321,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const validateStep = (currentStep: Step): boolean => {
// Convert form to LeagueWizardFormData for validation
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
@@ -365,7 +371,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
},
};
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
setErrors((prev) => ({
...prev,
...stepErrors,
@@ -409,7 +416,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
// Convert form to LeagueWizardFormData for validation
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const formData: any = {
leagueId: form.leagueId || '',
basics: {
name: form.basics?.name || '',
@@ -471,7 +479,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
setLoading(true);
setErrors((prev) => {
const { submit, ...rest } = prev;
const { submit: _, ...rest } = prev;
return rest;
});
@@ -587,39 +595,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const CurrentStepIcon = currentStepData?.icon ?? FileText;
return (
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto pb-8">
<Box as="form" onSubmit={handleSubmit} maxWidth="4xl" mx="auto" pb={8}>
{/* Header with icon */}
<div className="mb-8">
<div className="flex items-center gap-3 mb-3">
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
<Sparkles className="w-5 h-5 text-primary-blue" />
</div>
<div>
<Heading level={1} className="text-2xl sm:text-3xl">
<Box mb={8}>
<Stack direction="row" align="center" gap={3} mb={3}>
<Box display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
<Icon icon={Sparkles} size={5} color="text-primary-blue" />
</Box>
<Box>
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
Create a new league
</Heading>
<p className="text-sm text-gray-500">
We'll also set up your first season in {steps.length} easy steps.
</p>
<p className="text-xs text-gray-500 mt-1">
<Text size="sm" color="text-gray-500" block>
We&apos;ll also set up your first season in {steps.length} easy steps.
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
A league is your long-term brand. Each season is a block of races you can run again and again.
</p>
</div>
</div>
</div>
</Text>
</Box>
</Stack>
</Box>
{/* Desktop Progress Bar */}
<div className="hidden md:block mb-8">
<div className="relative">
<Box display={{ base: 'none', md: 'block' }} mb={8}>
<Box position="relative">
{/* Background track */}
<div className="absolute top-5 left-6 right-6 h-0.5 bg-charcoal-outline rounded-full" />
<Box position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
{/* Progress fill */}
<div
className="absolute top-5 left-6 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)` }}
<Box
position="absolute"
top="5"
left="6"
h="0.5"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
/>
<div className="relative flex justify-between">
<Box position="relative" display="flex" justifyContent="between">
{steps.map((wizardStep) => {
const isCompleted = wizardStep.id < step;
const isCurrent = wizardStep.id === step;
@@ -627,148 +641,153 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
const StepIcon = wizardStep.icon;
return (
<button
<Box
as="button"
key={wizardStep.id}
type="button"
onClick={() => goToStep(wizardStep.id)}
disabled={!isAccessible}
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
display="flex"
flexDirection="col"
alignItems="center"
bg="bg-transparent"
borderStyle="none"
cursor={isAccessible ? 'pointer' : 'not-allowed'}
opacity={!isAccessible ? 0.6 : 1}
>
<div
className={`
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
transition-all duration-300 ease-out
${isCurrent
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
: isCompleted
? 'bg-primary-blue text-white hover:scale-105'
: isAccessible
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
}
`}
<Box
position="relative"
zIndex={10}
display="flex"
h="10"
w="10"
alignItems="center"
justifyContent="center"
rounded="full"
transition
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
border={!isCurrent && !isCompleted}
borderColor="border-charcoal-outline"
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
>
{isCompleted ? (
<Check className="w-4 h-4" strokeWidth={3} />
<Icon icon={Check} size={4} strokeWidth={3} />
) : (
<StepIcon className="w-4 h-4" />
<Icon icon={StepIcon} size={4} />
)}
</div>
<div className="mt-2 text-center">
<p
className={`text-xs font-medium transition-colors duration-200 ${
isCurrent
? 'text-white'
: isCompleted
? 'text-primary-blue'
: isAccessible
? 'text-gray-400'
: 'text-gray-500'
}`}
</Box>
<Box mt={2} textAlign="center">
<Text
size="xs"
weight="medium"
transition
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
>
{wizardStep.label}
</p>
</div>
</button>
</Text>
</Box>
</Box>
);
})}
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Mobile Progress */}
<div className="md:hidden mb-6">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<CurrentStepIcon className="w-4 h-4 text-primary-blue" />
<span className="text-sm font-medium text-white">{currentStepData?.label}</span>
</div>
<span className="text-xs text-gray-500">
<Box display={{ base: 'block', md: 'none' }} mb={6}>
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Icon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
<Text size="sm" weight="medium" color="text-white">{currentStepData?.label}</Text>
</Stack>
<Text size="xs" color="text-gray-500">
{step}/{steps.length}
</span>
</div>
<div className="h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
style={{ width: `${(step / steps.length) * 100}%` }}
</Text>
</Box>
<Box h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
<Box
h="full"
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
rounded="full"
transition
height="full"
width={`${(step / steps.length) * 100}%`}
/>
</div>
</Box>
{/* Step dots */}
<div className="flex justify-between mt-2 px-0.5">
<Box display="flex" justifyContent="between" mt={2} px={0.5}>
{steps.map((s) => (
<div
<Box
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step
? 'w-4 bg-primary-blue'
: s.id < step
? 'w-1.5 bg-primary-blue/60'
: 'w-1.5 bg-charcoal-outline'
}
`}
h="1.5"
rounded="full"
transition
width={s.id === step ? '4' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
/>
))}
</div>
</div>
</Box>
</Box>
{/* Main Card */}
<Card className="relative overflow-hidden">
<Card position="relative" overflow="hidden">
{/* Top gradient accent */}
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
<Box position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
{/* Step header */}
<div className="flex items-start gap-4 mb-6">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 shrink-0 transition-transform duration-300">
<CurrentStepIcon className="w-6 h-6 text-primary-blue" />
</div>
<div className="flex-1 min-w-0">
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
<div className="flex items-center gap-2 flex-wrap">
<span>{getStepTitle(step)}</span>
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
<Box display="flex" alignItems="start" gap={4} mb={6}>
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
<Icon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
</Box>
<Box flexGrow={1} minWidth="0">
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
<Stack direction="row" align="center" gap={2} flexWrap="wrap">
<Text>{getStepTitle(step)}</Text>
<Text size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
{getStepContextLabel(step)}
</span>
</div>
</Text>
</Stack>
</Heading>
<p className="text-sm text-gray-400 mt-1">
<Text size="sm" color="text-gray-400" block mt={1}>
{getStepSubtitle(step)}
</p>
</div>
<div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-deep-graphite border border-charcoal-outline">
<span className="text-xs text-gray-500">Step</span>
<span className="text-sm font-semibold text-white">{step}</span>
<span className="text-xs text-gray-500">/ {steps.length}</span>
</div>
</div>
</Text>
</Box>
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
<Text size="xs" color="text-gray-500">Step</Text>
<Text size="sm" weight="semibold" color="text-white">{step}</Text>
<Text size="xs" color="text-gray-500">/ {steps.length}</Text>
</Box>
</Box>
{/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent mb-6" />
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
{/* Step content with min-height for consistency */}
<div className="min-h-[320px]">
<Box minHeight="320px">
{step === 1 && (
<div className="animate-fade-in space-y-8">
<Box animate="fade-in" gap={8} display="flex" flexDirection="col">
<LeagueBasicsSection
form={form}
onChange={setForm}
errors={errors.basics ?? {}}
/>
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
<div className="flex items-center justify-between gap-2 mb-2">
<div>
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
<Box display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
<Box>
<Text size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
First season
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
Name the first season that will run in this league.
</p>
</div>
</div>
<div className="space-y-2 mt-2">
<label className="text-sm font-medium text-gray-300">
</Text>
</Box>
</Box>
<Box mt={2} display="flex" flexDirection="col" gap={2}>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
Season name
</label>
</Text>
<Input
value={form.seasonName ?? ''}
onChange={(e) =>
@@ -779,16 +798,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
}
placeholder="e.g., Season 1 (2025)"
/>
<p className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500" block>
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
</p>
</div>
</div>
</div>
</Text>
</Box>
</Box>
</Box>
)}
{step === 2 && (
<div className="animate-fade-in">
<Box animate="fade-in">
<LeagueVisibilitySection
form={form}
onChange={setForm}
@@ -798,55 +817,55 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
: {}
}
/>
</div>
</Box>
)}
{step === 3 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueStructureSection
form={form}
onChange={setForm}
readOnly={false}
/>
</div>
</Box>
)}
{step === 4 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueTimingsSection
form={form}
onChange={setForm}
errors={errors.timings ?? {}}
/>
</div>
</Box>
)}
{step === 5 && (
<div className="animate-fade-in space-y-8">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={8}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
{/* Scoring Pattern Selection */}
<ScoringPatternSection
scoring={form.scoring || {}}
@@ -866,81 +885,81 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
/>
{/* Divider */}
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
{/* Championships & Drop Rules side by side on larger screens */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
</div>
</Box>
{errors.submit && (
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</div>
</Box>
)}
{step === 6 && (
<div className="animate-fade-in space-y-4">
<div className="mb-2">
<p className="text-xs text-gray-500">
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
<Box mb={2}>
<Text size="xs" color="text-gray-500" block>
Applies to: First season of this league.
</p>
<p className="text-xs text-gray-500">
</Text>
<Text size="xs" color="text-gray-500" block>
These settings only affect this season. Future seasons can use different formats.
</p>
</div>
</Text>
</Box>
<LeagueStewardingSection
form={form}
onChange={setForm}
readOnly={false}
/>
</div>
</Box>
)}
{step === 7 && (
<div className="animate-fade-in space-y-6">
<Box animate="fade-in" display="flex" flexDirection="col" gap={6}>
<LeagueReviewSummary form={form} presets={presets} />
{errors.submit && (
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
<p className="text-sm text-warning-amber">{errors.submit}</p>
</div>
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
</Box>
)}
</div>
</Box>
)}
</div>
</Box>
</Card>
{/* Navigation */}
<div className="flex justify-between items-center mt-6">
<Box display="flex" alignItems="center" justifyContent="between" mt={6}>
<Button
type="button"
variant="secondary"
disabled={step === 1 || loading}
onClick={goToPreviousStep}
className="flex items-center gap-2"
icon={<Icon icon={ChevronLeft} size={4} />}
>
<ChevronLeft className="w-4 h-4" />
<span className="hidden sm:inline">Back</span>
<Text display={{ base: 'none', md: 'inline-block' }}>Back</Text>
</Button>
<div className="flex items-center gap-3">
<Box display="flex" alignItems="center" gap={3}>
{/* Mobile step dots */}
<div className="flex sm:hidden items-center gap-1">
<Box display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
{steps.map((s) => (
<div
<Box
key={s.id}
className={`
h-1.5 rounded-full transition-all duration-300
${s.id === step ? 'w-3 bg-primary-blue' : s.id < step ? 'w-1.5 bg-primary-blue/50' : 'w-1.5 bg-charcoal-outline'}
`}
h="1.5"
rounded="full"
transition
width={s.id === step ? '3' : '1.5'}
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
/>
))}
</div>
</Box>
{step < 7 ? (
<Button
@@ -948,38 +967,34 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
variant="primary"
disabled={loading}
onClick={goToNextStep}
className="flex items-center gap-2"
icon={<Icon icon={ChevronRight} size={4} />}
flexDirection="row-reverse"
>
<span>Continue</span>
<ChevronRight className="w-4 h-4" />
<Text>Continue</Text>
</Button>
) : (
<Button
type="submit"
variant="primary"
disabled={loading}
className="flex items-center gap-2 min-w-[150px] justify-center"
minWidth="150px"
justifyContent="center"
icon={loading ? <Icon icon={Loader2} size={4} animate="spin" /> : <Icon icon={Sparkles} size={4} />}
>
{loading ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
<span>Creating…</span>
</>
<Text>Creating</Text>
) : (
<>
<Sparkles className="w-4 h-4" />
<span>Create League</span>
</>
<Text>Create League</Text>
)}
</Button>
)}
</div>
</div>
</Box>
</Box>
{/* Helper text */}
<p className="text-center text-xs text-gray-500 mt-4">
<Text size="xs" color="text-gray-500" align="center" block mt={4}>
This will create your league and its first season. You can edit both later.
</p>
</form>
</Text>
</Box>
);
}
}

View File

@@ -2,47 +2,42 @@
import React from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
import { CreateLeagueWizard } from './CreateLeagueWizard';
import { Section } from '@/ui/Section';
import { Container } from '@/ui/Container';
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
function normalizeStepName(raw: string | null): StepName {
switch (raw) {
case 'basics':
case 'visibility':
case 'structure':
case 'schedule':
case 'scoring':
case 'stewarding':
case 'review':
return raw;
default:
return 'basics';
}
}
export default function CreateLeaguePage() {
const router = useRouter();
const searchParams = useSearchParams();
const currentStepName = normalizeStepName(
searchParams && typeof searchParams.get === 'function'
? searchParams.get('step')
: null,
);
const wizardParams = SearchParamParser.parseWizard(searchParams as any).unwrap();
const rawStep = wizardParams.step;
let currentStepName: StepName = 'basics';
if (rawStep === 'basics' ||
rawStep === 'visibility' ||
rawStep === 'structure' ||
rawStep === 'schedule' ||
rawStep === 'scoring' ||
rawStep === 'stewarding' ||
rawStep === 'review') {
currentStepName = rawStep;
}
const handleStepChange = (stepName: StepName) => {
const params = new URLSearchParams(
searchParams && typeof searchParams.toString === 'function'
? searchParams.toString()
: '',
);
params.set('step', stepName);
const query = params.toString();
const href = query ? `/leagues/create?${query}` : '/leagues/create';
router.push(href);
const builder = new SearchParamBuilder();
// Copy existing params if needed, but here we just want to set the step
if (searchParams) {
searchParams.forEach((value, key) => {
if (key !== 'step') builder.set(key, value);
});
}
builder.step(stepName);
router.push(`/leagues/create${builder.build()}`);
};
return (

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation';
import { LeaguesClient } from '@/components/leagues/LeaguesClient';
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
import { LeaguesPageClient } from './LeaguesPageClient';
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
export default async function Page() {
// Execute the PageQuery
@@ -20,11 +20,11 @@ export default async function Page() {
case 'UNKNOWN_ERROR':
default:
// Return error state - use LeaguesTemplate with empty data
return <LeaguesClient viewData={{ leagues: [] }} />;
return <LeaguesPageClient viewData={{ leagues: [] }} />;
}
}
const viewData = result.unwrap();
return <LeaguesClient viewData={viewData} />;
return <LeaguesPageClient viewData={viewData} />;
}

View File

@@ -1,22 +1,35 @@
import Link from 'next/link';
'use client';
import { Link } from '@/ui/Link';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
export default function NotFound() {
return (
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
<div className="max-w-md text-center space-y-4">
<h1 className="text-3xl font-semibold">Page not found</h1>
<p className="text-sm text-gray-400">
The page you requested doesn't exist (or isn't available in this mode).
</p>
<div className="pt-2">
<Link
href="/"
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
>
Drive home
</Link>
</div>
</div>
</main>
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" color="text-white" px={6}>
<Box maxWidth="md" textAlign="center">
<Stack gap={4}>
<Heading level={1} fontSize="3xl" weight="semibold">Page not found</Heading>
<Text size="sm" color="text-gray-400" block>
The page you requested doesn&apos;t exist (or isn&apos;t available in this mode).
</Text>
<Box pt={2}>
<Link
href="/"
variant="primary"
size="sm"
weight="medium"
rounded="md"
px={4}
py={2}
>
Drive home
</Link>
</Box>
</Stack>
</Box>
</Box>
);
}

View File

@@ -1,6 +1,6 @@
'use client';
import { OnboardingWizard } from './OnboardingWizard';
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
import { routes } from '@/lib/routing/RouteConfig';
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction';
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction';

View File

@@ -1,6 +1,6 @@
import { redirect } from 'next/navigation';
import { OnboardingWizardClient } from '@/components/onboarding/OnboardingWizardClient';
import { OnboardingPageQuery } from '@/lib/page-queries/page-queries/OnboardingPageQuery';
import { OnboardingWizardClient } from './OnboardingWizardClient';
import { OnboardingPageQuery } from '@/lib/page-queries/OnboardingPageQuery';
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
import { routes } from '@/lib/routing/RouteConfig';

View File

@@ -1,15 +1,27 @@
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import HomeTemplate from '@/templates/HomeTemplate';
import { HomeTemplate, type HomeViewData } from '@/templates/HomeTemplate';
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { getHomeData } from '@/lib/services/home/getHomeData';
import { notFound } from 'next/navigation';
import { HomeService } from '@/lib/services/home/HomeService'; // @server-safe
import { notFound, redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
export default async function Page() {
const data = await PageDataFetcher.fetchManual(async () => getHomeData());
const homeService = new HomeService();
if (await homeService.shouldRedirectToDashboard()) {
redirect(routes.protected.dashboard);
}
const data = await PageDataFetcher.fetchManual(async () => {
const result = await homeService.getHomeData();
return result.isOk() ? result.unwrap() : null;
});
if (!data) {
notFound();
}
return <PageWrapper data={data} Template={HomeTemplate} />;
}
const Template = ({ data }: { data: HomeViewData }) => <HomeTemplate viewData={data} />;
return <PageWrapper data={data} Template={Template} />;
}

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery';
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
export default async function ProfileLeaguesPage() {

View File

@@ -1,11 +1,13 @@
import Link from 'next/link';
import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Grid } from '@/ui/Grid';
import { routes } from '@/lib/routing/RouteConfig';
import { LiveryCard } from '@/ui/LiveryCard';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
export default async function ProfileLiveriesPage() {
const mockLiveries = [
@@ -14,7 +16,7 @@ export default async function ProfileLiveriesPage() {
carId: 'gt3-r',
carName: 'Porsche 911 GT3 R (992)',
thumbnailUrl: '',
uploadedAt: new Date(),
uploadedAt: new globalThis.Date(),
isValidated: true,
},
{
@@ -22,22 +24,22 @@ export default async function ProfileLiveriesPage() {
carId: 'f3',
carName: 'Dallara F3',
thumbnailUrl: '',
uploadedAt: new Date(),
uploadedAt: new globalThis.Date(),
isValidated: false,
}
];
return (
<Container size="lg" py={8}>
<div className="flex items-center justify-between mb-8">
<div>
<Stack direction="row" align="center" justify="between" mb={8}>
<Box>
<Heading level={1}>My Liveries</Heading>
<p className="text-gray-400 mt-1">Manage your custom car liveries</p>
</div>
<Text color="text-gray-400" mt={1} block>Manage your custom car liveries</Text>
</Box>
<Link href={routes.protected.profileLiveryUpload}>
<Button variant="primary">Upload livery</Button>
</Link>
</div>
</Stack>
<Grid cols={3} gap={6}>
{mockLiveries.map((livery) => (
@@ -45,11 +47,11 @@ export default async function ProfileLiveriesPage() {
))}
</Grid>
<div className="mt-12">
<Box mt={12}>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</div>
</Box>
</Container>
);
}

View File

@@ -3,6 +3,7 @@ import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
export default async function ProfileLiveryUploadPage() {
@@ -10,7 +11,7 @@ export default async function ProfileLiveryUploadPage() {
<Container size="md">
<Heading level={1}>Upload livery</Heading>
<Card>
<p>Livery upload is currently unavailable.</p>
<Text block mb={4}>Livery upload is currently unavailable.</Text>
<Link href={routes.protected.profileLiveries}>
<Button variant="secondary">Back to liveries</Button>
</Link>

View File

@@ -1,4 +1,4 @@
import { ProfilePageQuery } from '@/lib/page-queries/page-queries/ProfilePageQuery';
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
import { notFound } from 'next/navigation';
import { updateProfileAction } from './actions';
import { ProfilePageClient } from './ProfilePageClient';

View File

@@ -3,6 +3,7 @@ import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
export default async function ProfileSettingsPage() {
@@ -10,7 +11,7 @@ export default async function ProfileSettingsPage() {
<Container size="md">
<Heading level={1}>Settings</Heading>
<Card>
<p>Settings are currently unavailable.</p>
<Text block mb={4}>Settings are currently unavailable.</Text>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';

View File

@@ -34,8 +34,6 @@ export function RaceDetailPageClient({
onDriverClick,
isOwnerOrAdmin
}: RaceDetailPageClientProps) {
const [showProtestModal, setShowProtestModal] = useState(false);
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
const ratingChange = viewData.userResult?.ratingChange ?? null;

View File

@@ -32,11 +32,11 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
return (
<PageWrapper
data={null}
Template={({ data: _data }) => (
Template={() => (
<RaceDetailTemplate
viewData={undefined}
isLoading={false}
error={new Error('Failed to load race details')}
error={new globalThis.Error('Failed to load race details')}
onBack={() => {}}
onRegister={() => {}}
onWithdraw={() => {}}
@@ -77,7 +77,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
return (
<PageWrapper
data={viewData}
Template={({ data: _data }) => (
Template={() => (
<RaceDetailTemplate
viewData={viewData}
isLoading={false}

View File

@@ -34,20 +34,21 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
<StatefulPageWrapper
data={null}
isLoading={false}
error={new Error('Failed to load race results')}
error={new globalThis.Error('Failed to load race results')}
retry={() => Promise.resolve()}
Template={({ data: _data }) => (
Template={() => (
<RaceResultsTemplate
raceTrack={undefined}
raceScheduledAt={undefined}
totalDrivers={undefined}
leagueName={undefined}
raceSOF={null}
results={[]}
penalties={[]}
pointsSystem={{}}
fastestLapTime={0}
currentDriverId={''}
viewData={{
raceTrack: '',
raceScheduledAt: '',
totalDrivers: 0,
leagueName: '',
raceSOF: null,
results: [],
penalties: [],
pointsSystem: {},
fastestLapTime: 0,
}}
isAdmin={false}
isLoading={false}
error={null}
@@ -82,18 +83,9 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
isLoading={false}
error={null}
retry={() => Promise.resolve()}
Template={({ data: _data }) => (
Template={() => (
<RaceResultsTemplate
raceTrack={viewData.raceTrack}
raceScheduledAt={viewData.raceScheduledAt}
totalDrivers={viewData.totalDrivers}
leagueName={viewData.leagueName}
raceSOF={viewData.raceSOF}
results={viewData.results}
penalties={viewData.penalties}
pointsSystem={viewData.pointsSystem}
fastestLapTime={viewData.fastestLapTime}
currentDriverId={''}
viewData={viewData}
isAdmin={false}
isLoading={false}
error={null}
@@ -117,4 +109,4 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
}}
/>
);
}
}

View File

@@ -1,9 +1,12 @@
'use client';
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
import { Gavel } from 'lucide-react';
import { useState } from 'react';
import { useState, useEffect, useCallback } from 'react';
interface RaceStewardingPageProps {
params: {
@@ -20,12 +23,12 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
}
// Data state
const [pageData, setPageData] = useState<any>(null);
const [pageData, setPageData] = useState<RaceStewardingViewData | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Fetch function
const fetchData = async () => {
const fetchData = useCallback(async () => {
setIsLoading(true);
setError(null);
@@ -33,40 +36,31 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
const result = await RaceStewardingPageQuery.execute({ raceId });
if (result.isErr()) {
throw new Error('Failed to fetch stewarding data');
throw new globalThis.Error('Failed to fetch stewarding data');
}
setPageData(result.unwrap());
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
setError(err instanceof globalThis.Error ? err : new globalThis.Error('Unknown error'));
} finally {
setIsLoading(false);
}
};
}, [raceId]);
// Transform data for template
const templateData = pageData ? {
race: pageData.race,
league: pageData.league,
pendingProtests: pageData.pendingProtests,
resolvedProtests: pageData.resolvedProtests,
penalties: pageData.penalties,
driverMap: pageData.driverMap,
pendingCount: pageData.pendingCount,
resolvedCount: pageData.resolvedCount,
penaltiesCount: pageData.penaltiesCount,
} : undefined;
useEffect(() => {
fetchData();
}, [fetchData]);
// Actions
const handleBack = () => {
const handleBack = useCallback(() => {
window.history.back();
};
}, []);
const handleReviewProtest = (protestId: string) => {
if (templateData?.league?.id) {
window.location.href = `/leagues/${templateData.league.id}/stewarding/protests/${protestId}`;
const handleReviewProtest = useCallback((protestId: string) => {
if (pageData?.league?.id) {
window.location.href = `/leagues/${pageData.league.id}/stewarding/protests/${protestId}`;
}
};
}, [pageData?.league?.id]);
return (
<PageWrapper
@@ -74,9 +68,9 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
isLoading={isLoading}
error={error}
retry={fetchData}
Template={({ data: _data }) => (
Template={({ data }) => (
<RaceStewardingTemplate
stewardingData={templateData}
viewData={data as RaceStewardingViewData}
isLoading={false}
error={null}
onBack={handleBack}
@@ -96,4 +90,4 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
}}
/>
);
}
}

View File

@@ -0,0 +1,140 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
import { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
import { type RacesViewData, type RaceViewData } from '@/lib/view-data/RacesViewData';
import { Flag } from 'lucide-react';
import { routes } from '@/lib/routing/RouteConfig';
const ITEMS_PER_PAGE = 10;
export function RacesAllPageClient({ initialViewData }: { initialViewData: unknown }) {
const router = useRouter();
// Client-side state for filters and pagination
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Data state
const [pageData, setPageData] = useState<RacesViewData | null>(initialViewData as RacesViewData);
const [isLoading, setIsLoading] = useState(!initialViewData);
const [error, setError] = useState<Error | null>(null);
// Fetch data
const fetchData = useCallback(async () => {
if (pageData && !isLoading) return; // Already have data from server
setIsLoading(true);
setError(null);
try {
const result = await RacesAllPageQuery.execute();
if (result.isErr()) {
throw new globalThis.Error('Failed to fetch races');
}
setPageData(result.unwrap() as unknown as RacesViewData);
} catch (err) {
setError(err instanceof globalThis.Error ? err : new globalThis.Error('Unknown error'));
} finally {
setIsLoading(false);
}
}, [pageData, isLoading]);
// Fetch on mount if no initial data
useEffect(() => {
if (!initialViewData) {
fetchData();
}
}, [initialViewData, fetchData]);
// Transform data
const races: RaceViewData[] = pageData?.races ?? [];
// Filter and paginate (Note: This should be done by API per contract)
const filteredRaces = races.filter((race: RaceViewData) => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(routes.race.detail(raceId));
};
const handleLeagueClick = (leagueId: string) => {
router.push(routes.league.detail(leagueId));
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<StatefulPageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={fetchData}
Template={() => pageData ? (
<RacesAllTemplate
viewData={pageData}
races={paginatedRaces}
totalFilteredCount={filteredRaces.length}
isLoading={false}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
) : null}
loading={{ variant: 'skeleton', message: 'Loading races...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Flag,
title: 'No races found',
description: 'There are no races available at the moment',
}}
/>
);
}

View File

@@ -1,156 +1,20 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
import { notFound } from 'next/navigation';
import { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
import { Flag } from 'lucide-react';
import { RacesAllPageClient } from './RacesAllPageClient';
const ITEMS_PER_PAGE = 10;
interface Race {
id: string;
track: string;
car: string;
scheduledAt: string;
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
sessionType: string;
leagueId?: string;
leagueName?: string;
strengthOfField?: number;
}
export default function RacesAllPage() {
const router = useRouter();
export default async function Page() {
// Execute the PageQuery
const result = await RacesAllPageQuery.execute();
// Client-side state for filters and pagination
const [currentPage, setCurrentPage] = useState(1);
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
const [leagueFilter, setLeagueFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [showFilters, setShowFilters] = useState(false);
const [showFilterModal, setShowFilterModal] = useState(false);
// Data state
const [pageData, setPageData] = useState<any>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
// Fetch data
const fetchData = async () => {
setIsLoading(true);
setError(null);
try {
const result = await RacesAllPageQuery.execute();
if (result.isErr()) {
throw new Error('Failed to fetch races');
}
setPageData(result.unwrap());
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
};
// For other errors, we still render the client component which handles its own loading/error states
}
// Fetch on mount
useEffect(() => {
fetchData();
}, []);
const viewData = result.isOk() ? result.unwrap() : null;
// Transform data
const races: Race[] = pageData?.races.map((race: any) => ({
id: race.id,
track: race.track,
car: race.car,
scheduledAt: race.scheduledAt,
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
sessionType: 'race',
leagueId: race.leagueId,
leagueName: race.leagueName,
strengthOfField: race.strengthOfField ?? undefined,
})) ?? [];
// Filter and paginate (Note: This should be done by API per contract)
const filteredRaces = races.filter((race: Race) => {
if (statusFilter !== 'all' && race.status !== statusFilter) {
return false;
}
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
return false;
}
if (searchQuery) {
const query = searchQuery.toLowerCase();
const matchesTrack = race.track.toLowerCase().includes(query);
const matchesCar = race.car.toLowerCase().includes(query);
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
if (!matchesTrack && !matchesCar && !matchesLeague) {
return false;
}
}
return true;
});
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
// Actions
const handleRaceClick = (raceId: string) => {
router.push(`/races/${raceId}`);
};
const handleLeagueClick = (leagueId: string) => {
router.push(`/leagues/${leagueId}`);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
return (
<StatefulPageWrapper
data={pageData}
isLoading={isLoading}
error={error}
retry={fetchData}
Template={({ data: _data }) => (
<RacesAllTemplate
viewData={pageData}
races={paginatedRaces as any}
totalFilteredCount={filteredRaces.length}
isLoading={false}
currentPage={currentPage}
totalPages={totalPages}
itemsPerPage={ITEMS_PER_PAGE}
onPageChange={handlePageChange}
statusFilter={statusFilter}
setStatusFilter={setStatusFilter}
leagueFilter={leagueFilter}
setLeagueFilter={setLeagueFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
showFilters={showFilters}
setShowFilters={setShowFilters}
showFilterModal={showFilterModal}
setShowFilterModal={setShowFilterModal}
onRaceClick={handleRaceClick}
onLeagueClick={handleLeagueClick}
/>
)}
loading={{ variant: 'skeleton', message: 'Loading races...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Flag,
title: 'No races found',
description: 'There are no races available at the moment',
}}
/>
);
}
return <RacesAllPageClient initialViewData={viewData} />;
}

View File

@@ -5,12 +5,16 @@ import { motion, useReducedMotion } from 'framer-motion';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { StatCard } from '@/ui/StatCard';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { SectionHeader } from '@/ui/SectionHeader';
import { StatusBadge } from '@/ui/StatusBadge';
import { InfoBanner } from '@/ui/InfoBanner';
import { PageHeader } from '@/ui/PageHeader';
import { Icon } from '@/ui/Icon';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
import {
CreditCard,
DollarSign,
@@ -20,7 +24,6 @@ import {
Check,
AlertTriangle,
FileText,
ArrowRight,
TrendingUp,
Receipt,
Building2,
@@ -29,62 +32,21 @@ import {
ChevronRight,
Info,
ExternalLink,
Percent
Percent,
Loader2
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
interface PaymentMethod {
id: string;
type: 'card' | 'bank' | 'sepa';
last4: string;
brand?: string;
isDefault: boolean;
expiryMonth?: number;
expiryYear?: number;
bankName?: string;
}
interface Invoice {
id: string;
invoiceNumber: string;
date: Date;
dueDate: Date;
amount: number;
vatAmount: number;
totalAmount: number;
status: 'paid' | 'pending' | 'overdue' | 'failed';
description: string;
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
pdfUrl: string;
}
interface BillingStats {
totalSpent: number;
pendingAmount: number;
nextPaymentDate: Date;
nextPaymentAmount: number;
activeSponsorships: number;
averageMonthlySpend: number;
}
// ============================================================================
// Mock Data
// ============================================================================
import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO';
// ============================================================================
// Components
// ============================================================================
function PaymentMethodCard({
function PaymentMethodCardComponent({
method,
onSetDefault,
onRemove
}: {
method: any;
method: PaymentMethodDTO;
onSetDefault: () => void;
onRemove: () => void;
}) {
@@ -95,68 +57,81 @@ function PaymentMethodCard({
return CreditCard;
};
const Icon = getIcon();
const MethodIcon = getIcon();
const getLabel = () => {
if (method.type === 'sepa' && method.bankName) {
return `${method.bankName} •••• ${method.last4}`;
}
return `${method.brand} •••• ${method.last4}`;
};
const displayLabel = method.type === 'sepa' && method.bankName
? `${method.bankName} •••• ${method.last4}`
: `${method.brand} •••• ${method.last4}`;
const expiryDisplay = method.expiryMonth && method.expiryYear
? `${method.expiryMonth}/${method.expiryYear}`
: null;
return (
<motion.div
<Box
as={motion.div}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
className={`p-4 rounded-xl border transition-all ${
method.isDefault
? 'border-primary-blue/50 bg-gradient-to-r from-primary-blue/10 to-transparent shadow-[0_0_20px_rgba(25,140,255,0.1)]'
: 'border-charcoal-outline bg-iron-gray/30 hover:border-charcoal-outline/80'
}`}
p={4}
rounded="xl"
border
borderColor={method.isDefault ? 'border-primary-blue/50' : 'border-charcoal-outline'}
bg={method.isDefault ? 'bg-gradient-to-r from-primary-blue/10 to-transparent' : 'bg-iron-gray/30'}
shadow={method.isDefault ? '0_0_20px_rgba(25,140,255,0.1)' : undefined}
hoverBorderColor={!method.isDefault ? 'border-charcoal-outline/80' : undefined}
transition-all
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'
}`}>
<Icon className={`w-6 h-6 ${method.isDefault ? 'text-primary-blue' : 'text-gray-400'}`} />
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-white">{method.displayLabel}</span>
<Box display="flex" alignItems="center" justifyContent="between">
<Box display="flex" alignItems="center" gap={4}>
<Box
w="12"
h="12"
rounded="xl"
display="flex"
alignItems="center"
justifyContent="center"
bg={method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'}
>
<Icon icon={MethodIcon} size={6} color={method.isDefault ? 'text-primary-blue' : 'text-gray-400'} />
</Box>
<Box>
<Box display="flex" alignItems="center" gap={2}>
<Text weight="medium" color="text-white">{displayLabel}</Text>
{method.isDefault && (
<span className="px-2 py-0.5 rounded-full text-xs bg-primary-blue/20 text-primary-blue font-medium">
Default
</span>
<Box px={2} py={0.5} rounded="full" bg="bg-primary-blue/20">
<Text size="xs" color="text-primary-blue" weight="medium">
Default
</Text>
</Box>
)}
</div>
{method.expiryDisplay && (
<span className="text-sm text-gray-500">
Expires {method.expiryDisplay}
</span>
</Box>
{expiryDisplay && (
<Text size="sm" color="text-gray-500" block>
Expires {expiryDisplay}
</Text>
)}
{method.type === 'sepa' && (
<span className="text-sm text-gray-500">SEPA Direct Debit</span>
<Text size="sm" color="text-gray-500" block>SEPA Direct Debit</Text>
)}
</div>
</div>
<div className="flex items-center gap-2">
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
{!method.isDefault && (
<Button variant="secondary" onClick={onSetDefault} className="text-xs">
<Button variant="secondary" onClick={onSetDefault} size="sm">
Set Default
</Button>
)}
<Button variant="secondary" onClick={onRemove} className="text-xs text-gray-400 hover:text-racing-red">
<Button variant="secondary" onClick={onRemove} size="sm" color="text-gray-400" hoverTextColor="text-racing-red">
Remove
</Button>
</div>
</div>
</motion.div>
</Box>
</Box>
</Box>
);
}
function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
function InvoiceRowComponent({ invoice, index }: { invoice: InvoiceDTO; index: number }) {
const shouldReduceMotion = useReducedMotion();
const statusConfig = {
@@ -202,54 +177,64 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
const StatusIcon = status.icon;
return (
<motion.div
<Box
as={motion.div}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2, delay: shouldReduceMotion ? 0 : index * 0.05 }}
className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 last:border-b-0 hover:bg-iron-gray/20 transition-colors group"
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
hoverBg="bg-iron-gray/20"
transition-colors
group
>
<div className="flex items-center gap-4 flex-1">
<div className="w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center">
<Receipt className="w-5 h-5 text-gray-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="font-medium text-white truncate">{invoice.description}</span>
<span className="px-2 py-0.5 rounded text-xs bg-iron-gray text-gray-400 flex-shrink-0">
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
</span>
</div>
<div className="flex items-center gap-3 text-sm text-gray-500">
<span>{invoice.invoiceNumber}</span>
<span></span>
<span>
{invoice.formattedDate}
</span>
</div>
</div>
</div>
<Box display="flex" alignItems="center" gap={4} flexGrow={1}>
<Box w="10" h="10" rounded="lg" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center">
<Icon icon={Receipt} size={5} color="text-gray-400" />
</Box>
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} mb={0.5}>
<Text weight="medium" color="text-white" truncate>{invoice.description}</Text>
<Box px={2} py={0.5} rounded bg="bg-iron-gray">
<Text size="xs" color="text-gray-400">
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
</Text>
</Box>
</Box>
<Box display="flex" alignItems="center" gap={3}>
<Text size="sm" color="text-gray-500">{invoice.invoiceNumber}</Text>
<Text size="sm" color="text-gray-500"></Text>
<Text size="sm" color="text-gray-500">
{new globalThis.Date(invoice.date).toLocaleDateString()}
</Text>
</Box>
</Box>
</Box>
<div className="flex items-center gap-6">
<div className="text-right">
<div className="font-semibold text-white">
{invoice.formattedTotalAmount}
</div>
<div className="text-xs text-gray-500">
incl. {invoice.formattedVatAmount} VAT
</div>
</div>
<Box display="flex" alignItems="center" gap={6}>
<Box textAlign="right">
<Text weight="semibold" color="text-white" block>
${invoice.totalAmount.toFixed(2)}
</Text>
<Text size="xs" color="text-gray-500" block>
incl. ${invoice.vatAmount.toFixed(2)} VAT
</Text>
</Box>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.bg} ${status.color} border ${status.border}`}>
<StatusIcon className="w-3 h-3" />
{status.label}
</div>
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" bg={status.bg} border borderColor={status.border}>
<Icon icon={StatusIcon} size={3} color={status.color} />
<Text size="xs" weight="medium" color={status.color}>{status.label}</Text>
</Box>
<Button variant="secondary" className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">
<Download className="w-3 h-3 mr-1" />
<Button variant="secondary" size="sm" opacity={0} groupHoverTextColor="opacity-100" transition-opacity icon={<Icon icon={Download} size={3} />}>
PDF
</Button>
</div>
</motion.div>
</Box>
</Box>
);
}
@@ -265,27 +250,27 @@ export default function SponsorBillingPage() {
if (isLoading) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
<p className="text-gray-400">Loading billing data...</p>
</div>
</div>
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
<Text color="text-gray-400">Loading billing data...</Text>
</Box>
</Box>
);
}
if (error || !billingData) {
return (
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
<div className="text-center">
<p className="text-gray-400">{error?.getUserMessage() || 'No billing data available'}</p>
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Text color="text-gray-400" block>{error?.message || 'No billing data available'}</Text>
{error && (
<Button variant="secondary" onClick={retry} className="mt-4">
<Button variant="secondary" onClick={retry} mt={4}>
Retry
</Button>
)}
</div>
</div>
</Box>
</Box>
);
}
@@ -297,7 +282,7 @@ export default function SponsorBillingPage() {
};
const handleRemoveMethod = (methodId: string) => {
if (confirm('Remove this payment method?')) {
if (window.confirm('Remove this payment method?')) {
// In a real app, this would call an API
console.log('Removing payment method:', methodId);
}
@@ -319,14 +304,19 @@ export default function SponsorBillingPage() {
};
return (
<motion.div
className="max-w-5xl mx-auto py-8 px-4"
<Box
maxWidth="5xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<PageHeader
icon={Wallet}
title="Billing & Payments"
@@ -334,190 +324,178 @@ export default function SponsorBillingPage() {
iconGradient="from-warning-amber/20 to-warning-amber/5"
iconBorder="border-warning-amber/30"
/>
</motion.div>
</Box>
{/* Stats Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2, lg: 4 }} gap={4} mb={8}>
<StatCard
icon={DollarSign}
label="Total Spent"
value={data.stats.formattedTotalSpent}
value={`$${data.stats.totalSpent.toFixed(2)}`}
subValue="All time"
color="text-performance-green"
bgColor="bg-performance-green/10"
variant="green"
/>
<StatCard
icon={AlertTriangle}
label="Pending Payments"
value={data.stats.formattedPendingAmount}
value={`$${data.stats.pendingAmount.toFixed(2)}`}
subValue={`${data.invoices.filter((i: { status: string }) => i.status === 'pending' || i.status === 'overdue').length} invoices`}
color="text-warning-amber"
bgColor="bg-warning-amber/10"
variant="orange"
/>
<StatCard
icon={Calendar}
label="Next Payment"
value={data.stats.formattedNextPaymentDate}
subValue={data.stats.formattedNextPaymentAmount}
color="text-primary-blue"
bgColor="bg-primary-blue/10"
value={new globalThis.Date(data.stats.nextPaymentDate).toLocaleDateString()}
subValue={`$${data.stats.nextPaymentAmount.toFixed(2)}`}
variant="blue"
/>
<StatCard
icon={TrendingUp}
label="Monthly Average"
value={data.stats.formattedAverageMonthlySpend}
value={`$${data.stats.averageMonthlySpend.toFixed(2)}`}
subValue="Last 6 months"
color="text-gray-400"
bgColor="bg-iron-gray"
variant="blue"
/>
</motion.div>
</Box>
{/* Payment Methods */}
<motion.div variants={itemVariants}>
<Card className="mb-8 overflow-hidden">
<Box as={motion.div} variants={itemVariants}>
<Card mb={8} overflow="hidden">
<SectionHeader
icon={CreditCard}
title="Payment Methods"
action={
<Button variant="secondary" className="text-sm">
<Plus className="w-4 h-4 mr-2" />
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Add Payment Method
</Button>
}
/>
<div className="p-5 space-y-3">
{data.paymentMethods.map((method: { id: string; type: string; last4: string; brand: string; default: boolean }) => (
<PaymentMethodCard
<Box p={5} display="flex" flexDirection="col" gap={3}>
{data.paymentMethods.map((method: PaymentMethodDTO) => (
<PaymentMethodCardComponent
key={method.id}
method={method}
onSetDefault={() => handleSetDefault(method.id)}
onRemove={() => handleRemoveMethod(method.id)}
/>
))}
</div>
<div className="px-5 pb-5">
</Box>
<Box px={5} pb={5}>
<InfoBanner type="info">
<p className="mb-1">We support Visa, Mastercard, American Express, and SEPA Direct Debit.</p>
<p>All payment information is securely processed and stored by our payment provider.</p>
<Text block mb={1}>We support Visa, Mastercard, American Express, and SEPA Direct Debit.</Text>
<Text block>All payment information is securely processed and stored by our payment provider.</Text>
</InfoBanner>
</div>
</Box>
</Card>
</motion.div>
</Box>
{/* Billing History */}
<motion.div variants={itemVariants}>
<Card className="mb-8 overflow-hidden">
<Box as={motion.div} variants={itemVariants}>
<Card mb={8} overflow="hidden">
<SectionHeader
icon={FileText}
title="Billing History"
color="text-warning-amber"
action={
<Button variant="secondary" className="text-sm">
<Download className="w-4 h-4 mr-2" />
<Button variant="secondary" size="sm" icon={<Icon icon={Download} size={4} />}>
Export All
</Button>
}
/>
<div>
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: { id: string; date: string; amount: number; status: string }, index: number) => (
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
<Box>
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: InvoiceDTO, index: number) => (
<InvoiceRowComponent key={invoice.id} invoice={invoice} index={index} />
))}
</div>
</Box>
{data.invoices.length > 4 && (
<div className="p-4 border-t border-charcoal-outline">
<Box p={4} borderTop borderColor="border-charcoal-outline">
<Button
variant="secondary"
className="w-full"
fullWidth
onClick={() => setShowAllInvoices(!showAllInvoices)}
icon={<Icon icon={ChevronRight} size={4} className={showAllInvoices ? 'rotate-90' : ''} />}
flexDirection="row-reverse"
>
{showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`}
<ChevronRight className={`w-4 h-4 ml-2 transition-transform ${showAllInvoices ? 'rotate-90' : ''}`} />
</Button>
</div>
</Box>
)}
</Card>
</motion.div>
</Box>
{/* Platform Fee & VAT Information */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
{/* Platform Fee */}
<Card className="overflow-hidden">
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
<h3 className="font-semibold text-white flex items-center gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50">
<Percent className="w-4 h-4 text-primary-blue" />
</div>
<Card overflow="hidden">
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Percent} size={4} color="text-primary-blue" /></Box>}>
Platform Fee
</h3>
</div>
<div className="p-5">
<div className="text-3xl font-bold text-white mb-2">
</Heading>
</Box>
<Box p={5}>
<Text size="3xl" weight="bold" color="text-white" block mb={2}>
{siteConfig.fees.platformFeePercent}%
</div>
<p className="text-sm text-gray-400 mb-4">
</Text>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.fees.description}
</p>
<div className="text-xs text-gray-500 space-y-1">
<p> Applied to all sponsorship payments</p>
<p> Covers platform maintenance and analytics</p>
<p> Ensures quality sponsorship placements</p>
</div>
</div>
</Text>
<Box display="flex" flexDirection="col" gap={1}>
<Text size="xs" color="text-gray-500" block> Applied to all sponsorship payments</Text>
<Text size="xs" color="text-gray-500" block> Covers platform maintenance and analytics</Text>
<Text size="xs" color="text-gray-500" block> Ensures quality sponsorship placements</Text>
</Box>
</Box>
</Card>
{/* VAT Information */}
<Card className="overflow-hidden">
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
<h3 className="font-semibold text-white flex items-center gap-3">
<div className="p-2 rounded-lg bg-iron-gray/50">
<Receipt className="w-4 h-4 text-performance-green" />
</div>
<Card overflow="hidden">
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Receipt} size={4} color="text-performance-green" /></Box>}>
VAT Information
</h3>
</div>
<div className="p-5">
<p className="text-sm text-gray-400 mb-4">
</Heading>
</Box>
<Box p={5}>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.vat.notice}
</p>
<div className="space-y-3 text-sm">
<div className="flex justify-between items-center py-2 border-b border-charcoal-outline/50">
<span className="text-gray-500">Standard VAT Rate</span>
<span className="text-white font-medium">{siteConfig.vat.standardRate}%</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-gray-500">B2B Reverse Charge</span>
<span className="text-performance-green font-medium">Available</span>
</div>
</div>
<p className="text-xs text-gray-500 mt-4">
</Text>
<Box display="flex" flexDirection="col" gap={3}>
<Box display="flex" justifyContent="between" alignItems="center" py={2} borderBottom borderColor="border-charcoal-outline/50">
<Text color="text-gray-500">Standard VAT Rate</Text>
<Text color="text-white" weight="medium">{siteConfig.vat.standardRate}%</Text>
</Box>
<Box display="flex" justifyContent="between" alignItems="center" py={2}>
<Text color="text-gray-500">B2B Reverse Charge</Text>
<Text color="text-performance-green" weight="medium">Available</Text>
</Box>
</Box>
<Text size="xs" color="text-gray-500" block mt={4}>
Enter your VAT ID in Settings to enable reverse charge for B2B transactions.
</p>
</div>
</Text>
</Box>
</Card>
</motion.div>
</Box>
{/* Billing Support */}
<motion.div variants={itemVariants} className="mt-6">
<Card className="p-5">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-3 rounded-xl bg-iron-gray">
<Info className="w-5 h-5 text-gray-400" />
</div>
<div>
<h3 className="font-medium text-white">Need help with billing?</h3>
<p className="text-sm text-gray-500">
<Box as={motion.div} variants={itemVariants} mt={6}>
<Card p={5}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={4}>
<Box p={3} rounded="xl" bg="bg-iron-gray">
<Icon icon={Info} size={5} color="text-gray-400" />
</Box>
<Box>
<Heading level={3} fontSize="base" weight="medium" color="text-white">Need help with billing?</Heading>
<Text size="sm" color="text-gray-500" block>
Contact our billing support for questions about invoices, payments, or refunds.
</p>
</div>
</div>
<Button variant="secondary">
</Text>
</Box>
</Stack>
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
Contact Support
<ExternalLink className="w-4 h-4 ml-2" />
</Button>
</div>
</Box>
</Card>
</motion.div>
</motion.div>
</Box>
</Box>
);
}
}

View File

@@ -2,12 +2,16 @@
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
import { motion, useReducedMotion } from 'framer-motion';
import Link from 'next/link';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { InfoBanner } from '@/ui/InfoBanner';
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
import {
Megaphone,
Trophy,
@@ -23,17 +27,14 @@ import {
Car,
Flag,
Search,
Filter,
TrendingUp,
BarChart3,
ArrowUpRight,
ArrowDownRight,
AlertCircle,
Send,
ThumbsUp,
ThumbsDown,
RefreshCw,
Handshake
} from 'lucide-react';
// ============================================================================
@@ -98,26 +99,27 @@ const STATUS_CONFIG = {
// Components
// ============================================================================
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
function SponsorshipCard({ sponsorship }: { sponsorship: unknown }) {
const shouldReduceMotion = useReducedMotion();
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
const statusConfig = STATUS_CONFIG[sponsorship.status as keyof typeof STATUS_CONFIG];
const s = sponsorship as any; // Temporary cast to avoid breaking logic
const typeConfig = TYPE_CONFIG[s.type as keyof typeof TYPE_CONFIG];
const statusConfig = STATUS_CONFIG[s.status as keyof typeof STATUS_CONFIG];
const TypeIcon = typeConfig.icon;
const StatusIcon = statusConfig.icon;
const daysRemaining = Math.ceil((sponsorship.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
const daysRemaining = Math.ceil((s.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysRemaining > 0 && daysRemaining <= 30;
const isPending = sponsorship.status === 'pending_approval';
const isRejected = sponsorship.status === 'rejected';
const isApproved = sponsorship.status === 'approved';
const isPending = s.status === 'pending_approval';
const isRejected = s.status === 'rejected';
const isApproved = s.status === 'approved';
const getEntityLink = () => {
switch (sponsorship.type) {
case 'leagues': return `/leagues/${sponsorship.entityId}`;
case 'teams': return `/teams/${sponsorship.entityId}`;
case 'drivers': return `/drivers/${sponsorship.entityId}`;
case 'races': return `/races/${sponsorship.entityId}`;
switch (s.type) {
case 'leagues': return `/leagues/${s.entityId}`;
case 'teams': return `/teams/${s.entityId}`;
case 'drivers': return `/drivers/${s.entityId}`;
case 'races': return `/races/${s.entityId}`;
default: return '#';
}
};
@@ -135,172 +137,166 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
isApproved ? 'border-primary-blue/30' : ''
}`}>
{/* Header */}
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${typeConfig.bgColor} flex items-center justify-center`}>
<Stack direction="row" align="start" justify="between" mb={4}>
<Stack direction="row" align="center" gap={3}>
<Box w="10" h="10" rounded="lg" bg={typeConfig.bgColor} display="flex" alignItems="center" justifyContent="center">
<TypeIcon className={`w-5 h-5 ${typeConfig.color}`} />
</div>
<div>
<div className="flex items-center gap-2 flex-wrap">
<span className={`text-xs font-medium px-2 py-0.5 rounded ${typeConfig.bgColor} ${typeConfig.color}`}>
</Box>
<Box>
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
<Text size="xs" weight="medium" px={2} py={0.5} rounded="md" bg={typeConfig.bgColor} color={typeConfig.color}>
{typeConfig.label}
</span>
{sponsorship.tier && (
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
sponsorship.tier === 'main'
? 'bg-primary-blue/20 text-primary-blue'
: 'bg-purple-400/20 text-purple-400'
}`}>
{sponsorship.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
</span>
</Text>
{s.tier && (
<Text size="xs" weight="medium" px={2} py={0.5} rounded="md" bg={s.tier === 'main' ? 'bg-primary-blue/20' : 'bg-purple-400/20'} color={s.tier === 'main' ? 'text-primary-blue' : 'text-purple-400'}>
{s.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
</Text>
)}
</div>
</div>
</div>
<div className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.bgColor} ${statusConfig.color} ${statusConfig.borderColor}`}>
</Box>
</Box>
</Stack>
<Box display="flex" alignItems="center" gap={1} px={2.5} py={1} rounded="full" border bg={statusConfig.bgColor} color={statusConfig.color} borderColor={statusConfig.borderColor}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
</div>
<Text size="xs" weight="medium">{statusConfig.label}</Text>
</Box>
</Stack>
{/* Entity Name */}
<h3 className="text-lg font-semibold text-white mb-1">{sponsorship.entityName}</h3>
{sponsorship.details && (
<p className="text-sm text-gray-500 mb-3">{sponsorship.details}</p>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={1}>{s.entityName}</Heading>
{s.details && (
<Text size="sm" color="text-gray-500" block mb={3}>{s.details}</Text>
)}
{/* Application/Approval Info for non-active states */}
{isPending && (
<div className="mb-4 p-3 rounded-lg bg-warning-amber/5 border border-warning-amber/20">
<div className="flex items-center gap-2 text-warning-amber text-sm mb-2">
<Box mb={4} p={3} rounded="lg" bg="bg-warning-amber/5" border borderColor="border-warning-amber/20">
<Stack direction="row" align="center" gap={2} color="text-warning-amber" mb={2}>
<Send className="w-4 h-4" />
<span className="font-medium">Application Pending</span>
</div>
<p className="text-xs text-gray-400 mb-2">
Sent to <span className="text-gray-300">{sponsorship.entityOwner}</span> on{' '}
{sponsorship.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
{sponsorship.applicationMessage && (
<p className="text-xs text-gray-500 italic">"{sponsorship.applicationMessage}"</p>
<Text size="sm" weight="medium">Application Pending</Text>
</Stack>
<Text size="xs" color="text-gray-400" block mb={2}>
Sent to <Text color="text-gray-300">{s.entityOwner}</Text> on{' '}
{s.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</Text>
{s.applicationMessage && (
<Text size="xs" color="text-gray-500" italic block>&quot;{s.applicationMessage}&quot;</Text>
)}
</div>
</Box>
)}
{isApproved && (
<div className="mb-4 p-3 rounded-lg bg-primary-blue/5 border border-primary-blue/20">
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
<Box mb={4} p={3} rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20">
<Stack direction="row" align="center" gap={2} color="text-primary-blue" mb={1}>
<ThumbsUp className="w-4 h-4" />
<span className="font-medium">Approved!</span>
</div>
<p className="text-xs text-gray-400">
Approved by <span className="text-gray-300">{sponsorship.entityOwner}</span> on{' '}
{sponsorship.approvalDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</p>
<p className="text-xs text-gray-500 mt-1">
Starts {sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</p>
</div>
<Text size="sm" weight="medium">Approved!</Text>
</Stack>
<Text size="xs" color="text-gray-400" block>
Approved by <Text color="text-gray-300">{s.entityOwner}</Text> on{' '}
{s.approvalDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</Text>
<Text size="xs" color="text-gray-500" block mt={1}>
Starts {s.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
</Text>
</Box>
)}
{isRejected && (
<div className="mb-4 p-3 rounded-lg bg-racing-red/5 border border-racing-red/20">
<div className="flex items-center gap-2 text-racing-red text-sm mb-1">
<Box mb={4} p={3} rounded="lg" bg="bg-racing-red/5" border borderColor="border-racing-red/20">
<Stack direction="row" align="center" gap={2} color="text-racing-red" mb={1}>
<ThumbsDown className="w-4 h-4" />
<span className="font-medium">Application Declined</span>
</div>
{sponsorship.rejectionReason && (
<p className="text-xs text-gray-400 mt-1">
Reason: <span className="text-gray-300">{sponsorship.rejectionReason}</span>
</p>
<Text size="sm" weight="medium">Application Declined</Text>
</Stack>
{s.rejectionReason && (
<Text size="xs" color="text-gray-400" block mt={1}>
Reason: <Text color="text-gray-300">{s.rejectionReason}</Text>
</Text>
)}
<Button variant="secondary" className="mt-2 text-xs">
<RefreshCw className="w-3 h-3 mr-1" />
Reapply
</Button>
</div>
</Box>
)}
{/* Metrics Grid - Only show for active sponsorships */}
{sponsorship.status === 'active' && (
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
{s.status === 'active' && (
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={3} mb={4}>
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
<Eye className="w-3 h-3" />
Impressions
</div>
<div className="flex items-center gap-2">
<span className="text-white font-semibold">{sponsorship.formattedImpressions}</span>
{sponsorship.impressionsChange !== undefined && sponsorship.impressionsChange !== 0 && (
<span className={`text-xs flex items-center ${
sponsorship.impressionsChange > 0 ? 'text-performance-green' : 'text-racing-red'
}`}>
{sponsorship.impressionsChange > 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(sponsorship.impressionsChange)}%
</span>
<Text size="xs">Impressions</Text>
</Box>
<Stack direction="row" align="center" gap={2}>
<Text color="text-white" weight="semibold">{s.formattedImpressions}</Text>
{s.impressionsChange !== undefined && s.impressionsChange !== 0 && (
<Text size="xs" display="flex" alignItems="center" color={s.impressionsChange > 0 ? 'text-performance-green' : 'text-racing-red'}>
{s.impressionsChange > 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
{Math.abs(s.impressionsChange)}%
</Text>
)}
</div>
</div>
</Stack>
</Box>
{sponsorship.engagement && (
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
{s.engagement && (
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
<TrendingUp className="w-3 h-3" />
Engagement
</div>
<div className="text-white font-semibold">{sponsorship.engagement}%</div>
</div>
<Text size="xs">Engagement</Text>
</Box>
<Text color="text-white" weight="semibold">{s.engagement}%</Text>
</Box>
)}
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
<Calendar className="w-3 h-3" />
Period
</div>
<div className="text-white font-semibold text-xs">
{sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</div>
</div>
<Text size="xs">Period</Text>
</Box>
<Text color="text-white" weight="semibold" size="xs">
{s.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {s.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
</Text>
</Box>
<div className="bg-iron-gray/50 rounded-lg p-3">
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
<Trophy className="w-3 h-3" />
Investment
</div>
<div className="text-white font-semibold">{sponsorship.formattedPrice}</div>
</div>
</div>
<Text size="xs">Investment</Text>
</Box>
<Text color="text-white" weight="semibold">{s.formattedPrice}</Text>
</Box>
</Box>
)}
{/* Basic info for non-active */}
{sponsorship.status !== 'active' && (
<div className="flex items-center gap-4 mb-4 text-sm">
<div className="flex items-center gap-1 text-gray-400">
{s.status !== 'active' && (
<Stack direction="row" align="center" gap={4} mb={4}>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400">
<Calendar className="w-3.5 h-3.5" />
{sponsorship.periodDisplay}
</div>
<div className="flex items-center gap-1 text-gray-400">
<Text size="sm">{s.periodDisplay}</Text>
</Box>
<Box display="flex" alignItems="center" gap={1} color="text-gray-400">
<Trophy className="w-3.5 h-3.5" />
{sponsorship.formattedPrice}
</div>
</div>
<Text size="sm">{s.formattedPrice}</Text>
</Box>
</Stack>
)}
{/* Footer */}
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
<div className="flex items-center gap-2">
{sponsorship.status === 'active' && (
<span className={`text-xs ${isExpiringSoon ? 'text-warning-amber' : 'text-gray-500'}`}>
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-charcoal-outline/50">
<Box display="flex" alignItems="center" gap={2}>
{s.status === 'active' && (
<Text size="xs" color={isExpiringSoon ? 'text-warning-amber' : 'text-gray-500'}>
{daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Ended'}
</span>
</Text>
)}
{isPending && (
<span className="text-xs text-gray-500">
<Text size="xs" color="text-gray-500">
Waiting for response...
</span>
</Text>
)}
</div>
<div className="flex items-center gap-2">
{sponsorship.type !== 'platform' && (
</Box>
<Stack direction="row" align="center" gap={2}>
{s.type !== 'platform' && (
<Link href={getEntityLink()}>
<Button variant="secondary" className="text-xs">
<ExternalLink className="w-3 h-3 mr-1" />
@@ -313,14 +309,14 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
Cancel Application
</Button>
)}
{sponsorship.status === 'active' && (
{s.status === 'active' && (
<Button variant="secondary" className="text-xs">
Details
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
)}
</div>
</div>
</Stack>
</Box>
</Card>
</motion.div>
);
@@ -370,53 +366,65 @@ export default function SponsorCampaignsPage() {
const data = sponsorshipsData;
// Filter sponsorships
const filteredSponsorships = data.sponsorships.filter((s: any) => {
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
const filteredSponsorships = data.sponsorships.filter((s: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const sponsorship = s as any;
if (typeFilter !== 'all' && sponsorship.type !== typeFilter) return false;
if (statusFilter !== 'all' && sponsorship.status !== statusFilter) return false;
if (searchQuery && !sponsorship.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
return true;
});
// Calculate stats
const stats = {
total: data.sponsorships.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
active: data.sponsorships.filter((s: any) => s.status === 'active').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pending: data.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
approved: data.sponsorships.filter((s: any) => s.status === 'approved').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
rejected: data.sponsorships.filter((s: any) => s.status === 'rejected').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
totalInvestment: data.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
totalImpressions: data.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
};
// Stats by type
const statsByType = {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leagues: data.sponsorships.filter((s: any) => s.type === 'leagues').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
teams: data.sponsorships.filter((s: any) => s.type === 'teams').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
drivers: data.sponsorships.filter((s: any) => s.type === 'drivers').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
races: data.sponsorships.filter((s: any) => s.type === 'races').length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
platform: data.sponsorships.filter((s: any) => s.type === 'platform').length,
};
return (
<div className="max-w-7xl mx-auto py-8 px-4">
<Box maxWidth="7xl" mx="auto" py={8} px={4}>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
<div>
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
<Megaphone className="w-7 h-7 text-primary-blue" />
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4} mb={8}>
<Box>
<Heading level={1} fontSize="2xl" weight="bold" color="text-white" icon={<Megaphone className="w-7 h-7 text-primary-blue" />}>
My Sponsorships
</h1>
<p className="text-gray-400 mt-1">Manage applications and active sponsorship campaigns</p>
</div>
<div className="flex items-center gap-3">
</Heading>
<Text color="text-gray-400" mt={1} block>Manage applications and active sponsorship campaigns</Text>
</Box>
<Box display="flex" alignItems="center" gap={3}>
<Link href="/leagues">
<Button variant="primary">
<Plus className="w-4 h-4 mr-2" />
Find Opportunities
</Button>
</Link>
</div>
</div>
</Box>
</Stack>
{/* Info Banner about how sponsorships work */}
{stats.pending > 0 && (
@@ -426,23 +434,23 @@ export default function SponsorCampaignsPage() {
className="mb-6"
>
<InfoBanner type="info" title="Sponsorship Applications">
<p>
You have <strong className="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</strong> waiting for approval.
<Text size="sm">
You have <Text weight="bold" color="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</Text> waiting for approval.
League admins, team owners, and drivers review applications before accepting sponsorships.
</p>
</Text>
</InfoBanner>
</motion.div>
)}
{/* Quick Stats */}
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
<Box display="grid" gridCols={{ base: 2, md: 6 }} gap={4} mb={8}>
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
>
<Card className="p-4">
<div className="text-2xl font-bold text-white">{stats.total}</div>
<div className="text-sm text-gray-400">Total</div>
<Text size="2xl" weight="bold" color="text-white" block>{stats.total}</Text>
<Text size="sm" color="text-gray-400">Total</Text>
</Card>
</motion.div>
<motion.div
@@ -451,8 +459,8 @@ export default function SponsorCampaignsPage() {
transition={{ delay: 0.05 }}
>
<Card className="p-4">
<div className="text-2xl font-bold text-performance-green">{stats.active}</div>
<div className="text-sm text-gray-400">Active</div>
<Text size="2xl" weight="bold" color="text-performance-green" block>{stats.active}</Text>
<Text size="sm" color="text-gray-400">Active</Text>
</Card>
</motion.div>
<motion.div
@@ -461,8 +469,8 @@ export default function SponsorCampaignsPage() {
transition={{ delay: 0.1 }}
>
<Card className={`p-4 ${stats.pending > 0 ? 'border-warning-amber/30' : ''}`}>
<div className="text-2xl font-bold text-warning-amber">{stats.pending}</div>
<div className="text-sm text-gray-400">Pending</div>
<Text size="2xl" weight="bold" color="text-warning-amber" block>{stats.pending}</Text>
<Text size="sm" color="text-gray-400">Pending</Text>
</Card>
</motion.div>
<motion.div
@@ -471,8 +479,8 @@ export default function SponsorCampaignsPage() {
transition={{ delay: 0.15 }}
>
<Card className="p-4">
<div className="text-2xl font-bold text-primary-blue">{stats.approved}</div>
<div className="text-sm text-gray-400">Approved</div>
<Text size="2xl" weight="bold" color="text-primary-blue" block>{stats.approved}</Text>
<Text size="sm" color="text-gray-400">Approved</Text>
</Card>
</motion.div>
<motion.div
@@ -481,8 +489,8 @@ export default function SponsorCampaignsPage() {
transition={{ delay: 0.2 }}
>
<Card className="p-4">
<div className="text-2xl font-bold text-white">${stats.totalInvestment.toLocaleString()}</div>
<div className="text-sm text-gray-400">Active Investment</div>
<Text size="2xl" weight="bold" color="text-white" block>${stats.totalInvestment.toLocaleString()}</Text>
<Text size="sm" color="text-gray-400">Active Investment</Text>
</Card>
</motion.div>
<motion.div
@@ -491,16 +499,16 @@ export default function SponsorCampaignsPage() {
transition={{ delay: 0.25 }}
>
<Card className="p-4">
<div className="text-2xl font-bold text-primary-blue">{(stats.totalImpressions / 1000).toFixed(0)}k</div>
<div className="text-sm text-gray-400">Impressions</div>
<Text size="2xl" weight="bold" color="text-primary-blue" block>{(stats.totalImpressions / 1000).toFixed(0)}k</Text>
<Text size="sm" color="text-gray-400">Impressions</Text>
</Card>
</motion.div>
</div>
</Box>
{/* Filters */}
<div className="flex flex-col lg:flex-row gap-4 mb-6">
<Stack direction={{ base: 'col', lg: 'row' }} gap={4} mb={6}>
{/* Search */}
<div className="relative flex-1">
<Box position="relative" flexGrow={1}>
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
<input
type="text"
@@ -509,10 +517,10 @@ export default function SponsorCampaignsPage() {
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
/>
</div>
</Box>
{/* Type Filter */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
<Box display="flex" alignItems="center" gap={2} overflow="auto" pb={{ base: 2, lg: 0 }}>
{(['all', 'leagues', 'teams', 'drivers', 'races', 'platform'] as const).map((type) => {
const config = TYPE_CONFIG[type];
const Icon = config.icon;
@@ -525,22 +533,20 @@ export default function SponsorCampaignsPage() {
typeFilter === type
? 'bg-primary-blue text-white'
: 'bg-iron-gray/50 text-gray-400 hover:bg-iron-gray'
}`}
} border-0 cursor-pointer`}
>
<Icon className="w-4 h-4" />
{config.label}
<span className={`px-1.5 py-0.5 rounded text-xs ${
typeFilter === type ? 'bg-white/20' : 'bg-charcoal-outline'
}`}>
<Text size="xs" px={1.5} py={0.5} rounded="sm" bg={typeFilter === type ? 'bg-white/20' : 'bg-charcoal-outline'}>
{count}
</span>
</Text>
</button>
);
})}
</div>
</Box>
{/* Status Filter */}
<div className="flex items-center gap-2 overflow-x-auto">
<Box display="flex" alignItems="center" gap={2} overflow="auto">
{(['all', 'active', 'pending_approval', 'approved', 'rejected'] as const).map((status) => {
const config = status === 'all'
? { label: 'All', color: 'text-gray-400' }
@@ -556,33 +562,31 @@ export default function SponsorCampaignsPage() {
statusFilter === status
? 'bg-iron-gray text-white border border-charcoal-outline'
: 'text-gray-500 hover:text-gray-300'
}`}
} border-0 cursor-pointer`}
>
{config.label}
{count > 0 && status !== 'all' && (
<span className={`ml-1.5 px-1.5 py-0.5 rounded text-xs ${
status === 'pending_approval' ? 'bg-warning-amber/20 text-warning-amber' : 'bg-charcoal-outline'
}`}>
<Text size="xs" ml={1.5} px={1.5} py={0.5} rounded="sm" bg={status === 'pending_approval' ? 'bg-warning-amber/20' : 'bg-charcoal-outline'} color={status === 'pending_approval' ? 'text-warning-amber' : ''}>
{count}
</span>
</Text>
)}
</button>
);
})}
</div>
</div>
</Box>
</Stack>
{/* Sponsorship List */}
{filteredSponsorships.length === 0 ? (
<Card className="text-center py-16">
<Megaphone className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-white mb-2">No sponsorships found</h3>
<p className="text-gray-400 mb-6 max-w-md mx-auto">
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={2}>No sponsorships found</Heading>
<Text color="text-gray-400" mb={6} maxWidth="md" mx="auto" block>
{searchQuery || typeFilter !== 'all' || statusFilter !== 'all'
? 'Try adjusting your filters to see more results.'
: 'Start sponsoring leagues, teams, or drivers to grow your brand visibility.'}
</p>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
</Text>
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
<Link href="/leagues">
<Button variant="primary">
<Trophy className="w-4 h-4 mr-2" />
@@ -601,15 +605,15 @@ export default function SponsorCampaignsPage() {
Browse Drivers
</Button>
</Link>
</div>
</Stack>
</Card>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
{filteredSponsorships.map((sponsorship: any) => (
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
))}
</div>
</Box>
)}
</div>
</Box>
);
}

View File

@@ -3,15 +3,18 @@
import React, { useState, useMemo } from 'react';
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate';
export default function SponsorLeaguesPageClient({ data }: { data: any }) {
export default function SponsorLeaguesPageClient({ data }: { data: unknown }) {
const [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('rating');
const [tierFilter] = useState<TierFilter>('all');
const [availabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy] = useState<SortOption>('rating');
const filteredLeagues = useMemo(() => {
if (!data?.leagues) return [];
return data.leagues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const d = data as any;
if (!d?.leagues) return [];
return d.leagues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((league: any) => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
@@ -27,6 +30,7 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
}
return true;
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sort((a: any, b: any) => {
switch (sortBy) {
case 'rating': return b.rating - a.rating;
@@ -36,20 +40,15 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
default: return 0;
}
});
}, [data?.leagues, searchQuery, tierFilter, availabilityFilter, sortBy]);
}, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
return (
<SponsorLeaguesTemplate
viewData={data}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
viewData={data as any}
filteredLeagues={filteredLeagues}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
tierFilter={tierFilter}
setTierFilter={setTierFilter}
availabilityFilter={availabilityFilter}
setAvailabilityFilter={setAvailabilityFilter}
sortBy={sortBy}
setSortBy={setSortBy}
/>
);
}

View File

@@ -29,10 +29,14 @@ export default async function Page() {
// Calculate summary stats (business logic moved from view model)
const stats = {
total: leaguesData.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mainAvailable: leaguesData.filter((l: any) => l.mainSponsorSlot.available).length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
secondaryAvailable: leaguesData.reduce((sum: number, l: any) => sum + l.secondarySlots.available, 0),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
totalDrivers: leaguesData.reduce((sum: number, l: any) => sum + l.drivers, 0),
avgCpm: Math.round(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leaguesData.reduce((sum: number, l: any) => sum + l.cpm, 0) / leaguesData.length
),
};

View File

@@ -6,9 +6,14 @@ import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Toggle } from '@/ui/Toggle';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { SectionHeader } from '@/ui/SectionHeader';
import { FormField } from '@/ui/FormField';
import { PageHeader } from '@/ui/PageHeader';
import { Image } from '@/ui/Image';
import {
Settings,
Building2,
@@ -201,33 +206,38 @@ export default function SponsorSettingsPage() {
};
return (
<motion.div
className="max-w-4xl mx-auto py-8 px-4"
<Box
maxWidth="4xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<PageHeader
icon={Settings}
title="Sponsor Settings"
description="Manage your company profile, notifications, and security preferences"
action={<SavedIndicator visible={saved} />}
/>
</motion.div>
</Box>
{/* Company Profile */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Building2}
title="Company Profile"
description="Your public-facing company information"
/>
<div className="p-6 space-y-6">
<Box p={6} className="space-y-6">
{/* Company Basic Info */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Company Name" icon={Building2} required>
<Input
type="text"
@@ -238,24 +248,32 @@ export default function SponsorSettingsPage() {
</FormField>
<FormField label="Industry">
<select
<Box as="select"
value={profile.industry}
onChange={(e) => setProfile({ ...profile, industry: e.target.value })}
className="w-full px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue"
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setProfile({ ...profile, industry: e.target.value })}
w="full"
px={3}
py={2}
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
className="focus:outline-none focus:border-primary-blue"
>
{INDUSTRY_OPTIONS.map(industry => (
<option key={industry} value={industry}>{industry}</option>
))}
</select>
</Box>
</FormField>
</div>
</Box>
{/* Contact Information */}
<div className="pt-4 border-t border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Contact Information
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Contact Name" icon={User} required>
<Input
type="text"
@@ -291,16 +309,16 @@ export default function SponsorSettingsPage() {
placeholder="https://company.com"
/>
</FormField>
</div>
</div>
</Box>
</Box>
{/* Address */}
<div className="pt-4 border-t border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Business Address
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<Box colSpan={{ base: 1, md: 2 }}>
<FormField label="Street Address" icon={MapPin}>
<Input
type="text"
@@ -312,7 +330,7 @@ export default function SponsorSettingsPage() {
placeholder="123 Main Street"
/>
</FormField>
</div>
</Box>
<FormField label="City">
<Input
@@ -358,31 +376,39 @@ export default function SponsorSettingsPage() {
placeholder="XX12-3456789"
/>
</FormField>
</div>
</div>
</Box>
</Box>
{/* Description */}
<div className="pt-4 border-t border-charcoal-outline/50">
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<FormField label="Company Description">
<textarea
<Box as="textarea"
value={profile.description}
onChange={(e) => setProfile({ ...profile, description: e.target.value })}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setProfile({ ...profile, description: e.target.value })}
placeholder="Tell potential sponsorship partners about your company, products, and what you're looking for in sponsorship opportunities..."
rows={4}
className="w-full px-4 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue resize-none"
w="full"
px={4}
py={3}
bg="bg-iron-gray"
border
borderColor="border-charcoal-outline"
rounded="lg"
color="text-white"
className="placeholder-gray-500 focus:outline-none focus:border-primary-blue resize-none"
/>
<p className="text-xs text-gray-500 mt-1">
<Text size="xs" color="text-gray-500" block mt={1}>
This description appears on your public sponsor profile.
</p>
</Text>
</FormField>
</div>
</Box>
{/* Social Links */}
<div className="pt-4 border-t border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Social Media
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 3 }} gap={6}>
<FormField label="Twitter / X" icon={LinkIcon}>
<Input
type="text"
@@ -418,49 +444,49 @@ export default function SponsorSettingsPage() {
placeholder="@username"
/>
</FormField>
</div>
</div>
</Box>
</Box>
{/* Logo Upload */}
<div className="pt-4 border-t border-charcoal-outline/50">
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<FormField label="Company Logo" icon={ImageIcon}>
<div className="flex items-start gap-6">
<div className="w-24 h-24 rounded-xl bg-gradient-to-br from-iron-gray to-deep-graphite border-2 border-dashed border-charcoal-outline flex items-center justify-center overflow-hidden">
<Stack direction="row" align="start" gap={6}>
<Box w="24" h="24" rounded="xl" bg="bg-gradient-to-br from-iron-gray to-deep-graphite" border borderColor="border-charcoal-outline" borderStyle="dashed" display="flex" alignItems="center" justifyContent="center" overflow="hidden">
{profile.logoUrl ? (
<img src={profile.logoUrl} alt="Company logo" className="w-full h-full object-cover" />
<Image src={profile.logoUrl} alt="Company logo" width={96} height={96} objectFit="cover" />
) : (
<Building2 className="w-10 h-10 text-gray-600" />
)}
</div>
<div className="flex-1">
<div className="flex items-center gap-3">
<label className="cursor-pointer">
</Box>
<Box flexGrow={1}>
<Stack direction="row" align="center" gap={3}>
<Text as="label" cursor="pointer">
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
className="hidden"
/>
<div className="px-4 py-2 rounded-lg bg-iron-gray border border-charcoal-outline text-gray-300 hover:bg-charcoal-outline transition-colors flex items-center gap-2">
<Box px={4} py={2} rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" color="text-gray-300" transition className="hover:bg-charcoal-outline" display="flex" alignItems="center" gap={2}>
<Upload className="w-4 h-4" />
Upload Logo
</div>
</label>
<Text>Upload Logo</Text>
</Box>
</Text>
{profile.logoUrl && (
<Button variant="secondary" className="text-sm text-gray-400">
Remove
</Button>
)}
</div>
<p className="text-xs text-gray-500 mt-2">
</Stack>
<Text size="xs" color="text-gray-500" block mt={2}>
PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px.
</p>
</div>
</div>
</Text>
</Box>
</Stack>
</FormField>
</div>
</Box>
{/* Save Button */}
<div className="pt-6 border-t border-charcoal-outline flex items-center justify-end gap-4">
<Box pt={6} borderTop borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="end" gap={4}>
<Button
variant="primary"
onClick={handleSaveProfile}
@@ -468,24 +494,24 @@ export default function SponsorSettingsPage() {
className="min-w-[160px]"
>
{saving ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
Saving...
</span>
<Stack direction="row" align="center" gap={2}>
<Box w="4" h="4" border borderColor="border-white/30" borderTopColor="border-t-white" rounded="full" animate="spin" />
<Text>Saving...</Text>
</Stack>
) : (
<span className="flex items-center gap-2">
<Stack direction="row" align="center" gap={2}>
<Save className="w-4 h-4" />
Save Profile
</span>
<Text>Save Profile</Text>
</Stack>
)}
</Button>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
</Box>
{/* Notification Preferences */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Bell}
@@ -493,8 +519,8 @@ export default function SponsorSettingsPage() {
description="Control which emails you receive from GridPilot"
color="text-warning-amber"
/>
<div className="p-6">
<div className="space-y-1">
<Box p={6}>
<Box className="space-y-1">
<Toggle
checked={notifications.emailNewSponsorships}
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
@@ -531,13 +557,13 @@ export default function SponsorSettingsPage() {
label="Contract Expiry Reminders"
description="Receive reminders before your sponsorship contracts expire"
/>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
</Box>
{/* Privacy & Visibility */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Eye}
@@ -545,8 +571,8 @@ export default function SponsorSettingsPage() {
description="Control how your profile appears to others"
color="text-performance-green"
/>
<div className="p-6">
<div className="space-y-1">
<Box p={6}>
<Box className="space-y-1">
<Toggle
checked={privacy.publicProfile}
onChange={(checked) => setPrivacy({ ...privacy, publicProfile: checked })}
@@ -571,13 +597,13 @@ export default function SponsorSettingsPage() {
label="Allow Direct Contact"
description="Enable leagues and teams to send you sponsorship proposals"
/>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
</Box>
{/* Security */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Shield}
@@ -585,80 +611,77 @@ export default function SponsorSettingsPage() {
description="Protect your sponsor account"
color="text-primary-blue"
/>
<div className="p-6 space-y-4">
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-iron-gray">
<Box p={6} className="space-y-4">
<Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
<Stack direction="row" align="center" gap={4}>
<Box p={2} rounded="lg" bg="bg-iron-gray">
<Key className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="text-gray-200 font-medium">Password</p>
<p className="text-sm text-gray-500">Last changed 3 months ago</p>
</div>
</div>
</Box>
<Box>
<Text color="text-gray-200" weight="medium" block>Password</Text>
<Text size="sm" color="text-gray-500" block>Last changed 3 months ago</Text>
</Box>
</Stack>
<Button variant="secondary">
Change Password
</Button>
</div>
</Box>
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-iron-gray">
<Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
<Stack direction="row" align="center" gap={4}>
<Box p={2} rounded="lg" bg="bg-iron-gray">
<Smartphone className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="text-gray-200 font-medium">Two-Factor Authentication</p>
<p className="text-sm text-gray-500">Add an extra layer of security to your account</p>
</div>
</div>
</Box>
<Box>
<Text color="text-gray-200" weight="medium" block>Two-Factor Authentication</Text>
<Text size="sm" color="text-gray-500" block>Add an extra layer of security to your account</Text>
</Box>
</Stack>
<Button variant="secondary">
Enable 2FA
</Button>
</div>
</Box>
<div className="flex items-center justify-between py-3">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-iron-gray">
<Box display="flex" alignItems="center" justifyContent="between" py={3}>
<Stack direction="row" align="center" gap={4}>
<Box p={2} rounded="lg" bg="bg-iron-gray">
<Lock className="w-5 h-5 text-gray-400" />
</div>
<div>
<p className="text-gray-200 font-medium">Active Sessions</p>
<p className="text-sm text-gray-500">Manage devices where you're logged in</p>
</div>
</div>
</Box>
<Box>
<Text color="text-gray-200" weight="medium" block>Active Sessions</Text>
<Text size="sm" color="text-gray-500" block>Manage devices where you&apos;re logged in</Text>
</Box>
</Stack>
<Button variant="secondary">
View Sessions
</Button>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
</Box>
{/* Danger Zone */}
<motion.div variants={itemVariants}>
<Box as={motion.div} variants={itemVariants}>
<Card className="border-racing-red/30 overflow-hidden">
<div className="p-5 border-b border-racing-red/30 bg-gradient-to-r from-racing-red/10 to-transparent">
<h2 className="text-lg font-semibold text-racing-red flex items-center gap-3">
<div className="p-2 rounded-lg bg-racing-red/10">
<AlertCircle className="w-5 h-5 text-racing-red" />
</div>
<Box p={5} borderBottom borderColor="border-racing-red/30" bg="bg-gradient-to-r from-racing-red/10 to-transparent">
<Heading level={2} fontSize="lg" weight="semibold" color="text-racing-red" icon={<Box p={2} rounded="lg" bg="bg-racing-red/10"><AlertCircle className="w-5 h-5 text-racing-red" /></Box>}>
Danger Zone
</h2>
</div>
<div className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="p-2 rounded-lg bg-racing-red/10">
</Heading>
</Box>
<Box p={6}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={4}>
<Box p={2} rounded="lg" bg="bg-racing-red/10">
<Trash2 className="w-5 h-5 text-racing-red" />
</div>
<div>
<p className="text-gray-200 font-medium">Delete Sponsor Account</p>
<p className="text-sm text-gray-500">
</Box>
<Box>
<Text color="text-gray-200" weight="medium" block>Delete Sponsor Account</Text>
<Text size="sm" color="text-gray-500" block>
Permanently delete your account and all associated sponsorship data.
This action cannot be undone.
</p>
</div>
</div>
</Text>
</Box>
</Stack>
<Button
variant="secondary"
onClick={handleDeleteAccount}
@@ -666,10 +689,10 @@ export default function SponsorSettingsPage() {
>
Delete Account
</Button>
</div>
</div>
</Box>
</Box>
</Card>
</motion.div>
</motion.div>
</Box>
</Box>
);
}

View File

@@ -5,6 +5,10 @@ import { motion, useReducedMotion } from 'framer-motion';
import { Card } from '@/ui/Card';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { SponsorHero } from '@/components/sponsors/SponsorHero';
import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup';
import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard';
@@ -25,7 +29,6 @@ import {
BarChart3,
Shield,
CheckCircle2,
Star,
Megaphone
} from 'lucide-react';
@@ -238,13 +241,13 @@ export default function SponsorSignupPage() {
// Landing page for sponsors
if (mode === 'landing') {
return (
<div className="min-h-screen bg-deep-graphite">
<Box minHeight="screen" bg="bg-deep-graphite">
{/* Hero Section */}
<SponsorHero
title="Connect Your Brand with Sim Racing"
subtitle="Reach passionate racing communities through league, team, driver, and race sponsorships. Real exposure, measurable results."
>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Stack direction={{ base: 'col', md: 'row' }} gap={4} justify="center">
<Button
variant="primary"
onClick={() => setMode('signup')}
@@ -261,12 +264,12 @@ export default function SponsorSignupPage() {
Sign In
<ArrowRight className="w-5 h-5 ml-2" />
</Button>
</div>
</Stack>
</SponsorHero>
{/* Platform Stats */}
<div className="max-w-6xl mx-auto px-4 -mt-8 relative z-10">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Box maxWidth="6xl" mx="auto" px={4} mt={-8} position="relative" zIndex={10}>
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={4}>
{PLATFORM_STATS.map((stat, index) => (
<motion.div
key={stat.label}
@@ -275,27 +278,27 @@ export default function SponsorSignupPage() {
transition={{ delay: index * 0.1 }}
>
<Card className="text-center p-4 bg-iron-gray/80 backdrop-blur-sm">
<div className="text-2xl sm:text-3xl font-bold text-white mb-1">{stat.value}</div>
<div className="text-xs sm:text-sm text-gray-400">{stat.label}</div>
<Text size={{ base: '2xl', sm: '3xl' }} weight="bold" color="text-white" block mb={1}>{stat.value}</Text>
<Text size={{ base: 'xs', sm: 'sm' }} color="text-gray-400">{stat.label}</Text>
</Card>
</motion.div>
))}
</div>
</div>
</Box>
</Box>
{/* Sponsorship Types Section */}
<section className="max-w-6xl mx-auto px-4 py-16 sm:py-24">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
<Box as="section" maxWidth="6xl" mx="auto" px={4} py={{ base: 16, md: 24 }}>
<Box textAlign="center" mb={12}>
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
Sponsorship Opportunities
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
</Heading>
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
Choose how you want to connect with the sim racing community.
Multiple sponsorship tiers and types to fit every budget and goal.
</p>
</div>
</Text>
</Box>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{SPONSORSHIP_TYPES.map((type, index) => (
<motion.div
key={type.id}
@@ -304,60 +307,60 @@ export default function SponsorSignupPage() {
transition={{ delay: index * 0.1 }}
>
<Card className="h-full hover:border-primary-blue/50 transition-all duration-300 group">
<div className="flex items-start gap-4 mb-4">
<div className={`w-12 h-12 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center group-hover:border-primary-blue/50 transition-colors`}>
<Stack direction="row" align="start" gap={4} mb={4}>
<Box w="12" h="12" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="center" group hoverBorderColor="primary-blue/50" transition>
<type.icon className={`w-6 h-6 ${type.color}`} />
</div>
<div>
<h3 className="text-lg font-semibold text-white">{type.title}</h3>
<p className="text-sm text-primary-blue font-medium">{type.priceRange}</p>
</div>
</div>
</Box>
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">{type.title}</Heading>
<Text size="sm" color="text-primary-blue" weight="medium" block>{type.priceRange}</Text>
</Box>
</Stack>
<p className="text-sm text-gray-400 mb-4">{type.description}</p>
<Text size="sm" color="text-gray-400" block mb={4}>{type.description}</Text>
<ul className="space-y-2">
<Box as="ul" className="space-y-2">
{type.benefits.map((benefit, i) => (
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
<Box as="li" key={i} display="flex" alignItems="center" gap={2}>
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
{benefit}
</li>
<Text size="sm" color="text-gray-300">{benefit}</Text>
</Box>
))}
</ul>
</Box>
</Card>
</motion.div>
))}
</div>
</section>
</Box>
</Box>
{/* Workflow Mockup Section */}
<section className="bg-iron-gray/30 py-16 sm:py-24">
<div className="max-w-6xl mx-auto px-4">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
<Box as="section" bg="bg-iron-gray/30" py={{ base: 16, md: 24 }}>
<Box maxWidth="6xl" mx="auto" px={4}>
<Box textAlign="center" mb={12}>
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
How It Works
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
</Heading>
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
From discovery to results tracking a seamless sponsorship experience.
</p>
</div>
</Text>
</Box>
<SponsorWorkflowMockup />
</div>
</section>
</Box>
</Box>
{/* Benefits Grid */}
<section className="max-w-6xl mx-auto px-4 py-16 sm:py-24">
<div className="text-center mb-12">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
<Box as="section" maxWidth="6xl" mx="auto" px={4} py={{ base: 16, md: 24 }}>
<Box textAlign="center" mb={12}>
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
Why Sponsor on GridPilot?
</h2>
<p className="text-gray-400 max-w-2xl mx-auto">
</Heading>
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
Professional tools and genuine community engagement make your sponsorship worthwhile.
</p>
</div>
</Text>
</Box>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
<SponsorBenefitCard
icon={Eye}
title="Real Visibility"
@@ -395,18 +398,18 @@ export default function SponsorSignupPage() {
description="Scale your sponsorships as you see results. Start small or go big from day one."
delay={0.5}
/>
</div>
</section>
</Box>
</Box>
{/* CTA Section */}
<section className="max-w-4xl mx-auto px-4 py-16 sm:py-24 text-center">
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
<Box as="section" maxWidth="4xl" mx="auto" px={4} py={{ base: 16, md: 24 }} textAlign="center">
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
Ready to Grow Your Brand?
</h2>
<p className="text-gray-400 max-w-2xl mx-auto mb-8">
</Heading>
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block mb={8}>
Join sponsors connecting with sim racing communities worldwide.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
</Text>
<Stack direction={{ base: 'col', md: 'row' }} gap={4} justify="center">
<Button
variant="primary"
onClick={() => setMode('signup')}
@@ -424,45 +427,45 @@ export default function SponsorSignupPage() {
<Mail className="w-5 h-5 mr-2" />
Contact Sales
</Button>
</div>
</section>
</div>
</Stack>
</Box>
</Box>
);
}
// Login form
if (mode === 'login') {
return (
<div className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
<div className="w-full max-w-md">
<div className="mb-8">
<Box minHeight="screen" bg="bg-deep-graphite" display="flex" alignItems="center" justifyContent="center" px={4} py={12}>
<Box fullWidth maxWidth="md">
<Box mb={8}>
<button
onClick={() => setMode('landing')}
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2"
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2 bg-transparent border-0 cursor-pointer"
>
<ArrowRight className="w-4 h-4 rotate-180" />
Back to overview
</button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20">
<Stack direction="row" align="center" gap={4} mb={2}>
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
<Building2 className="w-7 h-7 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Sponsor Sign In</h1>
<p className="text-sm text-gray-400">Access your sponsor dashboard</p>
</div>
</div>
</div>
</Box>
<Box>
<Heading level={1} fontSize="2xl" weight="bold" color="text-white">Sponsor Sign In</Heading>
<Text size="sm" color="text-gray-400">Access your sponsor dashboard</Text>
</Box>
</Stack>
</Box>
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-5">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Box as="form" onSubmit={handleSubmit} className="space-y-5">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Mail className="w-4 h-4 text-gray-500" />
Email Address
</div>
</label>
</Stack>
</Text>
<Input
type="email"
value={formData.contactEmail}
@@ -471,12 +474,12 @@ export default function SponsorSignupPage() {
variant={errors.contactEmail ? 'error' : 'default'}
errorMessage={errors.contactEmail}
/>
</div>
</Box>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
Password
</label>
</Text>
<Input
type="password"
value={formData.password}
@@ -485,17 +488,17 @@ export default function SponsorSignupPage() {
variant={errors.password ? 'error' : 'default'}
errorMessage={errors.password}
/>
</div>
</Box>
<div className="flex items-center justify-between text-sm">
<label className="flex items-center gap-2 text-gray-400">
<Stack direction="row" align="center" justify="between">
<Text as="label" display="flex" alignItems="center" gap={2} color="text-gray-400">
<input type="checkbox" className="rounded border-charcoal-outline bg-iron-gray" />
Remember me
</label>
<button type="button" className="text-primary-blue hover:underline">
</Text>
<button type="button" className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer">
Forgot password?
</button>
</div>
</Stack>
<Button
type="submit"
@@ -505,63 +508,62 @@ export default function SponsorSignupPage() {
>
{submitting ? 'Signing in...' : 'Sign In'}
</Button>
</form>
</Box>
<div className="mt-6 pt-6 border-t border-charcoal-outline">
<p className="text-sm text-gray-400 text-center mb-4">
<Box mt={6} pt={6} borderTop borderColor="border-charcoal-outline">
<Text size="sm" color="text-gray-400" align="center" block mb={4}>
Don't have an account?{' '}
<button
onClick={() => setMode('signup')}
className="text-primary-blue hover:underline"
className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer"
>
Create one
</button>
</p>
</div>
</Text>
</Box>
</Card>
</div>
</div>
</Box>
</Box>
);
}
// Signup form
return (
<div className="min-h-screen bg-deep-graphite py-12 px-4">
<div className="max-w-2xl mx-auto">
<Box minHeight="screen" bg="bg-deep-graphite" py={12} px={4}>
<Box maxWidth="2xl" mx="auto">
{/* Header */}
<div className="mb-8">
<Box mb={8}>
<button
onClick={() => setMode('landing')}
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2"
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2 bg-transparent border-0 cursor-pointer"
>
<ArrowRight className="w-4 h-4 rotate-180" />
Back to overview
</button>
<div className="flex items-center gap-4 mb-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20">
<Stack direction="row" align="center" gap={4} mb={2}>
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
<Building2 className="w-7 h-7 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">Create Sponsor Account</h1>
<p className="text-sm text-gray-400">Register your company to sponsor racing entities</p>
</div>
</div>
</div>
</Box>
<Box>
<Heading level={1} fontSize="2xl" weight="bold" color="text-white">Create Sponsor Account</Heading>
<Text size="sm" color="text-gray-400">Register your company to sponsor racing entities</Text>
</Box>
</Stack>
</Box>
<Card className="p-6">
<form onSubmit={handleSubmit} className="space-y-6">
<Box as="form" onSubmit={handleSubmit} className="space-y-6">
{/* Company Information */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Building2 className="w-5 h-5 text-primary-blue" />
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Building2 className="w-5 h-5 text-primary-blue" />}>
Company Information
</h3>
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="md:col-span-2">
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
<Box colSpan={{ base: 1, md: 2 }}>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
Company Name *
</label>
</Text>
<Input
type="text"
value={formData.companyName}
@@ -570,15 +572,15 @@ export default function SponsorSignupPage() {
variant={errors.companyName ? 'error' : 'default'}
errorMessage={errors.companyName}
/>
</div>
</Box>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Mail className="w-4 h-4 text-gray-500" />
Contact Email *
</div>
</label>
</Stack>
</Text>
<Input
type="email"
value={formData.contactEmail}
@@ -587,36 +589,35 @@ export default function SponsorSignupPage() {
variant={errors.contactEmail ? 'error' : 'default'}
errorMessage={errors.contactEmail}
/>
</div>
</Box>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Globe className="w-4 h-4 text-gray-500" />
Website URL
</div>
</label>
</Stack>
</Text>
<Input
type="url"
value={formData.websiteUrl}
onChange={(e) => setFormData({ ...formData, websiteUrl: e.target.value })}
placeholder="https://company.com"
/>
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Sponsorship Interests */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Target className="w-5 h-5 text-purple-400" />
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Target className="w-5 h-5 text-purple-400" />}>
Sponsorship Interests
</h3>
<p className="text-sm text-gray-400 mb-4">
</Heading>
<Text size="sm" color="text-gray-400" block mb={4}>
Select the types of sponsorships you're interested in (optional)
</p>
</Text>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<Box display="grid" gridCols={{ base: 2, sm: 3 }} gap={3}>
{SPONSORSHIP_TYPES.map((type) => {
const isSelected = formData.interests.includes(type.id);
return (
@@ -633,28 +634,28 @@ export default function SponsorSignupPage() {
`}
>
<type.icon className={`w-5 h-5 ${isSelected ? type.color : 'text-gray-500'} mb-2`} />
<p className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'} block>
{type.title.replace(' Sponsorship', '').replace(' Advertising', '')}
</p>
</Text>
</button>
);
})}
</div>
</div>
</Box>
</Box>
{/* Company Logo */}
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<div className="flex items-center gap-2">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
<Stack direction="row" align="center" gap={2}>
<Upload className="w-4 h-4 text-gray-500" />
Company Logo (optional)
</div>
</label>
<div className="flex items-center gap-4">
<div className="w-16 h-16 rounded-lg bg-iron-gray border border-charcoal-outline flex items-center justify-center flex-shrink-0">
</Stack>
</Text>
<Stack direction="row" align="center" gap={4}>
<Box w="16" h="16" rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
<Building2 className="w-6 h-6 text-gray-500" />
</div>
<div className="flex-1">
</Box>
<Box flexGrow={1}>
<input
type="file"
accept="image/png,image/jpeg,image/svg+xml"
@@ -664,25 +665,24 @@ export default function SponsorSignupPage() {
}}
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-blue/10 file:text-primary-blue hover:file:bg-primary-blue/20"
/>
<p className="text-xs text-gray-500 mt-1">
<Text size="xs" color="text-gray-500" block mt={1}>
PNG, JPEG, or SVG. Recommended: 500x500px transparent background.
</p>
</div>
</div>
</div>
</Text>
</Box>
</Stack>
</Box>
{/* Account Security */}
<div>
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
<Shield className="w-5 h-5 text-performance-green" />
<Box>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Shield className="w-5 h-5 text-performance-green" />}>
Account Security
</h3>
</Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
Password *
</label>
</Text>
<Input
type="password"
value={formData.password}
@@ -691,12 +691,12 @@ export default function SponsorSignupPage() {
variant={errors.password ? 'error' : 'default'}
errorMessage={errors.password}
/>
</div>
</Box>
<div>
<label className="block text-sm font-medium text-gray-300 mb-2">
<Box>
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
Confirm Password *
</label>
</Text>
<Input
type="password"
value={formData.confirmPassword}
@@ -705,49 +705,49 @@ export default function SponsorSignupPage() {
variant={errors.confirmPassword ? 'error' : 'default'}
errorMessage={errors.confirmPassword}
/>
</div>
</div>
</div>
</Box>
</Box>
</Box>
{/* Legal Agreements */}
<div className="space-y-3 pt-4 border-t border-charcoal-outline">
<label className="flex items-start gap-3 cursor-pointer">
<Stack gap={3} pt={4} borderTop borderColor="border-charcoal-outline">
<Text as="label" display="flex" alignItems="start" gap={3} cursor="pointer">
<input
type="checkbox"
checked={formData.acceptTerms}
onChange={(e) => setFormData({ ...formData, acceptTerms: e.target.checked })}
className="mt-1 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue"
/>
<span className="text-sm text-gray-400">
<Text size="sm" color="text-gray-400">
I accept the{' '}
<a href="/legal/terms" className="text-primary-blue hover:underline">Terms of Service</a>
{' '}and{' '}
<a href="/legal/privacy" className="text-primary-blue hover:underline">Privacy Policy</a>
{' '}*
</span>
</label>
</Text>
</Text>
{errors.acceptTerms && (
<p className="text-sm text-warning-amber">{errors.acceptTerms}</p>
<Text size="sm" color="text-warning-amber">{errors.acceptTerms}</Text>
)}
<label className="flex items-start gap-3 cursor-pointer">
<Text as="label" display="flex" alignItems="start" gap={3} cursor="pointer">
<input
type="checkbox"
checked={formData.acceptVat}
onChange={(e) => setFormData({ ...formData, acceptVat: e.target.checked })}
className="mt-1 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue"
/>
<span className="text-sm text-gray-400">
<Text size="sm" color="text-gray-400">
{siteConfig.vat.notice} A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorships. *
</span>
</label>
</Text>
</Text>
{errors.acceptVat && (
<p className="text-sm text-warning-amber">{errors.acceptVat}</p>
<Text size="sm" color="text-warning-amber">{errors.acceptVat}</Text>
)}
</div>
</Stack>
{/* Actions */}
<div className="flex gap-3 pt-4">
<Stack direction="row" gap={3} pt={4}>
<Button
type="submit"
variant="primary"
@@ -764,22 +764,22 @@ export default function SponsorSignupPage() {
>
Cancel
</Button>
</div>
</form>
</Stack>
</Box>
<div className="mt-6 pt-6 border-t border-charcoal-outline text-center">
<p className="text-sm text-gray-400">
<Box mt={6} pt={6} borderTop="1px solid" borderColor="border-charcoal-outline" textAlign="center">
<Text size="sm" color="text-gray-400">
Already have an account?{' '}
<button
onClick={() => setMode('login')}
className="text-primary-blue hover:underline"
className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer"
>
Sign in
</button>
</p>
</div>
</Text>
</Box>
</Card>
</div>
</div>
</Box>
</Box>
);
}

View File

@@ -1,24 +1,18 @@
import { notFound } from 'next/navigation';
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
import { TeamDetailViewDataBuilder } from '@/lib/builders/view-data/TeamDetailViewDataBuilder';
import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery';
import { TeamDetailPageClient } from './TeamDetailPageClient';
export default async function Page({ params }: { params: { id: string } }) {
const result = await TeamDetailPageQuery.execute(params.id);
switch (result.status) {
case 'ok':
const viewData = TeamDetailViewDataBuilder.build(result.dto);
return <TeamDetailPageClient viewData={viewData} />;
case 'notFound':
notFound();
case 'redirect':
// This would typically use redirect() from next/navigation
// but we need to handle it at the page level
return null;
case 'error':
// For now, treat errors as not found
// In production, you might want a proper error page
if (result.isErr()) {
const error = result.getError();
if (error === 'notFound') {
notFound();
}
// For other errors, treat as not found for now
notFound();
}
}
return <TeamDetailPageClient viewData={result.unwrap()} />;
}

View File

@@ -4,6 +4,7 @@ import { Trophy } from 'lucide-react';
import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// MAIN PAGE COMPONENT
@@ -21,7 +22,7 @@ export default async function TeamLeaderboardPage() {
let error = null;
if (result.isOk()) {
data = result.unwrap();
data = result.unwrap().map(t => new TeamSummaryViewModel(t));
} else {
const domainError = result.getError();
error = new Error(domainError.message);
@@ -52,4 +53,4 @@ export default async function TeamLeaderboardPage() {
}}
/>
);
}
}

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery';
import { TeamsPageClient } from './TeamsPageClient';
export default async function Page() {

View File

@@ -10,8 +10,8 @@ import {
import { useRouter } from 'next/navigation';
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession";
import { useLogout } from "@/lib/hooks/auth/useLogout";
import { useCurrentSession } from "@/hooks/auth/useCurrentSession";
import { useLogout } from "@/hooks/auth/useLogout";
export type AuthContextValue = {
session: SessionViewModel | null;

View File

@@ -124,7 +124,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
// Trigger a test API error
const testError = new Error('This is a test error for debugging');
(testError as any).type = 'TEST_ERROR';
(testError as Error & { type?: string }).type = 'TEST_ERROR';
const globalHandler = getGlobalErrorHandler();
globalHandler.report(testError, { test: true, timestamp: Date.now() });
@@ -138,7 +138,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
try {
// This will fail and be logged
await fetch('https://httpstat.us/500');
} catch (error) {
} catch (_error) {
// Already logged by interceptor
console.log('%c[TEST] API call completed', 'color: #00aaff; font-weight: bold;');
}
@@ -173,7 +173,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
},
errors: globalHandler.getStats(),
api: apiLogger.getStats(),
reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__ || [],
reactErrors: window.__GRIDPILOT_REACT_ERRORS__ || [],
};
try {
@@ -350,14 +350,14 @@ export function useDebugMode() {
globalHandler.initialize();
const apiLogger = getGlobalApiLogger();
if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) {
if (!window.__GRIDPILOT_FETCH_LOGGED__) {
const loggedFetch = apiLogger.createLoggedFetch();
window.fetch = loggedFetch as any;
(window as any).__GRIDPILOT_FETCH_LOGGED__ = true;
window.fetch = loggedFetch as typeof fetch;
window.__GRIDPILOT_FETCH_LOGGED__ = true;
}
(window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
(window as any).__GRIDPILOT_API_LOGGER__ = apiLogger;
window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
window.__GRIDPILOT_API_LOGGER__ = apiLogger;
}, []);
const disable = useCallback(() => {

View File

@@ -1,4 +1,4 @@
import Link from 'next/link';
import { Link } from '@/ui/Link';
import { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
@@ -35,7 +35,8 @@ export function DriverIdentity(props: DriverIdentityProps) {
alignItems="center"
justifyContent="center"
flexShrink={0}
style={{ width: avatarSize, height: avatarSize }}
w={`${avatarSize}px`}
h={`${avatarSize}px`}
>
{avatarUrl ? (
<Image
@@ -54,17 +55,17 @@ export function DriverIdentity(props: DriverIdentityProps) {
<Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} minWidth="0">
<Text size={nameSize} weight="medium" color="text-white" className="truncate">
<Text size={nameSize} weight="medium" color="text-white" truncate>
{driver.name}
</Text>
{contextLabel && (
<Badge variant="default" className="bg-charcoal-outline/60 text-[10px] md:text-xs">
<Badge variant="default" bg="bg-charcoal-outline/60" size="xs">
{contextLabel}
</Badge>
)}
</Box>
{meta && (
<Text size="xs" color="text-gray-400" mt={0.5} className="truncate" block>
<Text size="xs" color="text-gray-400" mt={0.5} truncate block>
{meta}
</Text>
)}
@@ -74,7 +75,7 @@ export function DriverIdentity(props: DriverIdentityProps) {
if (href) {
return (
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
<Link href={href} block variant="ghost">
{content}
</Link>
);

View File

@@ -2,7 +2,6 @@
import React from 'react';
import { Check } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Surface } from '@/ui/Surface';

View File

@@ -5,7 +5,7 @@ import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
import { Icon } from '@/ui/Icon';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { processLeagueActivities } from '@/lib/services/league/LeagueActivityService';
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
export type LeagueActivity =
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
@@ -37,8 +37,10 @@ export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedP
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
const activities = useMemo(() => {
if (isLoading || raceList.length === 0) return [];
return processLeagueActivities(raceList, limit);
if (isLoading || !Array.isArray(raceList) || raceList.length === 0) return [];
const service = new LeagueActivityService();
const result = service.processLeagueActivities(raceList, limit);
return result.isOk() ? result.unwrap() : [];
}, [raceList, isLoading, limit]);
if (isLoading) {

View File

@@ -24,7 +24,6 @@ interface LeagueMemberRowProps {
export function LeagueMemberRow({
driver,
driverId,
isCurrentUser,
isTopPerformer,
role,

View File

@@ -44,16 +44,17 @@ export function LeagueMembers({
const membershipData = leagueMembershipService.getLeagueMembers(leagueId);
setMembers(membershipData);
const uniqueDriverIds = Array.from(new Set(membershipData.map((m) => m.driverId)));
const uniqueDriverIds = Array.from(new Set(membershipData.map((m: LeagueMembership) => m.driverId)));
if (uniqueDriverIds.length > 0) {
const driverDtos = await driverService.findByIds(uniqueDriverIds);
const byId: Record<string, DriverViewModel> = {};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
for (const dto of driverDtos as any[]) {
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
const result = await driverService.findByIds(uniqueDriverIds);
if (result.isOk()) {
const driverDtos = result.unwrap();
const byId: Record<string, DriverViewModel> = {};
for (const dto of driverDtos) {
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
}
setDriversById(byId);
}
setDriversById(byId);
} else {
setDriversById({});
}

View File

@@ -50,8 +50,9 @@ export function LeagueSponsorshipsSection({
const [tempPrice, setTempPrice] = useState<string>('');
// Load season ID if not provided
const { data: seasons = [] } = useLeagueSeasons(leagueId);
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
const { data: seasonsResult } = useLeagueSeasons(leagueId);
const seasons = seasonsResult?.isOk() ? seasonsResult.unwrap() : [];
const activeSeason = seasons.find((s: any) => s.status === 'active') ?? seasons[0];
const seasonId = propSeasonId || activeSeason?.seasonId;
// Load pending sponsorship requests

View File

@@ -2,7 +2,6 @@
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
import { Box } from "@/ui/Box";
import { Card } from "@/ui/Card";
import { ProtestListItem } from "./ProtestListItem";
@@ -12,17 +11,13 @@ import { Flag } from "lucide-react";
interface PendingProtestsListProps {
protests: ProtestViewModel[];
races: Record<string, RaceViewModel>;
drivers: Record<string, DriverViewModel>;
leagueId: string;
onReviewProtest: (protest: ProtestViewModel) => void;
onProtestReviewed: () => void;
}
export function PendingProtestsList({
protests,
drivers,
leagueId,
onReviewProtest,
}: PendingProtestsListProps) {

View File

@@ -57,10 +57,10 @@ export function ReviewProtestModal({
const [submitting, setSubmitting] = useState(false);
const [showConfirmation, setShowConfirmation] = useState(false);
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const { data: penaltyTypesReferenceResult, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
const penaltyOptions = useMemo(() => {
const refs = penaltyTypesReference?.penaltyTypes ?? [];
const refs = penaltyTypesReferenceResult?.isOk() ? penaltyTypesReferenceResult.unwrap().penaltyTypes : [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return refs.map((ref: any) => ({
type: ref.type as PenaltyType,
@@ -71,7 +71,7 @@ export function ReviewProtestModal({
Icon: getPenaltyIcon(ref.type),
colorClass: getPenaltyColor(ref.type),
}));
}, [penaltyTypesReference]);
}, [penaltyTypesReferenceResult]);
const selectedPenalty = useMemo(() => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -99,7 +99,7 @@ export function ReviewProtestModal({
}
};
const getPenaltyIcon = (type: PenaltyType) => {
function getPenaltyIcon(type: PenaltyType) {
switch (type) {
case "time_penalty":
return Clock;
@@ -122,9 +122,9 @@ export function ReviewProtestModal({
default:
return AlertCircle;
}
};
}
const getPenaltyName = (type: PenaltyType) => {
function getPenaltyName(type: PenaltyType) {
switch (type) {
case "time_penalty":
return "Time Penalty";
@@ -147,9 +147,9 @@ export function ReviewProtestModal({
default:
return type.replaceAll("_", " ");
}
};
}
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
switch (valueKind) {
case "seconds":
return "seconds";
@@ -162,9 +162,9 @@ export function ReviewProtestModal({
case "none":
return "";
}
};
}
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
function getPenaltyDefaultValue(type: PenaltyType, valueKind: PenaltyValueKindDTO): number {
if (type === "license_points") return 2;
if (type === "race_ban") return 1;
switch (valueKind) {
@@ -179,9 +179,9 @@ export function ReviewProtestModal({
case "none":
return 0;
}
};
}
const getPenaltyColor = (type: PenaltyType) => {
function getPenaltyColor(type: PenaltyType) {
switch (type) {
case "time_penalty":
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
@@ -204,7 +204,7 @@ export function ReviewProtestModal({
default:
return "text-warning-amber bg-warning-amber/10 border-warning-amber/30";
}
};
}
if (showConfirmation) {
return (
@@ -351,7 +351,6 @@ export function ReviewProtestModal({
</Box>
)}
</Stack>
<Box borderTop borderColor="border-gray-800" pt={6}>
<Stack gap={4}>
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Stewarding Decision</Heading>
@@ -471,4 +470,4 @@ export function ReviewProtestModal({
</Stack>
</Modal>
);
}
}

View File

@@ -1,6 +0,0 @@
export { useCurrentSession } from './useCurrentSession';
export { useLogin } from './useLogin';
export { useLogout } from './useLogout';
export { useSignup } from './useSignup';
export { useForgotPassword } from './useForgotPassword';
export { useResetPassword } from './useResetPassword';

View File

@@ -12,7 +12,15 @@ export function useCurrentSession(
const queryResult = useQuery({
queryKey: ['currentSession'],
queryFn: () => sessionService.getSession(),
queryFn: async () => {
const result = await sessionService.getSession();
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
const session = result.unwrap();
return session ? new SessionViewModel(session.user) : null;
},
initialData: options?.initialData,
...options,
});

View File

@@ -9,7 +9,13 @@ export function useLogout(
const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => authService.logout(),
mutationFn: async () => {
const result = await authService.logout();
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}

View File

@@ -1,6 +0,0 @@
export { useCurrentDriver } from './useCurrentDriver';
export { useDriverLeaderboard } from '@/lib/hooks/useDriverLeaderboard';
export { useDriverProfile } from './useDriverProfile';
export { useUpdateDriverProfile } from './useUpdateDriverProfile';
export { useCreateDriver } from './useCreateDriver';
export { useFindDriverById } from './useFindDriverById';

View File

@@ -3,17 +3,22 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
type DriverData = any; // Replace with actual type
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
export function useCurrentDriver(
options?: Omit<UseQueryOptions<DriverData, ApiError>, 'queryKey' | 'queryFn'>
options?: Omit<UseQueryOptions<DriverDTO | null, ApiError>, 'queryKey' | 'queryFn'>
) {
const driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['currentDriver'],
queryFn: () => driverService.getCurrentDriver(),
queryFn: async () => {
const result = await driverService.getCurrentDriver();
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
...options,
});

View File

@@ -3,7 +3,7 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
import { DriverProfileViewModel, type DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
export function useDriverProfile(
driverId: string,
@@ -13,10 +13,17 @@ export function useDriverProfile(
const queryResult = useQuery({
queryKey: ['driverProfile', driverId],
queryFn: () => driverService.getDriverProfile(driverId),
queryFn: async () => {
const result = await driverService.getDriverProfile(driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new globalThis.Date().toISOString() });
}
return new DriverProfileViewModel(result.unwrap() as unknown as DriverProfileViewModelData);
},
enabled: !!driverId,
...options,
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -13,10 +13,17 @@ export function useFindDriverById(
const queryResult = useQuery({
queryKey: ['driver', driverId],
queryFn: () => driverService.findById(driverId),
queryFn: async () => {
const result = await driverService.findById(driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
enabled: !!driverId,
...options,
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -1,14 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useLeagueMembershipMutation() {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const queryClient = useQueryClient();
const joinLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
leagueMembershipService.joinLeague(leagueId, driverId),
mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
const result = await leagueMembershipService.joinLeague(leagueId, driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
@@ -16,8 +22,13 @@ export function useLeagueMembershipMutation() {
});
const leaveLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
leagueMembershipService.leaveLeague(leagueId, driverId),
mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
const result = await leagueMembershipService.leaveLeague(leagueId, driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
@@ -28,4 +39,4 @@ export function useLeagueMembershipMutation() {
joinLeague: joinLeagueMutation,
leaveLeague: leaveLeagueMutation,
};
}
}

View File

@@ -26,7 +26,13 @@ export function useLeagueRosterAdmin(leagueId: string, options?: UseQueryOptions
return useQuery<LeagueRosterMemberDTO[], ApiError>({
queryKey: ['league-roster-admin', leagueId],
queryFn: () => leagueService.getAdminRosterMembers(leagueId),
queryFn: async () => {
const result = await leagueService.getAdminRosterMembers(leagueId);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
}
@@ -36,7 +42,13 @@ export function useLeagueJoinRequests(leagueId: string, options?: UseQueryOption
return useQuery<LeagueRosterJoinRequestDTO[], ApiError>({
queryKey: ['league-join-requests', leagueId],
queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId),
queryFn: async () => {
const result = await leagueService.getAdminRosterJoinRequests(leagueId);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
}
@@ -48,7 +60,11 @@ export function useUpdateMemberRole(
return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({
mutationFn: async (input) => {
return leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole);
const result = await leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
@@ -61,7 +77,11 @@ export function useRemoveMember(
return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({
mutationFn: async (input) => {
return leagueService.removeMember(input.leagueId, input.driverId);
const result = await leagueService.removeMember(input.leagueId, input.driverId);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
@@ -74,7 +94,11 @@ export function useApproveJoinRequest(
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => {
return leagueService.approveJoinRequest(input.leagueId, input.requestId);
const result = await leagueService.approveJoinRequest(input.leagueId, input.requestId);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
@@ -87,8 +111,12 @@ export function useRejectJoinRequest(
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => {
return leagueService.rejectJoinRequest(input.leagueId, input.requestId);
const result = await leagueService.rejectJoinRequest(input.leagueId, input.requestId);
if (result.isErr()) {
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
...options,
});
}
}

View File

@@ -3,21 +3,19 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime();
const isUpcoming = !isPast;
return {
id: race.id,
name: race.name,
scheduledAt,
isPast,
isUpcoming,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',
track: undefined,
car: undefined,
@@ -32,7 +30,11 @@ export function useLeagueSchedule(leagueId: string) {
const queryResult = useQuery({
queryKey: ['leagueSchedule', leagueId],
queryFn: async (): Promise<LeagueScheduleViewModel> => {
const dto = await leagueService.getLeagueSchedule(leagueId);
const result = await leagueService.getLeagueSchedule(leagueId);
if (result.isErr()) {
throw new Error(result.getError().message);
}
const dto = result.unwrap();
const races = dto.races.map(mapRaceDtoToViewModel);
return new LeagueScheduleViewModel(races);
},
@@ -40,4 +42,4 @@ export function useLeagueSchedule(leagueId: string) {
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -5,7 +5,6 @@ import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
@@ -13,14 +12,13 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
const now = new Date();
const isPast = scheduledAt.getTime() < now.getTime();
const isUpcoming = !isPast;
return {
id: race.id,
name: race.name,
scheduledAt,
isPast,
isUpcoming,
isUpcoming: !isPast,
status: isPast ? 'completed' : 'scheduled',
track: undefined,
car: undefined,
@@ -49,7 +47,11 @@ export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
return usePageData({
queryKey: ['leagueSeasons', leagueId],
queryFn: async (): Promise<LeagueSeasonSummaryViewModel[]> => {
const dtos = await leagueService.getLeagueSeasonSummaries(leagueId);
const result = await leagueService.getLeagueSeasonSummaries(leagueId);
if (result.isErr()) {
throw new Error(result.getError().message);
}
const dtos = result.unwrap();
return dtos.map((dto: LeagueSeasonSummaryDTO) => new LeagueSeasonSummaryViewModel(dto));
},
enabled: !!leagueId && !!isAdmin,
@@ -62,7 +64,11 @@ export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: strin
return usePageData({
queryKey: ['adminSchedule', leagueId, selectedSeasonId],
queryFn: async (): Promise<LeagueAdminScheduleViewModel> => {
const dto = await leagueService.getAdminSchedule(leagueId, selectedSeasonId);
const result = await leagueService.getAdminSchedule(leagueId, selectedSeasonId);
if (result.isErr()) {
throw new Error(result.getError().message);
}
const dto = result.unwrap();
const races = dto.races.map(mapRaceDtoToViewModel);
return new LeagueAdminScheduleViewModel({
seasonId: dto.seasonId,
@@ -72,4 +78,4 @@ export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: strin
},
enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
});
}
}

View File

@@ -2,7 +2,7 @@ import { usePageMutation } from '@/lib/page/usePageData';
export function useLeagueStewardingMutations(onRefetch: () => void) {
const acceptProtestMutation = usePageMutation(
async (variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
async (_variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
// TODO: Implement protest review and penalty application
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,
@@ -28,7 +28,7 @@ export function useLeagueStewardingMutations(onRefetch: () => void) {
);
const rejectProtestMutation = usePageMutation(
async (variables: { protestId: string; stewardNotes: string }) => {
async (_variables: { protestId: string; stewardNotes: string }) => {
// TODO: Implement protest rejection
// await leagueStewardingService.reviewProtest({
// protestId: variables.protestId,

View File

@@ -22,7 +22,7 @@ export function useLeagueWalletPageData(leagueId: string) {
amount: t.amount,
fee: t.fee,
netAmount: t.netAmount,
date: new Date(t.date),
date: new globalThis.Date(t.date),
status: t.status,
reference: t.reference,
}));
@@ -48,6 +48,6 @@ export function useLeagueWalletPageData(leagueId: string) {
* @deprecated Use useLeagueWalletWithdrawalWithBlockers instead
* This wrapper maintains backward compatibility while using the new blocker-aware hook
*/
export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
export function useLeagueWalletWithdrawal(leagueId: string, data: LeagueWalletViewModel | null, refetch: () => void) {
return useLeagueWalletWithdrawalWithBlockers(leagueId, data, refetch);
}

View File

@@ -3,13 +3,15 @@
import { usePageMutation } from '@/lib/page/usePageData';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
import { SubmitBlocker } from '@/lib/blockers/SubmitBlocker';
import { ThrottleBlocker } from '@/lib/blockers/ThrottleBlocker';
import type { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
/**
* Hook for wallet withdrawals with client-side blockers
* Handles UX prevention mechanisms (rate limiting, duplicate submission prevention)
*/
export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: any, refetch: () => void) {
export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: LeagueWalletViewModel | null, refetch: () => void) {
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
// Client-side blockers for UX improvement
@@ -18,11 +20,11 @@ export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: an
const withdrawMutation = usePageMutation(
async ({ amount }: { amount: number }) => {
if (!data) throw new Error('Wallet data not available');
if (!data) throw new globalThis.Error('Wallet data not available');
// Client-side blockers (UX only, not security)
if (!submitBlocker.canExecute() || !throttle.canExecute()) {
throw new Error('Request blocked due to rate limiting');
throw new globalThis.Error('Request blocked due to rate limiting');
}
submitBlocker.block();
@@ -38,7 +40,7 @@ export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: an
);
if (!result.success) {
throw new Error(result.message || 'Withdrawal failed');
throw new globalThis.Error(result.message || 'Withdrawal failed');
}
return result;

View File

@@ -7,7 +7,7 @@ export function usePenaltyMutation() {
const queryClient = useQueryClient();
const applyPenaltyMutation = useMutation({
mutationFn: (command: any) => penaltyService.applyPenalty(command),
mutationFn: (command: unknown) => penaltyService.applyPenalty(command),
onSuccess: () => {
// Invalidate relevant queries to refresh data
queryClient.invalidateQueries({ queryKey: ['leagueStewardingData'] });

View File

@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
export function useProtestDetail(leagueId: string, protestId: string, enabled: boolean = true) {
const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['protestDetail', leagueId, protestId],
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
queryFn: async () => {
const result = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
enabled: enabled && !!leagueId && !!protestId,
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -1,7 +1,6 @@
'use client';
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
import { Result } from '@/lib/contracts/Result';
import { DomainError } from '@/lib/contracts/services/Service';
@@ -21,8 +20,7 @@ export function useGenerateAvatars(
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
) {
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
mutationFn: async (params) => {
const service = new OnboardingService();
mutationFn: async (_params) => {
// This method doesn't exist in the service yet, but the hook is now created
// The service will need to implement this or we need to adjust the architecture
return Result.ok({ success: false, errorMessage: 'Not implemented' });

View File

@@ -10,7 +10,13 @@ export function useFileProtest(
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, FileProtestCommandDTO>({
mutationFn: (command) => raceService.fileProtest(command),
mutationFn: async (command) => {
const result = await raceService.fileProtest(command);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}
}

View File

@@ -15,7 +15,13 @@ export function useRegisterForRace(
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, RegisterForRaceParams>({
mutationFn: (params) => raceService.registerForRace(params.raceId, params.leagueId, params.driverId),
mutationFn: async (params) => {
const result = await raceService.registerForRace(params.raceId, params.leagueId, params.driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}

View File

@@ -14,7 +14,13 @@ export function useWithdrawFromRace(
const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, WithdrawFromRaceParams>({
mutationFn: (params) => raceService.withdrawFromRace(params.raceId, params.driverId),
mutationFn: async (params) => {
const result = await raceService.withdrawFromRace(params.raceId, params.driverId);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}

View File

@@ -1,5 +0,0 @@
export { useAvailableLeagues } from './useAvailableLeagues';
export { useSponsorDashboard } from './useSponsorDashboard';
export { useSponsorSponsorships } from './useSponsorSponsorships';
export { useSponsorBilling } from './useSponsorBilling';
export { useSponsorLeagueDetail } from './useSponsorLeagueDetail';

View File

@@ -11,7 +11,7 @@ export function useSponsorBilling(sponsorId: string) {
queryFn: async () => {
const result = await sponsorService.getBilling(sponsorId);
if (result.isErr()) {
throw result.getError();
throw new Error(result.getError().message);
}
return result.unwrap();
},
@@ -19,4 +19,4 @@ export function useSponsorBilling(sponsorId: string) {
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -1,11 +1,11 @@
import { useAuth } from '@/lib/auth/AuthContext';
import React from 'react';
import { useState, useEffect } from 'react';
export function useSponsorMode(): boolean {
const { session } = useAuth();
const [isSponsor, setIsSponsor] = React.useState(false);
const [isSponsor, setIsSponsor] = useState(false);
React.useEffect(() => {
useEffect(() => {
if (!session) {
setIsSponsor(false);
return;

View File

@@ -1,13 +0,0 @@
export { useAllTeams } from './useAllTeams';
export { useTeamDetails } from './useTeamDetails';
export { useTeamMembers } from './useTeamMembers';
export { useTeamJoinRequests } from './useTeamJoinRequests';
export { useCreateTeam } from './useCreateTeam';
export { useUpdateTeam } from './useUpdateTeam';
export { useTeamMembership } from './useTeamMembership';
export { useApproveJoinRequest } from './useApproveJoinRequest';
export { useRejectJoinRequest } from './useRejectJoinRequest';
export { useTeamStandings } from './useTeamStandings';
export { useJoinTeam } from './useJoinTeam';
export { useLeaveTeam } from './useLeaveTeam';
export { useTeamRoster } from './useTeamRoster';

View File

@@ -7,7 +7,13 @@ export function useApproveJoinRequest(options?: Omit<UseMutationOptions<void, Ap
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => teamJoinService.approveJoinRequest(),
mutationFn: async () => {
const result = await teamJoinService.approveJoinRequest();
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}
}

View File

@@ -1,6 +1,4 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface JoinTeamParams {
@@ -10,8 +8,6 @@ interface JoinTeamParams {
}
export function useJoinTeam(options?: Omit<UseMutationOptions<void, ApiError, JoinTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, JoinTeamParams>({
mutationFn: async (params) => {
// Note: Team join functionality would need to be added to teamService

View File

@@ -1,6 +1,4 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
interface LeaveTeamParams {
@@ -9,8 +7,6 @@ interface LeaveTeamParams {
}
export function useLeaveTeam(options?: Omit<UseMutationOptions<void, ApiError, LeaveTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, LeaveTeamParams>({
mutationFn: async (params) => {
// Note: Leave team functionality would need to be added to teamService

View File

@@ -7,7 +7,13 @@ export function useRejectJoinRequest(options?: Omit<UseMutationOptions<void, Api
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({
mutationFn: () => teamJoinService.rejectJoinRequest(),
mutationFn: async () => {
const result = await teamJoinService.rejectJoinRequest();
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
},
...options,
});
}
}

View File

@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
export function useTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean) {
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
const queryResult = useQuery({
queryKey: ['teamJoinRequests', teamId, currentUserId, isOwner],
queryFn: () => teamJoinService.getJoinRequests(teamId, currentUserId, isOwner),
queryFn: async () => {
const result = await teamJoinService.getJoinRequests(teamId, currentUserId, isOwner);
if (result.isErr()) {
const error = result.getError();
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
}
return result.unwrap();
},
enabled: !!teamId && !!currentUserId,
});
return enhanceQueryResult(queryResult);
}
}

View File

@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO';
export function useTeamMembership(teamId: string, driverId: string) {
const teamService = useInject(TEAM_SERVICE_TOKEN);

Some files were not shown because too many files have changed in this diff Show More