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": { "rules": {
"@next/next/no-img-element": "error", "@next/next/no-img-element": "error",
"@typescript-eslint/no-explicit-any": "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": [ "boundaries/element-types": [
2, 2,
{ {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { notFound, redirect } from 'next/navigation'; 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'; import { DashboardTemplate } from '@/templates/DashboardTemplate';
export default async function DashboardPage() { export default async function DashboardPage() {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { notFound, redirect } from 'next/navigation'; 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 { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation'; 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 { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';

View File

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

View File

@@ -10,8 +10,11 @@ import {
useRejectJoinRequest, useRejectJoinRequest,
useUpdateMemberRole, useUpdateMemberRole,
useRemoveMember, useRemoveMember,
} from "@/lib/hooks/league/useLeagueRosterAdmin"; } from "@/hooks/league/useLeagueRosterAdmin";
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate'; 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']; const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
@@ -71,10 +74,25 @@ export function RosterAdminPage() {
await removeMemberMutation.mutateAsync({ leagueId, driverId }); 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 ( return (
<RosterAdminTemplate <RosterAdminTemplate
joinRequests={joinRequests} viewData={viewData}
members={members}
loading={loading} loading={loading}
pendingCountLabel={pendingCountLabel} pendingCountLabel={pendingCountLabel}
onApprove={handleApprove} onApprove={handleApprove}
@@ -84,4 +102,4 @@ export function RosterAdminPage() {
roleOptions={ROLE_OPTIONS} 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 { RulebookTemplate } from '@/templates/RulebookTemplate';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -17,18 +17,21 @@ export default async function Page({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // For serverError, show the template with empty data
return <RulebookTemplate viewData={{ return <RulebookTemplate viewData={{
leagueId, leagueId,
scoringConfig: { gameName: 'Unknown',
gameName: 'Unknown', scoringPresetName: 'Unknown',
scoringPresetName: 'Unknown', championshipsCount: 0,
championships: [], sessionTypes: 'None',
dropPolicySummary: 'Unknown', 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'; interface Props {
import { useParams } from 'next/navigation'; params: Promise<{ id: string }>;
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';
export default function LeagueAdminSchedulePage() { export default async function Page({ params }: Props) {
const params = useParams(); const { id } = await params;
const leagueId = params.id as string;
const currentDriverId = useEffectiveDriverId() || '';
const queryClient = useQueryClient();
const leagueService = useInject(LEAGUE_SERVICE_TOKEN); // Execute the PageQuery
const result = await LeagueScheduleAdminPageQuery.execute({ leagueId: id });
// Form state
const [seasonId, setSeasonId] = useState<string>(''); if (result.isErr()) {
const [track, setTrack] = useState(''); const error = result.getError();
const [car, setCar] = useState(''); if (error === 'notFound') {
const [scheduledAtIso, setScheduledAtIso] = useState(''); notFound();
const [editingRaceId, setEditingRaceId] = useState<string | null>(null); }
// For other errors, we still render the client component which handles its own loading/error states
// Check admin status using domain hook // or we could render an error banner here.
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>
);
} }
// Template component that wraps the actual template with all props return <LeagueAdminSchedulePageClient />;
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',
}}
/>
);
}

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 { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSchedulePage({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // 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 { LeagueSettingsTemplate } from '@/templates/LeagueSettingsTemplate';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function LeagueSettingsPage({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // For serverError, show the template with empty data
@@ -29,8 +29,8 @@ export default async function LeagueSettingsPage({ params }: Props) {
description: 'League information unavailable', description: 'League information unavailable',
visibility: 'private', visibility: 'private',
ownerId: 'unknown', ownerId: 'unknown',
createdAt: new Date().toISOString(), createdAt: '1970-01-01T00:00:00Z',
updatedAt: new Date().toISOString(), updatedAt: '1970-01-01T00:00:00Z',
}, },
config: { config: {
maxDrivers: 0, 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 { LeagueSponsorshipsTemplate } from '@/templates/LeagueSponsorshipsTemplate';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -17,12 +17,14 @@ export default async function LeagueSponsorshipsPage({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // For serverError, show the template with empty data
return <LeagueSponsorshipsTemplate viewData={{ return <LeagueSponsorshipsTemplate viewData={{
leagueId, leagueId,
activeTab: 'overview',
onTabChange: () => {},
league: { league: {
id: leagueId, id: leagueId,
name: 'Unknown League', 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 { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
@@ -17,7 +17,7 @@ export default async function Page({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // 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 { LeagueStewardingPageQuery } from '@/lib/page-queries/LeagueStewardingPageQuery';
import { StewardingTemplate } from '@/templates/StewardingTemplate'; import { StewardingPageClient } from './StewardingPageClient';
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
interface Props { interface Props {
@@ -17,19 +17,31 @@ export default async function LeagueStewardingPage({ params }: Props) {
if (result.isErr()) { if (result.isErr()) {
const error = result.getError(); const error = result.getError();
if (error.type === 'notFound') { if (error === 'notFound') {
notFound(); notFound();
} }
// For serverError, show the template with empty data // For serverError, show the template with empty data
return <StewardingTemplate viewData={{ return <StewardingPageClient
leagueId, leagueId={leagueId}
totalPending: 0, currentDriverId=""
totalResolved: 0, onRefetch={() => {}}
totalPenalties: 0, data={{
races: [], leagueId,
drivers: [] 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'; interface Props {
import { Card } from '@/ui/Card'; params: Promise<{ id: string; protestId: string }>;
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>;
} }
type PenaltyUiConfig = { export default async function Page({ params }: Props) {
label: string; const { id, protestId } = await params;
description: string;
icon: typeof Gavel; // Execute the PageQuery
color: string; const result = await LeagueProtestDetailPageQuery.execute({ leagueId: id, protestId });
defaultValue?: number;
}; if (result.isErr()) {
const error = result.getError();
const PENALTY_UI: Record<string, PenaltyUiConfig> = { if (error === 'notFound') {
time_penalty: { notFound();
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);
} }
}, [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 const viewData = result.isOk() ? result.unwrap() : null;
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>
);
}
return ( return <ProtestDetailPageClient initialViewData={viewData} />;
<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>
);
} }

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

View File

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

View File

@@ -1,6 +1,6 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { LeaguesClient } from '@/components/leagues/LeaguesClient'; import { LeaguesPageClient } from './LeaguesPageClient';
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery'; import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
export default async function Page() { export default async function Page() {
// Execute the PageQuery // Execute the PageQuery
@@ -20,11 +20,11 @@ export default async function Page() {
case 'UNKNOWN_ERROR': case 'UNKNOWN_ERROR':
default: default:
// Return error state - use LeaguesTemplate with empty data // Return error state - use LeaguesTemplate with empty data
return <LeaguesClient viewData={{ leagues: [] }} />; return <LeaguesPageClient viewData={{ leagues: [] }} />;
} }
} }
const viewData = result.unwrap(); 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() { export default function NotFound() {
return ( return (
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6"> <Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" color="text-white" px={6}>
<div className="max-w-md text-center space-y-4"> <Box maxWidth="md" textAlign="center">
<h1 className="text-3xl font-semibold">Page not found</h1> <Stack gap={4}>
<p className="text-sm text-gray-400"> <Heading level={1} fontSize="3xl" weight="semibold">Page not found</Heading>
The page you requested doesn't exist (or isn't available in this mode). <Text size="sm" color="text-gray-400" block>
</p> The page you requested doesn&apos;t exist (or isn&apos;t available in this mode).
<div className="pt-2"> </Text>
<Link <Box pt={2}>
href="/" <Link
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" href="/"
> variant="primary"
Drive home size="sm"
</Link> weight="medium"
</div> rounded="md"
</div> px={4}
</main> py={2}
>
Drive home
</Link>
</Box>
</Stack>
</Box>
</Box>
); );
} }

View File

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

View File

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

View File

@@ -1,15 +1,27 @@
import { PageWrapper } from '@/components/shared/state/PageWrapper'; 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 { PageDataFetcher } from '@/lib/page/PageDataFetcher';
import { getHomeData } from '@/lib/services/home/getHomeData'; import { HomeService } from '@/lib/services/home/HomeService'; // @server-safe
import { notFound } from 'next/navigation'; import { notFound, redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
export default async function Page() { 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) { if (!data) {
notFound(); 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 { 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'; import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
export default async function ProfileLeaguesPage() { export default async function ProfileLeaguesPage() {

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation'; 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 { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions'; import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';

View File

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

View File

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

View File

@@ -34,20 +34,21 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
<StatefulPageWrapper <StatefulPageWrapper
data={null} data={null}
isLoading={false} isLoading={false}
error={new Error('Failed to load race results')} error={new globalThis.Error('Failed to load race results')}
retry={() => Promise.resolve()} retry={() => Promise.resolve()}
Template={({ data: _data }) => ( Template={() => (
<RaceResultsTemplate <RaceResultsTemplate
raceTrack={undefined} viewData={{
raceScheduledAt={undefined} raceTrack: '',
totalDrivers={undefined} raceScheduledAt: '',
leagueName={undefined} totalDrivers: 0,
raceSOF={null} leagueName: '',
results={[]} raceSOF: null,
penalties={[]} results: [],
pointsSystem={{}} penalties: [],
fastestLapTime={0} pointsSystem: {},
currentDriverId={''} fastestLapTime: 0,
}}
isAdmin={false} isAdmin={false}
isLoading={false} isLoading={false}
error={null} error={null}
@@ -82,18 +83,9 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
isLoading={false} isLoading={false}
error={null} error={null}
retry={() => Promise.resolve()} retry={() => Promise.resolve()}
Template={({ data: _data }) => ( Template={() => (
<RaceResultsTemplate <RaceResultsTemplate
raceTrack={viewData.raceTrack} viewData={viewData}
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={''}
isAdmin={false} isAdmin={false}
isLoading={false} isLoading={false}
error={null} 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 { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper'; 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 { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
import { Gavel } from 'lucide-react'; import { Gavel } from 'lucide-react';
import { useState } from 'react'; import { useState, useEffect, useCallback } from 'react';
interface RaceStewardingPageProps { interface RaceStewardingPageProps {
params: { params: {
@@ -20,12 +23,12 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
} }
// Data state // Data state
const [pageData, setPageData] = useState<any>(null); const [pageData, setPageData] = useState<RaceStewardingViewData | undefined>(undefined);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null); const [error, setError] = useState<Error | null>(null);
// Fetch function // Fetch function
const fetchData = async () => { const fetchData = useCallback(async () => {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
@@ -33,40 +36,31 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
const result = await RaceStewardingPageQuery.execute({ raceId }); const result = await RaceStewardingPageQuery.execute({ raceId });
if (result.isErr()) { if (result.isErr()) {
throw new Error('Failed to fetch stewarding data'); throw new globalThis.Error('Failed to fetch stewarding data');
} }
setPageData(result.unwrap()); setPageData(result.unwrap());
} catch (err) { } catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error')); setError(err instanceof globalThis.Error ? err : new globalThis.Error('Unknown error'));
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; }, [raceId]);
// Transform data for template useEffect(() => {
const templateData = pageData ? { fetchData();
race: pageData.race, }, [fetchData]);
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;
// Actions // Actions
const handleBack = () => { const handleBack = useCallback(() => {
window.history.back(); window.history.back();
}; }, []);
const handleReviewProtest = (protestId: string) => { const handleReviewProtest = useCallback((protestId: string) => {
if (templateData?.league?.id) { if (pageData?.league?.id) {
window.location.href = `/leagues/${templateData.league.id}/stewarding/protests/${protestId}`; window.location.href = `/leagues/${pageData.league.id}/stewarding/protests/${protestId}`;
} }
}; }, [pageData?.league?.id]);
return ( return (
<PageWrapper <PageWrapper
@@ -74,9 +68,9 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
isLoading={isLoading} isLoading={isLoading}
error={error} error={error}
retry={fetchData} retry={fetchData}
Template={({ data: _data }) => ( Template={({ data }) => (
<RaceStewardingTemplate <RaceStewardingTemplate
stewardingData={templateData} viewData={data as RaceStewardingViewData}
isLoading={false} isLoading={false}
error={null} error={null}
onBack={handleBack} 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 { notFound } from 'next/navigation';
import { useState, useEffect } 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 { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
import { Flag } from 'lucide-react'; import { RacesAllPageClient } from './RacesAllPageClient';
const ITEMS_PER_PAGE = 10; export default async function Page() {
// Execute the PageQuery
interface Race { const result = await RacesAllPageQuery.execute();
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();
// Client-side state for filters and pagination if (result.isErr()) {
const [currentPage, setCurrentPage] = useState(1); const error = result.getError();
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all'); if (error === 'notFound') {
const [leagueFilter, setLeagueFilter] = useState<string>('all'); notFound();
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);
} }
}; // For other errors, we still render the client component which handles its own loading/error states
}
// Fetch on mount const viewData = result.isOk() ? result.unwrap() : null;
useEffect(() => {
fetchData();
}, []);
// Transform data return <RacesAllPageClient initialViewData={viewData} />;
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',
}}
/>
);
}

View File

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

View File

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

View File

@@ -3,15 +3,18 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate'; 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 [searchQuery, setSearchQuery] = useState('');
const [tierFilter, setTierFilter] = useState<TierFilter>('all'); const [tierFilter] = useState<TierFilter>('all');
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all'); const [availabilityFilter] = useState<AvailabilityFilter>('all');
const [sortBy, setSortBy] = useState<SortOption>('rating'); const [sortBy] = useState<SortOption>('rating');
const filteredLeagues = useMemo(() => { const filteredLeagues = useMemo(() => {
if (!data?.leagues) return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any
return data.leagues const d = data as any;
if (!d?.leagues) return [];
return d.leagues
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.filter((league: any) => { .filter((league: any) => {
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false; return false;
@@ -27,6 +30,7 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
} }
return true; return true;
}) })
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.sort((a: any, b: any) => { .sort((a: any, b: any) => {
switch (sortBy) { switch (sortBy) {
case 'rating': return b.rating - a.rating; case 'rating': return b.rating - a.rating;
@@ -36,20 +40,15 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
default: return 0; default: return 0;
} }
}); });
}, [data?.leagues, searchQuery, tierFilter, availabilityFilter, sortBy]); }, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
return ( return (
<SponsorLeaguesTemplate <SponsorLeaguesTemplate
viewData={data} // eslint-disable-next-line @typescript-eslint/no-explicit-any
viewData={data as any}
filteredLeagues={filteredLeagues} filteredLeagues={filteredLeagues}
searchQuery={searchQuery} searchQuery={searchQuery}
setSearchQuery={setSearchQuery} 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) // Calculate summary stats (business logic moved from view model)
const stats = { const stats = {
total: leaguesData.length, total: leaguesData.length,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mainAvailable: leaguesData.filter((l: any) => l.mainSponsorSlot.available).length, 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), 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), totalDrivers: leaguesData.reduce((sum: number, l: any) => sum + l.drivers, 0),
avgCpm: Math.round( avgCpm: Math.round(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
leaguesData.reduce((sum: number, l: any) => sum + l.cpm, 0) / leaguesData.length 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 { Button } from '@/ui/Button';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Toggle } from '@/ui/Toggle'; 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 { SectionHeader } from '@/ui/SectionHeader';
import { FormField } from '@/ui/FormField'; import { FormField } from '@/ui/FormField';
import { PageHeader } from '@/ui/PageHeader'; import { PageHeader } from '@/ui/PageHeader';
import { Image } from '@/ui/Image';
import { import {
Settings, Settings,
Building2, Building2,
@@ -201,33 +206,38 @@ export default function SponsorSettingsPage() {
}; };
return ( return (
<motion.div <Box
className="max-w-4xl mx-auto py-8 px-4" maxWidth="4xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants} variants={containerVariants}
initial="hidden" initial="hidden"
animate="visible" animate="visible"
> >
{/* Header */} {/* Header */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<PageHeader <PageHeader
icon={Settings} icon={Settings}
title="Sponsor Settings" title="Sponsor Settings"
description="Manage your company profile, notifications, and security preferences" description="Manage your company profile, notifications, and security preferences"
action={<SavedIndicator visible={saved} />} action={<SavedIndicator visible={saved} />}
/> />
</motion.div> </Box>
{/* Company Profile */} {/* Company Profile */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden"> <Card className="mb-6 overflow-hidden">
<SectionHeader <SectionHeader
icon={Building2} icon={Building2}
title="Company Profile" title="Company Profile"
description="Your public-facing company information" description="Your public-facing company information"
/> />
<div className="p-6 space-y-6"> <Box p={6} className="space-y-6">
{/* Company Basic Info */} {/* 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> <FormField label="Company Name" icon={Building2} required>
<Input <Input
type="text" type="text"
@@ -238,24 +248,32 @@ export default function SponsorSettingsPage() {
</FormField> </FormField>
<FormField label="Industry"> <FormField label="Industry">
<select <Box as="select"
value={profile.industry} value={profile.industry}
onChange={(e) => setProfile({ ...profile, industry: e.target.value })} onChange={(e: React.ChangeEvent<HTMLSelectElement>) => 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" 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 => ( {INDUSTRY_OPTIONS.map(industry => (
<option key={industry} value={industry}>{industry}</option> <option key={industry} value={industry}>{industry}</option>
))} ))}
</select> </Box>
</FormField> </FormField>
</div> </Box>
{/* Contact Information */} {/* Contact Information */}
<div className="pt-4 border-t border-charcoal-outline/50"> <Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> <Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Contact Information Contact Information
</h3> </Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Contact Name" icon={User} required> <FormField label="Contact Name" icon={User} required>
<Input <Input
type="text" type="text"
@@ -291,16 +309,16 @@ export default function SponsorSettingsPage() {
placeholder="https://company.com" placeholder="https://company.com"
/> />
</FormField> </FormField>
</div> </Box>
</div> </Box>
{/* Address */} {/* Address */}
<div className="pt-4 border-t border-charcoal-outline/50"> <Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> <Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Business Address Business Address
</h3> </Heading>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<div className="md:col-span-2"> <Box colSpan={{ base: 1, md: 2 }}>
<FormField label="Street Address" icon={MapPin}> <FormField label="Street Address" icon={MapPin}>
<Input <Input
type="text" type="text"
@@ -312,7 +330,7 @@ export default function SponsorSettingsPage() {
placeholder="123 Main Street" placeholder="123 Main Street"
/> />
</FormField> </FormField>
</div> </Box>
<FormField label="City"> <FormField label="City">
<Input <Input
@@ -358,31 +376,39 @@ export default function SponsorSettingsPage() {
placeholder="XX12-3456789" placeholder="XX12-3456789"
/> />
</FormField> </FormField>
</div> </Box>
</div> </Box>
{/* Description */} {/* Description */}
<div className="pt-4 border-t border-charcoal-outline/50"> <Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<FormField label="Company Description"> <FormField label="Company Description">
<textarea <Box as="textarea"
value={profile.description} 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..." placeholder="Tell potential sponsorship partners about your company, products, and what you're looking for in sponsorship opportunities..."
rows={4} 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. This description appears on your public sponsor profile.
</p> </Text>
</FormField> </FormField>
</div> </Box>
{/* Social Links */} {/* Social Links */}
<div className="pt-4 border-t border-charcoal-outline/50"> <Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4"> <Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Social Media Social Media
</h3> </Heading>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <Box display="grid" gridCols={{ base: 1, md: 3 }} gap={6}>
<FormField label="Twitter / X" icon={LinkIcon}> <FormField label="Twitter / X" icon={LinkIcon}>
<Input <Input
type="text" type="text"
@@ -418,49 +444,49 @@ export default function SponsorSettingsPage() {
placeholder="@username" placeholder="@username"
/> />
</FormField> </FormField>
</div> </Box>
</div> </Box>
{/* Logo Upload */} {/* 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}> <FormField label="Company Logo" icon={ImageIcon}>
<div className="flex items-start gap-6"> <Stack direction="row" align="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"> <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 ? ( {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" /> <Building2 className="w-10 h-10 text-gray-600" />
)} )}
</div> </Box>
<div className="flex-1"> <Box flexGrow={1}>
<div className="flex items-center gap-3"> <Stack direction="row" align="center" gap={3}>
<label className="cursor-pointer"> <Text as="label" cursor="pointer">
<input <input
type="file" type="file"
accept="image/png,image/jpeg,image/svg+xml" accept="image/png,image/jpeg,image/svg+xml"
className="hidden" 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 className="w-4 h-4" />
Upload Logo <Text>Upload Logo</Text>
</div> </Box>
</label> </Text>
{profile.logoUrl && ( {profile.logoUrl && (
<Button variant="secondary" className="text-sm text-gray-400"> <Button variant="secondary" className="text-sm text-gray-400">
Remove Remove
</Button> </Button>
)} )}
</div> </Stack>
<p className="text-xs text-gray-500 mt-2"> <Text size="xs" color="text-gray-500" block mt={2}>
PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px. PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px.
</p> </Text>
</div> </Box>
</div> </Stack>
</FormField> </FormField>
</div> </Box>
{/* Save Button */} {/* 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 <Button
variant="primary" variant="primary"
onClick={handleSaveProfile} onClick={handleSaveProfile}
@@ -468,24 +494,24 @@ export default function SponsorSettingsPage() {
className="min-w-[160px]" className="min-w-[160px]"
> >
{saving ? ( {saving ? (
<span className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> <Box w="4" h="4" border borderColor="border-white/30" borderTopColor="border-t-white" rounded="full" animate="spin" />
Saving... <Text>Saving...</Text>
</span> </Stack>
) : ( ) : (
<span className="flex items-center gap-2"> <Stack direction="row" align="center" gap={2}>
<Save className="w-4 h-4" /> <Save className="w-4 h-4" />
Save Profile <Text>Save Profile</Text>
</span> </Stack>
)} )}
</Button> </Button>
</div> </Box>
</div> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Notification Preferences */} {/* Notification Preferences */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden"> <Card className="mb-6 overflow-hidden">
<SectionHeader <SectionHeader
icon={Bell} icon={Bell}
@@ -493,8 +519,8 @@ export default function SponsorSettingsPage() {
description="Control which emails you receive from GridPilot" description="Control which emails you receive from GridPilot"
color="text-warning-amber" color="text-warning-amber"
/> />
<div className="p-6"> <Box p={6}>
<div className="space-y-1"> <Box className="space-y-1">
<Toggle <Toggle
checked={notifications.emailNewSponsorships} checked={notifications.emailNewSponsorships}
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })} onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
@@ -531,13 +557,13 @@ export default function SponsorSettingsPage() {
label="Contract Expiry Reminders" label="Contract Expiry Reminders"
description="Receive reminders before your sponsorship contracts expire" description="Receive reminders before your sponsorship contracts expire"
/> />
</div> </Box>
</div> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Privacy & Visibility */} {/* Privacy & Visibility */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden"> <Card className="mb-6 overflow-hidden">
<SectionHeader <SectionHeader
icon={Eye} icon={Eye}
@@ -545,8 +571,8 @@ export default function SponsorSettingsPage() {
description="Control how your profile appears to others" description="Control how your profile appears to others"
color="text-performance-green" color="text-performance-green"
/> />
<div className="p-6"> <Box p={6}>
<div className="space-y-1"> <Box className="space-y-1">
<Toggle <Toggle
checked={privacy.publicProfile} checked={privacy.publicProfile}
onChange={(checked) => setPrivacy({ ...privacy, publicProfile: checked })} onChange={(checked) => setPrivacy({ ...privacy, publicProfile: checked })}
@@ -571,13 +597,13 @@ export default function SponsorSettingsPage() {
label="Allow Direct Contact" label="Allow Direct Contact"
description="Enable leagues and teams to send you sponsorship proposals" description="Enable leagues and teams to send you sponsorship proposals"
/> />
</div> </Box>
</div> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Security */} {/* Security */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden"> <Card className="mb-6 overflow-hidden">
<SectionHeader <SectionHeader
icon={Shield} icon={Shield}
@@ -585,80 +611,77 @@ export default function SponsorSettingsPage() {
description="Protect your sponsor account" description="Protect your sponsor account"
color="text-primary-blue" color="text-primary-blue"
/> />
<div className="p-6 space-y-4"> <Box p={6} className="space-y-4">
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50"> <Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="p-2 rounded-lg bg-iron-gray"> <Box p={2} rounded="lg" bg="bg-iron-gray">
<Key className="w-5 h-5 text-gray-400" /> <Key className="w-5 h-5 text-gray-400" />
</div> </Box>
<div> <Box>
<p className="text-gray-200 font-medium">Password</p> <Text color="text-gray-200" weight="medium" block>Password</Text>
<p className="text-sm text-gray-500">Last changed 3 months ago</p> <Text size="sm" color="text-gray-500" block>Last changed 3 months ago</Text>
</div> </Box>
</div> </Stack>
<Button variant="secondary"> <Button variant="secondary">
Change Password Change Password
</Button> </Button>
</div> </Box>
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50"> <Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="p-2 rounded-lg bg-iron-gray"> <Box p={2} rounded="lg" bg="bg-iron-gray">
<Smartphone className="w-5 h-5 text-gray-400" /> <Smartphone className="w-5 h-5 text-gray-400" />
</div> </Box>
<div> <Box>
<p className="text-gray-200 font-medium">Two-Factor Authentication</p> <Text color="text-gray-200" weight="medium" block>Two-Factor Authentication</Text>
<p className="text-sm text-gray-500">Add an extra layer of security to your account</p> <Text size="sm" color="text-gray-500" block>Add an extra layer of security to your account</Text>
</div> </Box>
</div> </Stack>
<Button variant="secondary"> <Button variant="secondary">
Enable 2FA Enable 2FA
</Button> </Button>
</div> </Box>
<div className="flex items-center justify-between py-3"> <Box display="flex" alignItems="center" justifyContent="between" py={3}>
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="p-2 rounded-lg bg-iron-gray"> <Box p={2} rounded="lg" bg="bg-iron-gray">
<Lock className="w-5 h-5 text-gray-400" /> <Lock className="w-5 h-5 text-gray-400" />
</div> </Box>
<div> <Box>
<p className="text-gray-200 font-medium">Active Sessions</p> <Text color="text-gray-200" weight="medium" block>Active Sessions</Text>
<p className="text-sm text-gray-500">Manage devices where you're logged in</p> <Text size="sm" color="text-gray-500" block>Manage devices where you&apos;re logged in</Text>
</div> </Box>
</div> </Stack>
<Button variant="secondary"> <Button variant="secondary">
View Sessions View Sessions
</Button> </Button>
</div> </Box>
</div> </Box>
</Card> </Card>
</motion.div> </Box>
{/* Danger Zone */} {/* Danger Zone */}
<motion.div variants={itemVariants}> <Box as={motion.div} variants={itemVariants}>
<Card className="border-racing-red/30 overflow-hidden"> <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"> <Box p={5} borderBottom borderColor="border-racing-red/30" bg="bg-gradient-to-r from-racing-red/10 to-transparent">
<h2 className="text-lg font-semibold text-racing-red flex items-center gap-3"> <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>}>
<div className="p-2 rounded-lg bg-racing-red/10">
<AlertCircle className="w-5 h-5 text-racing-red" />
</div>
Danger Zone Danger Zone
</h2> </Heading>
</div> </Box>
<div className="p-6"> <Box p={6}>
<div className="flex items-center justify-between"> <Box display="flex" alignItems="center" justifyContent="between">
<div className="flex items-center gap-4"> <Stack direction="row" align="center" gap={4}>
<div className="p-2 rounded-lg bg-racing-red/10"> <Box p={2} rounded="lg" bg="bg-racing-red/10">
<Trash2 className="w-5 h-5 text-racing-red" /> <Trash2 className="w-5 h-5 text-racing-red" />
</div> </Box>
<div> <Box>
<p className="text-gray-200 font-medium">Delete Sponsor Account</p> <Text color="text-gray-200" weight="medium" block>Delete Sponsor Account</Text>
<p className="text-sm text-gray-500"> <Text size="sm" color="text-gray-500" block>
Permanently delete your account and all associated sponsorship data. Permanently delete your account and all associated sponsorship data.
This action cannot be undone. This action cannot be undone.
</p> </Text>
</div> </Box>
</div> </Stack>
<Button <Button
variant="secondary" variant="secondary"
onClick={handleDeleteAccount} onClick={handleDeleteAccount}
@@ -666,10 +689,10 @@ export default function SponsorSettingsPage() {
> >
Delete Account Delete Account
</Button> </Button>
</div> </Box>
</div> </Box>
</Card> </Card>
</motion.div> </Box>
</motion.div> </Box>
); );
} }

View File

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

View File

@@ -1,24 +1,18 @@
import { notFound } from 'next/navigation'; import { notFound } from 'next/navigation';
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery'; import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery';
import { TeamDetailViewDataBuilder } from '@/lib/builders/view-data/TeamDetailViewDataBuilder';
import { TeamDetailPageClient } from './TeamDetailPageClient'; import { TeamDetailPageClient } from './TeamDetailPageClient';
export default async function Page({ params }: { params: { id: string } }) { export default async function Page({ params }: { params: { id: string } }) {
const result = await TeamDetailPageQuery.execute(params.id); const result = await TeamDetailPageQuery.execute(params.id);
switch (result.status) { if (result.isErr()) {
case 'ok': const error = result.getError();
const viewData = TeamDetailViewDataBuilder.build(result.dto); if (error === 'notFound') {
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
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 { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper'; import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================ // ============================================================================
// MAIN PAGE COMPONENT // MAIN PAGE COMPONENT
@@ -21,7 +22,7 @@ export default async function TeamLeaderboardPage() {
let error = null; let error = null;
if (result.isOk()) { if (result.isOk()) {
data = result.unwrap(); data = result.unwrap().map(t => new TeamSummaryViewModel(t));
} else { } else {
const domainError = result.getError(); const domainError = result.getError();
error = new Error(domainError.message); 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 { notFound } from 'next/navigation';
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery'; import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery';
import { TeamsPageClient } from './TeamsPageClient'; import { TeamsPageClient } from './TeamsPageClient';
export default async function Page() { export default async function Page() {

View File

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

View File

@@ -124,7 +124,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
// Trigger a test API error // Trigger a test API error
const testError = new Error('This is a test error for debugging'); 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(); const globalHandler = getGlobalErrorHandler();
globalHandler.report(testError, { test: true, timestamp: Date.now() }); globalHandler.report(testError, { test: true, timestamp: Date.now() });
@@ -138,7 +138,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
try { try {
// This will fail and be logged // This will fail and be logged
await fetch('https://httpstat.us/500'); await fetch('https://httpstat.us/500');
} catch (error) { } catch (_error) {
// Already logged by interceptor // Already logged by interceptor
console.log('%c[TEST] API call completed', 'color: #00aaff; font-weight: bold;'); console.log('%c[TEST] API call completed', 'color: #00aaff; font-weight: bold;');
} }
@@ -173,7 +173,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
}, },
errors: globalHandler.getStats(), errors: globalHandler.getStats(),
api: apiLogger.getStats(), api: apiLogger.getStats(),
reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__ || [], reactErrors: window.__GRIDPILOT_REACT_ERRORS__ || [],
}; };
try { try {
@@ -350,14 +350,14 @@ export function useDebugMode() {
globalHandler.initialize(); globalHandler.initialize();
const apiLogger = getGlobalApiLogger(); const apiLogger = getGlobalApiLogger();
if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) { if (!window.__GRIDPILOT_FETCH_LOGGED__) {
const loggedFetch = apiLogger.createLoggedFetch(); const loggedFetch = apiLogger.createLoggedFetch();
window.fetch = loggedFetch as any; window.fetch = loggedFetch as typeof fetch;
(window as any).__GRIDPILOT_FETCH_LOGGED__ = true; window.__GRIDPILOT_FETCH_LOGGED__ = true;
} }
(window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler; window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
(window as any).__GRIDPILOT_API_LOGGER__ = apiLogger; window.__GRIDPILOT_API_LOGGER__ = apiLogger;
}, []); }, []);
const disable = useCallback(() => { 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 { PlaceholderImage } from '@/ui/PlaceholderImage';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
@@ -35,7 +35,8 @@ export function DriverIdentity(props: DriverIdentityProps) {
alignItems="center" alignItems="center"
justifyContent="center" justifyContent="center"
flexShrink={0} flexShrink={0}
style={{ width: avatarSize, height: avatarSize }} w={`${avatarSize}px`}
h={`${avatarSize}px`}
> >
{avatarUrl ? ( {avatarUrl ? (
<Image <Image
@@ -54,17 +55,17 @@ export function DriverIdentity(props: DriverIdentityProps) {
<Box flexGrow={1} minWidth="0"> <Box flexGrow={1} minWidth="0">
<Box display="flex" alignItems="center" gap={2} 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} {driver.name}
</Text> </Text>
{contextLabel && ( {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} {contextLabel}
</Badge> </Badge>
)} )}
</Box> </Box>
{meta && ( {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} {meta}
</Text> </Text>
)} )}
@@ -74,7 +75,7 @@ export function DriverIdentity(props: DriverIdentityProps) {
if (href) { if (href) {
return ( 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} {content}
</Link> </Link>
); );

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,13 @@ export function useLogout(
const authService = useInject(AUTH_SERVICE_TOKEN); const authService = useInject(AUTH_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({ 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, ...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 { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError'; import { ApiError } from '@/lib/api/base/ApiError';
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
type DriverData = any; // Replace with actual type
export function useCurrentDriver( 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 driverService = useInject(DRIVER_SERVICE_TOKEN);
const queryResult = useQuery({ const queryResult = useQuery({
queryKey: ['currentDriver'], queryKey: ['currentDriver'],
queryFn: () => driverService.getCurrentDriver(), queryFn: async () => {
const result = await driverService.getCurrentDriver();
if (result.isErr()) {
throw result.getError();
}
return result.unwrap();
},
...options, ...options,
}); });

View File

@@ -3,7 +3,7 @@ import { useInject } from '@/lib/di/hooks/useInject';
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError'; 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( export function useDriverProfile(
driverId: string, driverId: string,
@@ -13,10 +13,17 @@ export function useDriverProfile(
const queryResult = useQuery({ const queryResult = useQuery({
queryKey: ['driverProfile', driverId], 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, enabled: !!driverId,
...options, ...options,
}); });
return enhanceQueryResult(queryResult); return enhanceQueryResult(queryResult);
} }

View File

@@ -13,10 +13,17 @@ export function useFindDriverById(
const queryResult = useQuery({ const queryResult = useQuery({
queryKey: ['driver', driverId], 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, enabled: !!driverId,
...options, ...options,
}); });
return enhanceQueryResult(queryResult); return enhanceQueryResult(queryResult);
} }

View File

@@ -1,14 +1,20 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
import { ApiError } from '@/lib/api/base/ApiError';
export function useLeagueMembershipMutation() { export function useLeagueMembershipMutation() {
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const joinLeagueMutation = useMutation({ const joinLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) => mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
leagueMembershipService.joinLeague(leagueId, driverId), 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] }); queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] }); queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
@@ -16,8 +22,13 @@ export function useLeagueMembershipMutation() {
}); });
const leaveLeagueMutation = useMutation({ const leaveLeagueMutation = useMutation({
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) => mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
leagueMembershipService.leaveLeague(leagueId, driverId), 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: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] }); queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
queryClient.invalidateQueries({ queryKey: ['allLeagues'] }); queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
@@ -28,4 +39,4 @@ export function useLeagueMembershipMutation() {
joinLeague: joinLeagueMutation, joinLeague: joinLeagueMutation,
leaveLeague: leaveLeagueMutation, leaveLeague: leaveLeagueMutation,
}; };
} }

View File

@@ -26,7 +26,13 @@ export function useLeagueRosterAdmin(leagueId: string, options?: UseQueryOptions
return useQuery<LeagueRosterMemberDTO[], ApiError>({ return useQuery<LeagueRosterMemberDTO[], ApiError>({
queryKey: ['league-roster-admin', leagueId], 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, ...options,
}); });
} }
@@ -36,7 +42,13 @@ export function useLeagueJoinRequests(leagueId: string, options?: UseQueryOption
return useQuery<LeagueRosterJoinRequestDTO[], ApiError>({ return useQuery<LeagueRosterJoinRequestDTO[], ApiError>({
queryKey: ['league-join-requests', leagueId], 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, ...options,
}); });
} }
@@ -48,7 +60,11 @@ export function useUpdateMemberRole(
return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({ return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({
mutationFn: async (input) => { 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, ...options,
}); });
@@ -61,7 +77,11 @@ export function useRemoveMember(
return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({ return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({
mutationFn: async (input) => { 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, ...options,
}); });
@@ -74,7 +94,11 @@ export function useApproveJoinRequest(
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({ return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => { 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, ...options,
}); });
@@ -87,8 +111,12 @@ export function useRejectJoinRequest(
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({ return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
mutationFn: async (input) => { 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, ...options,
}); });
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
export function useProtestDetail(leagueId: string, protestId: string, enabled: boolean = true) { export function useProtestDetail(leagueId: string, protestId: string, enabled: boolean = true) {
const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN); const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
const queryResult = useQuery({ const queryResult = useQuery({
queryKey: ['protestDetail', leagueId, protestId], 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, enabled: enabled && !!leagueId && !!protestId,
}); });
return enhanceQueryResult(queryResult); return enhanceQueryResult(queryResult);
} }

View File

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

View File

@@ -10,7 +10,13 @@ export function useFileProtest(
const raceService = useInject(RACE_SERVICE_TOKEN); const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, FileProtestCommandDTO>({ 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, ...options,
}); });
} }

View File

@@ -15,7 +15,13 @@ export function useRegisterForRace(
const raceService = useInject(RACE_SERVICE_TOKEN); const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, RegisterForRaceParams>({ 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, ...options,
}); });
} }

View File

@@ -14,7 +14,13 @@ export function useWithdrawFromRace(
const raceService = useInject(RACE_SERVICE_TOKEN); const raceService = useInject(RACE_SERVICE_TOKEN);
return useMutation<void, ApiError, WithdrawFromRaceParams>({ 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, ...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 () => { queryFn: async () => {
const result = await sponsorService.getBilling(sponsorId); const result = await sponsorService.getBilling(sponsorId);
if (result.isErr()) { if (result.isErr()) {
throw result.getError(); throw new Error(result.getError().message);
} }
return result.unwrap(); return result.unwrap();
}, },
@@ -19,4 +19,4 @@ export function useSponsorBilling(sponsorId: string) {
}); });
return enhanceQueryResult(queryResult); return enhanceQueryResult(queryResult);
} }

View File

@@ -1,11 +1,11 @@
import { useAuth } from '@/lib/auth/AuthContext'; import { useAuth } from '@/lib/auth/AuthContext';
import React from 'react'; import { useState, useEffect } from 'react';
export function useSponsorMode(): boolean { export function useSponsorMode(): boolean {
const { session } = useAuth(); const { session } = useAuth();
const [isSponsor, setIsSponsor] = React.useState(false); const [isSponsor, setIsSponsor] = useState(false);
React.useEffect(() => { useEffect(() => {
if (!session) { if (!session) {
setIsSponsor(false); setIsSponsor(false);
return; 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); const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({ 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, ...options,
}); });
} }

View File

@@ -1,6 +1,4 @@
import { useMutation, UseMutationOptions } from '@tanstack/react-query'; 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'; import { ApiError } from '@/lib/api/base/ApiError';
interface JoinTeamParams { interface JoinTeamParams {
@@ -10,8 +8,6 @@ interface JoinTeamParams {
} }
export function useJoinTeam(options?: Omit<UseMutationOptions<void, ApiError, JoinTeamParams>, 'mutationFn'>) { export function useJoinTeam(options?: Omit<UseMutationOptions<void, ApiError, JoinTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, JoinTeamParams>({ return useMutation<void, ApiError, JoinTeamParams>({
mutationFn: async (params) => { mutationFn: async (params) => {
// Note: Team join functionality would need to be added to teamService // 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 { 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'; import { ApiError } from '@/lib/api/base/ApiError';
interface LeaveTeamParams { interface LeaveTeamParams {
@@ -9,8 +7,6 @@ interface LeaveTeamParams {
} }
export function useLeaveTeam(options?: Omit<UseMutationOptions<void, ApiError, LeaveTeamParams>, 'mutationFn'>) { export function useLeaveTeam(options?: Omit<UseMutationOptions<void, ApiError, LeaveTeamParams>, 'mutationFn'>) {
const teamService = useInject(TEAM_SERVICE_TOKEN);
return useMutation<void, ApiError, LeaveTeamParams>({ return useMutation<void, ApiError, LeaveTeamParams>({
mutationFn: async (params) => { mutationFn: async (params) => {
// Note: Leave team functionality would need to be added to teamService // 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); const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
return useMutation<void, ApiError, void>({ 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, ...options,
}); });
} }

View File

@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
import { useInject } from '@/lib/di/hooks/useInject'; import { useInject } from '@/lib/di/hooks/useInject';
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens'; import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
import { ApiError } from '@/lib/api/base/ApiError';
export function useTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean) { export function useTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean) {
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN); const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
const queryResult = useQuery({ const queryResult = useQuery({
queryKey: ['teamJoinRequests', teamId, currentUserId, isOwner], 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, enabled: !!teamId && !!currentUserId,
}); });
return enhanceQueryResult(queryResult); return enhanceQueryResult(queryResult);
} }

View File

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

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