website refactor
This commit is contained in:
@@ -319,7 +319,14 @@
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "error",
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"@typescript-eslint/no-unused-vars": "error",
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"boundaries/element-types": [
|
||||
2,
|
||||
{
|
||||
|
||||
@@ -24,7 +24,7 @@ export default async function AdminLayout({ children }: AdminLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<Section variant="default" className="min-h-screen">
|
||||
<Section variant="default" minHeight="100vh">
|
||||
{children}
|
||||
</Section>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery';
|
||||
import { AdminDashboardWrapper } from '@/components/admin/AdminDashboardWrapper';
|
||||
import { AdminDashboardWrapper } from './AdminDashboardWrapper';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
export default async function AdminPage() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery';
|
||||
import { AdminUsersWrapper } from '@/components/admin/AdminUsersWrapper';
|
||||
import { AdminUsersWrapper } from './AdminUsersWrapper';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
export default async function AdminUsersPage() {
|
||||
|
||||
@@ -38,7 +38,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
...prev.formState,
|
||||
fields: {
|
||||
...prev.formState.fields,
|
||||
[name]: {
|
||||
[name as keyof typeof prev.formState.fields]: {
|
||||
...prev.formState.fields[name as keyof typeof prev.formState.fields],
|
||||
value,
|
||||
touched: true,
|
||||
@@ -71,7 +71,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
...validationErrors.reduce((acc, error) => ({
|
||||
...acc,
|
||||
[error.field]: {
|
||||
...prev.formState.fields[error.field],
|
||||
...prev.formState.fields[error.field as keyof typeof prev.formState.fields],
|
||||
error: error.message,
|
||||
touched: true,
|
||||
},
|
||||
@@ -147,15 +147,7 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
|
||||
return (
|
||||
<ResetPasswordTemplate
|
||||
// Spread the viewData properties
|
||||
token={templateViewData.token}
|
||||
returnTo={templateViewData.returnTo}
|
||||
showSuccess={templateViewData.showSuccess}
|
||||
successMessage={templateViewData.successMessage}
|
||||
formState={templateViewData.formState}
|
||||
isSubmitting={templateViewData.isSubmitting}
|
||||
submitError={templateViewData.submitError}
|
||||
// Add the additional props
|
||||
viewData={templateViewData}
|
||||
formActions={{
|
||||
handleChange,
|
||||
handleSubmit,
|
||||
@@ -178,4 +170,4 @@ export function ResetPasswordClient({ viewData }: ResetPasswordClientProps) {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { DashboardPageQuery } from '@/lib/page-queries/page-queries/DashboardPageQuery';
|
||||
import { DashboardPageQuery } from '@/lib/page-queries/DashboardPageQuery';
|
||||
import { DashboardTemplate } from '@/templates/DashboardTemplate';
|
||||
|
||||
export default async function DashboardPage() {
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||
import type { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel';
|
||||
import type { DriversViewData } from '@/lib/types/view-data/DriversViewData';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
interface DriversPageClientProps {
|
||||
pageDto: DriverLeaderboardViewModel | null;
|
||||
viewData: DriversViewData | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
@@ -17,34 +23,67 @@ interface DriversPageClientProps {
|
||||
* DriversPageClient
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Passes ViewModel directly to Template
|
||||
*
|
||||
* No business logic, filtering, or sorting here.
|
||||
* All data transformation happens in the PageQuery and ViewModelBuilder.
|
||||
* 1. Manages search state
|
||||
* 2. Filters drivers based on search
|
||||
* 3. Passes ViewData to Template
|
||||
*/
|
||||
export function DriversPageClient({ pageDto, error, empty }: DriversPageClientProps) {
|
||||
export function DriversPageClient({ viewData, error, empty }: DriversPageClientProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const filteredDrivers = useMemo(() => {
|
||||
if (!viewData) return [];
|
||||
if (!searchQuery) return viewData.drivers;
|
||||
|
||||
const query = searchQuery.toLowerCase();
|
||||
return viewData.drivers.filter(driver =>
|
||||
driver.name.toLowerCase().includes(query) ||
|
||||
driver.nationality.toLowerCase().includes(query)
|
||||
);
|
||||
}, [viewData, searchQuery]);
|
||||
|
||||
const handleDriverClick = (id: string) => {
|
||||
router.push(routes.driver.detail(id));
|
||||
};
|
||||
|
||||
const handleViewLeaderboard = () => {
|
||||
router.push(routes.leaderboards.drivers);
|
||||
};
|
||||
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
|
||||
<div className="text-red-400 mb-4">Error loading drivers</div>
|
||||
<p className="text-gray-400">Please try again later</p>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Text color="text-red-400">Error loading drivers</Text>
|
||||
<Text color="text-gray-400">Please try again later</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageDto || pageDto.drivers.length === 0) {
|
||||
if (!viewData || viewData.drivers.length === 0) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-12 text-center">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
|
||||
<p className="text-gray-400">{empty.description}</p>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={2}>
|
||||
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
|
||||
<Text color="text-gray-400">{empty.description}</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pass ViewModel directly to template
|
||||
return <DriversTemplate data={pageDto} />;
|
||||
return (
|
||||
<DriversTemplate
|
||||
viewData={viewData}
|
||||
searchQuery={searchQuery}
|
||||
onSearchChange={setSearchQuery}
|
||||
filteredDrivers={filteredDrivers}
|
||||
onDriverClick={handleDriverClick}
|
||||
onViewLeaderboard={handleViewLeaderboard}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||
import type { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData';
|
||||
import type { ProfileTab } from '@/ui/ProfileTabs';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
interface DriverProfilePageClientProps {
|
||||
pageDto: DriverProfileViewModel | null;
|
||||
viewData: DriverProfileViewData | null;
|
||||
error?: string;
|
||||
empty?: {
|
||||
title: string;
|
||||
@@ -19,16 +23,13 @@ interface DriverProfilePageClientProps {
|
||||
*
|
||||
* Client component that:
|
||||
* 1. Handles UI state (tabs, friend requests)
|
||||
* 2. Passes ViewModel directly to Template
|
||||
*
|
||||
* No business logic or data transformation here.
|
||||
* All data transformation happens in the PageQuery and ViewModelBuilder.
|
||||
* 2. Passes ViewData directly to Template
|
||||
*/
|
||||
export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfilePageClientProps) {
|
||||
export function DriverProfilePageClient({ viewData, error, empty }: DriverProfilePageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// UI State (UI-only concerns)
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
|
||||
const [activeTab, setActiveTab] = useState<ProfileTab>('overview');
|
||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||
|
||||
// Event handlers (UI-only concerns)
|
||||
@@ -43,46 +44,38 @@ export function DriverProfilePageClient({ pageDto, error, empty }: DriverProfile
|
||||
// Handle error/empty states
|
||||
if (error) {
|
||||
return (
|
||||
<div className="max-w-6xl mx-auto px-4 py-12 text-center">
|
||||
<div className="text-red-400 mb-4">Error loading driver profile</div>
|
||||
<p className="text-gray-400">Please try again later</p>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={4}>
|
||||
<Text color="text-red-400">Error loading driver profile</Text>
|
||||
<Text color="text-gray-400">Please try again later</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
if (!pageDto || !pageDto.currentDriver) {
|
||||
if (!viewData || !viewData.currentDriver) {
|
||||
if (empty) {
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto px-4">
|
||||
<div className="text-center py-12">
|
||||
<h2 className="text-xl font-semibold text-white mb-2">{empty.title}</h2>
|
||||
<p className="text-gray-400">{empty.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Container size="lg" py={12}>
|
||||
<Stack align="center" gap={2}>
|
||||
<Text size="xl" weight="semibold" color="text-white">{empty.title}</Text>
|
||||
<Text color="text-gray-400">{empty.description}</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Pass ViewModel directly to template
|
||||
// Pass ViewData directly to template
|
||||
return (
|
||||
<DriverProfileTemplate
|
||||
driverProfile={pageDto}
|
||||
allTeamMemberships={pageDto.teamMemberships.map(m => ({
|
||||
team: {
|
||||
id: m.teamId,
|
||||
name: m.teamName,
|
||||
},
|
||||
role: m.role,
|
||||
joinedAt: new Date(m.joinedAt),
|
||||
}))}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
viewData={viewData}
|
||||
onBackClick={handleBackClick}
|
||||
onAddFriend={handleAddFriend}
|
||||
friendRequestSent={friendRequestSent}
|
||||
activeTab={activeTab}
|
||||
setActiveTab={setActiveTab}
|
||||
onTabChange={setActiveTab}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { DriverProfilePageQuery } from '@/lib/page-queries/page-queries/DriverProfilePageQuery';
|
||||
import { DriverProfilePageClient } from '@/components/drivers/DriverProfilePageClient';
|
||||
import { DriverProfilePageQuery } from '@/lib/page-queries/DriverProfilePageQuery';
|
||||
import { DriverProfilePageClient } from './DriverProfilePageClient';
|
||||
|
||||
export default async function DriverProfilePage({ params }: { params: { id: string } }) {
|
||||
const result = await DriverProfilePageQuery.execute(params.id);
|
||||
@@ -13,7 +13,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
||||
}
|
||||
return (
|
||||
<DriverProfilePageClient
|
||||
pageDto={null}
|
||||
viewData={null}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
@@ -22,7 +22,7 @@ export default async function DriverProfilePage({ params }: { params: { id: stri
|
||||
const viewData = result.unwrap();
|
||||
return (
|
||||
<DriverProfilePageClient
|
||||
pageDto={viewData}
|
||||
viewData={viewData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { DriversPageQuery } from '@/lib/page-queries/page-queries/DriversPageQuery';
|
||||
import { DriversPageClient } from '@/components/drivers/DriversPageClient';
|
||||
import { DriversPageQuery } from '@/lib/page-queries/DriversPageQuery';
|
||||
import { DriversPageClient } from './DriversPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
const result = await DriversPageQuery.execute();
|
||||
@@ -13,7 +13,7 @@ export default async function Page() {
|
||||
}
|
||||
return (
|
||||
<DriversPageClient
|
||||
pageDto={null}
|
||||
viewData={null}
|
||||
error={error}
|
||||
/>
|
||||
);
|
||||
@@ -22,7 +22,7 @@ export default async function Page() {
|
||||
const viewData = result.unwrap();
|
||||
return (
|
||||
<DriversPageClient
|
||||
pageDto={viewData}
|
||||
viewData={viewData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,8 @@
|
||||
import React from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData';
|
||||
|
||||
interface LeaderboardsPageClientProps {
|
||||
viewData: LeaderboardsViewData;
|
||||
@@ -13,12 +13,12 @@ interface LeaderboardsPageClientProps {
|
||||
export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const handleDriverClick = (driverId: string) => {
|
||||
router.push(routes.driver.detail(driverId));
|
||||
const handleDriverClick = (id: string) => {
|
||||
router.push(routes.driver.detail(id));
|
||||
};
|
||||
|
||||
const handleTeamClick = (teamId: string) => {
|
||||
router.push(routes.team.detail(teamId));
|
||||
const handleTeamClick = (id: string) => {
|
||||
router.push(routes.team.detail(id));
|
||||
};
|
||||
|
||||
const handleNavigateToDrivers = () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { DriverRankingsPageQuery } from '@/lib/page-queries/page-queries/DriverRankingsPageQuery';
|
||||
import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery';
|
||||
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { LeaderboardsPageQuery } from '@/lib/page-queries/page-queries/LeaderboardsPageQuery';
|
||||
import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate';
|
||||
import { LeaderboardsPageQuery } from '@/lib/page-queries/LeaderboardsPageQuery';
|
||||
import { LeaderboardsPageClient } from './LeaderboardsPageClient';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function LeaderboardsPage() {
|
||||
@@ -23,5 +23,5 @@ export default async function LeaderboardsPage() {
|
||||
|
||||
// Success
|
||||
const viewData = result.unwrap();
|
||||
return <LeaderboardsTemplate viewData={viewData} />;
|
||||
return <LeaderboardsPageClient viewData={viewData} />;
|
||||
}
|
||||
@@ -1,37 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import {
|
||||
Trophy,
|
||||
Users,
|
||||
Globe,
|
||||
Award,
|
||||
Search,
|
||||
Plus,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Sparkles,
|
||||
Flag,
|
||||
Filter,
|
||||
Flame,
|
||||
Clock,
|
||||
Target,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { PageHero } from '@/ui/PageHero';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
|
||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { GridItem } from '@/ui/GridItem';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Icon as UIIcon } from '@/ui/Icon';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Link as UILink } from '@/ui/Link';
|
||||
import { PageHero } from '@/ui/PageHero';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import {
|
||||
Award,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Clock,
|
||||
Filter,
|
||||
Flag,
|
||||
Flame,
|
||||
Globe,
|
||||
Plus,
|
||||
Search,
|
||||
Sparkles,
|
||||
Target,
|
||||
Timer,
|
||||
Trophy,
|
||||
Users,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
|
||||
// ============================================================================
|
||||
// TYPES
|
||||
@@ -55,7 +60,7 @@ type CategoryId =
|
||||
interface Category {
|
||||
id: CategoryId;
|
||||
label: string;
|
||||
icon: React.ElementType;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
filter: (league: LeaguesViewData['leagues'][number]) => boolean;
|
||||
color?: string;
|
||||
@@ -63,7 +68,7 @@ interface Category {
|
||||
|
||||
interface LeagueSliderProps {
|
||||
title: string;
|
||||
icon: React.ElementType;
|
||||
icon: LucideIcon;
|
||||
description: string;
|
||||
leagues: LeaguesViewData['leagues'];
|
||||
autoScroll?: boolean;
|
||||
@@ -306,74 +311,71 @@ function LeagueSlider({
|
||||
if (leagues.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-10">
|
||||
<Box mb={10}>
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`flex h-10 w-10 items-center justify-center rounded-xl bg-iron-gray border border-charcoal-outline`}>
|
||||
<Icon className={`w-5 h-5 ${iconColor}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-white">{title}</h2>
|
||||
<p className="text-xs text-gray-500">{description}</p>
|
||||
</div>
|
||||
<span className="ml-2 px-2 py-0.5 rounded-full text-xs bg-charcoal-outline/50 text-gray-400">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline">
|
||||
<UIIcon icon={Icon} size={5} color={iconColor} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={2}>{title}</Heading>
|
||||
<Text size="xs" color="text-gray-500">{description}</Text>
|
||||
</Box>
|
||||
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400">
|
||||
{leagues.length}
|
||||
</span>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => scroll('left')}
|
||||
disabled={!canScrollLeft}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
||||
canScrollLeft
|
||||
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
||||
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
p={0}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
<UIIcon icon={ChevronLeft} size={4} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => scroll('right')}
|
||||
disabled={!canScrollRight}
|
||||
className={`flex h-8 w-8 items-center justify-center rounded-lg transition-all ${
|
||||
canScrollRight
|
||||
? 'bg-iron-gray border border-charcoal-outline text-white hover:border-primary-blue hover:text-primary-blue'
|
||||
: 'bg-iron-gray/30 border border-charcoal-outline/30 text-gray-600 cursor-not-allowed'
|
||||
}`}
|
||||
size="sm"
|
||||
w="2rem"
|
||||
h="2rem"
|
||||
p={0}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<UIIcon icon={ChevronRight} size={4} />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Scrollable container with fade edges */}
|
||||
<div className="relative">
|
||||
<Box position="relative">
|
||||
{/* Left fade gradient */}
|
||||
<div className="absolute left-0 top-0 bottom-4 w-12 bg-gradient-to-r from-deep-graphite to-transparent z-10 pointer-events-none" />
|
||||
<Box position="absolute" top={0} bottom={4} left={0} w="3rem" bg="bg-gradient-to-r from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
|
||||
{/* Right fade gradient */}
|
||||
<div className="absolute right-0 top-0 bottom-4 w-12 bg-gradient-to-l from-deep-graphite to-transparent z-10 pointer-events-none" />
|
||||
<Box position="absolute" top={0} bottom={4} right={0} w="3rem" bg="bg-gradient-to-l from-deep-graphite to-transparent" zIndex={10} pointerEvents="none" />
|
||||
|
||||
<div
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
onMouseEnter={() => setIsHovering(true)}
|
||||
onMouseLeave={() => setIsHovering(false)}
|
||||
className="flex gap-4 overflow-x-auto pb-4 px-4"
|
||||
style={{
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
display="flex"
|
||||
gap={4}
|
||||
overflow="auto"
|
||||
pb={4}
|
||||
px={4}
|
||||
hideScrollbar
|
||||
>
|
||||
<style jsx>{`
|
||||
div::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
`}</style>
|
||||
{leagues.map((league) => {
|
||||
// TODO wtf we have builders for this
|
||||
// Convert ViewData to ViewModel for LeagueCard
|
||||
const viewModel: LeagueSummaryViewModel = {
|
||||
id: league.id,
|
||||
@@ -396,16 +398,16 @@ function LeagueSlider({
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={league.id} className="flex-shrink-0 w-[320px] h-full">
|
||||
<a href={`/leagues/${league.id}`} className="block h-full">
|
||||
<Box key={league.id} flexShrink={0} w="320px" h="full">
|
||||
<UILink href={routes.league.detail(league.id)} block h="full">
|
||||
<LeagueCard league={viewModel} />
|
||||
</a>
|
||||
</div>
|
||||
</UILink>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -413,9 +415,10 @@ function LeagueSlider({
|
||||
// MAIN TEMPLATE COMPONENT
|
||||
// ============================================================================
|
||||
|
||||
export function LeaguesClient({
|
||||
export function LeaguesPageClient({
|
||||
viewData,
|
||||
}: LeaguesTemplateProps) {
|
||||
const router = useRouter();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
@@ -480,7 +483,7 @@ export function LeaguesClient({
|
||||
actions={[
|
||||
{
|
||||
label: 'Create League',
|
||||
onClick: () => { window.location.href = '/leagues/create'; },
|
||||
onClick: () => { router.push(routes.league.create); },
|
||||
icon: Plus,
|
||||
description: 'Set up your own racing series'
|
||||
}
|
||||
@@ -491,60 +494,57 @@ export function LeaguesClient({
|
||||
<Box mb={6}>
|
||||
<Stack direction="row" gap={4} wrap>
|
||||
{/* Search */}
|
||||
<Box display="flex" position="relative" style={{ flex: 1 }}>
|
||||
<Box position="absolute" style={{ left: '0.75rem', top: '50%', transform: 'translateY(-50%)', zIndex: 10 }}>
|
||||
<Search className="w-5 h-5 text-gray-500" />
|
||||
</Box>
|
||||
<Box display="flex" position="relative" flexGrow={1}>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search leagues by name, description, or game..."
|
||||
value={searchQuery}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
className="pl-11"
|
||||
icon={<UIIcon icon={Search} size={5} color="text-gray-500" />}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* Filter toggle (mobile) */}
|
||||
<Box display="none" className="lg:hidden">
|
||||
<Box display={{ base: 'block', lg: 'none' }}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setShowFilters(!showFilters)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Filter className="w-4 h-4" />
|
||||
Filters
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={Filter} size={4} />
|
||||
<Text>Filters</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<Box mt={4} display={showFilters ? 'block' : 'none'} className="lg:block">
|
||||
<Box mt={4} display={showFilters ? 'block' : { base: 'none', lg: 'block' }}>
|
||||
<Stack direction="row" gap={2} wrap>
|
||||
{CATEGORIES.map((category) => {
|
||||
const Icon = category.icon;
|
||||
const count = leaguesByCategory[category.id].length;
|
||||
const isActive = activeCategory === category.id;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Button
|
||||
key={category.id}
|
||||
type="button"
|
||||
variant={isActive ? 'primary' : 'secondary'}
|
||||
onClick={() => setActiveCategory(category.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-medium transition-all duration-200 ${
|
||||
isActive
|
||||
? 'bg-primary-blue text-white shadow-[0_0_15px_rgba(25,140,255,0.3)]'
|
||||
: 'bg-iron-gray/60 text-gray-400 border border-charcoal-outline hover:border-gray-500 hover:text-white'
|
||||
}`}
|
||||
size="sm"
|
||||
rounded="full"
|
||||
>
|
||||
<Icon className={`w-3.5 h-3.5 ${!isActive && category.color ? category.color : ''}`} />
|
||||
<span>{category.label}</span>
|
||||
{count > 0 && (
|
||||
<span className={`px-1.5 py-0.5 rounded-full text-[10px] ${isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}`}>
|
||||
{count}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Stack direction="row" align="center" gap={1.5}>
|
||||
<UIIcon icon={category.icon} size={3.5} color={!isActive && category.color ? category.color : undefined} />
|
||||
<Text size="xs" weight="medium">{category.label}</Text>
|
||||
{count > 0 && (
|
||||
<Box as="span" px={1.5} py={0.5} rounded="full" fontSize="10px" bg={isActive ? 'bg-white/20' : 'bg-charcoal-outline/50'}>
|
||||
{count}
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
@@ -554,24 +554,29 @@ export function LeaguesClient({
|
||||
{/* Content */}
|
||||
{viewData.leagues.length === 0 ? (
|
||||
/* Empty State */
|
||||
<Card className="text-center py-16">
|
||||
<Box maxWidth="28rem" mx="auto">
|
||||
<Box display="flex" center mb={6} rounded="2xl" p={4} className="bg-primary-blue/10 border border-primary-blue/20 mx-auto w-16 h-16">
|
||||
<Trophy className="w-8 h-8 text-primary-blue" />
|
||||
<Card>
|
||||
<Box py={16} textAlign="center">
|
||||
<Box maxWidth="28rem" mx="auto">
|
||||
<Box display="flex" center mb={6} rounded="2xl" p={4} bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" mx="auto" w="4rem" h="4rem">
|
||||
<UIIcon icon={Trophy} size={8} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Heading level={2}>
|
||||
No leagues yet
|
||||
</Heading>
|
||||
<Box mt={3} mb={8}>
|
||||
<Text color="text-gray-400">
|
||||
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
|
||||
</Text>
|
||||
</Box>
|
||||
<Button
|
||||
onClick={() => { router.push(routes.league.create); }}
|
||||
>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<UIIcon icon={Sparkles} size={4} />
|
||||
<Text>Create Your First League</Text>
|
||||
</Stack>
|
||||
</Button>
|
||||
</Box>
|
||||
<Heading level={2} className="text-2xl mb-3">
|
||||
No leagues yet
|
||||
</Heading>
|
||||
<Text color="text-gray-400" mb={8} block>
|
||||
Be the first to create a racing series. Start your own league and invite drivers to compete for glory.
|
||||
</Text>
|
||||
<Button
|
||||
onClick={() => { window.location.href = '/leagues/create'; }}
|
||||
className="inline-flex items-center gap-2 px-6 py-3 bg-primary-blue text-white rounded-lg hover:bg-blue-600 transition-colors"
|
||||
>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Create Your First League
|
||||
</Button>
|
||||
</Box>
|
||||
</Card>
|
||||
) : activeCategory === 'all' && !searchQuery ? (
|
||||
@@ -602,15 +607,15 @@ export function LeaguesClient({
|
||||
<Box>
|
||||
{categoryFilteredLeagues.length > 0 ? (
|
||||
<>
|
||||
<Box display="flex" align="center" justify="between" mb={6}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={6}>
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Showing <Text color="text-white" weight="medium">{categoryFilteredLeagues.length}</Text>{' '}
|
||||
{categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'}
|
||||
{searchQuery && (
|
||||
<span>
|
||||
<Box as="span">
|
||||
{' '}
|
||||
for "<Text color="text-primary-blue">{searchQuery}</Text>"
|
||||
</span>
|
||||
for "<Text color="text-primary-blue">{searchQuery}</Text>"
|
||||
</Box>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
@@ -639,31 +644,33 @@ export function LeaguesClient({
|
||||
|
||||
return (
|
||||
<GridItem key={league.id}>
|
||||
<a href={`/leagues/${league.id}`} className="block h-full">
|
||||
<UILink href={routes.league.detail(league.id)} block h="full">
|
||||
<LeagueCard league={viewModel} />
|
||||
</a>
|
||||
</UILink>
|
||||
</GridItem>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</>
|
||||
) : (
|
||||
<Card className="text-center py-12">
|
||||
<Stack align="center" gap={4}>
|
||||
<Search className="w-10 h-10 text-gray-600" />
|
||||
<Text color="text-gray-400">
|
||||
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setActiveCategory('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
<Card>
|
||||
<Box py={12} textAlign="center">
|
||||
<Stack align="center" gap={4}>
|
||||
<UIIcon icon={Search} size={10} color="text-gray-600" />
|
||||
<Text color="text-gray-400">
|
||||
No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'}
|
||||
</Text>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setActiveCategory('all');
|
||||
}}
|
||||
>
|
||||
Clear filters
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
|
||||
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
|
||||
import { ErrorBanner } from '@/ui/ErrorBanner';
|
||||
|
||||
@@ -22,8 +22,6 @@ export default async function Page({ params }: Props) {
|
||||
case 'redirect':
|
||||
// In a real app, this would redirect to login
|
||||
notFound();
|
||||
case 'LEAGUE_FETCH_FAILED':
|
||||
case 'UNKNOWN_ERROR':
|
||||
default:
|
||||
// Return error state
|
||||
return (
|
||||
@@ -49,5 +47,9 @@ export default async function Page({ params }: Props) {
|
||||
sponsors: [],
|
||||
});
|
||||
|
||||
return <LeagueDetailTemplate viewData={viewData} tabs={[]} children={null} />;
|
||||
}
|
||||
return (
|
||||
<LeagueDetailTemplate viewData={viewData} tabs={[]}>
|
||||
{null}
|
||||
</LeagueDetailTemplate>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
useRejectJoinRequest,
|
||||
useUpdateMemberRole,
|
||||
useRemoveMember,
|
||||
} from "@/lib/hooks/league/useLeagueRosterAdmin";
|
||||
} from "@/hooks/league/useLeagueRosterAdmin";
|
||||
import { RosterAdminTemplate } from '@/templates/RosterAdminTemplate';
|
||||
import type { JoinRequestData, RosterMemberData } from '@/lib/view-data/LeagueRosterAdminViewData';
|
||||
import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO';
|
||||
import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO';
|
||||
|
||||
const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member'];
|
||||
|
||||
@@ -71,10 +74,25 @@ export function RosterAdminPage() {
|
||||
await removeMemberMutation.mutateAsync({ leagueId, driverId });
|
||||
};
|
||||
|
||||
const viewData = useMemo(() => ({
|
||||
leagueId,
|
||||
joinRequests: joinRequests.map((req: LeagueRosterJoinRequestDTO): JoinRequestData => ({
|
||||
id: req.id,
|
||||
driver: req.driver as { id: string; name: string },
|
||||
requestedAt: req.requestedAt,
|
||||
message: req.message || undefined,
|
||||
})),
|
||||
members: members.map((m: LeagueRosterMemberDTO): RosterMemberData => ({
|
||||
driverId: m.driverId,
|
||||
driver: m.driver as { id: string; name: string },
|
||||
role: m.role,
|
||||
joinedAt: m.joinedAt,
|
||||
})),
|
||||
}), [leagueId, joinRequests, members]);
|
||||
|
||||
return (
|
||||
<RosterAdminTemplate
|
||||
joinRequests={joinRequests}
|
||||
members={members}
|
||||
viewData={viewData}
|
||||
loading={loading}
|
||||
pendingCountLabel={pendingCountLabel}
|
||||
onApprove={handleApprove}
|
||||
@@ -84,4 +102,4 @@ export function RosterAdminPage() {
|
||||
roleOptions={ROLE_OPTIONS}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LeagueRulebookPageQuery } from '@/lib/page-queries/page-queries/LeagueRulebookPageQuery';
|
||||
import { LeagueRulebookPageQuery } from '@/lib/page-queries/LeagueRulebookPageQuery';
|
||||
import { RulebookTemplate } from '@/templates/RulebookTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -17,18 +17,21 @@ export default async function Page({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <RulebookTemplate viewData={{
|
||||
leagueId,
|
||||
scoringConfig: {
|
||||
gameName: 'Unknown',
|
||||
scoringPresetName: 'Unknown',
|
||||
championships: [],
|
||||
dropPolicySummary: 'Unknown',
|
||||
},
|
||||
gameName: 'Unknown',
|
||||
scoringPresetName: 'Unknown',
|
||||
championshipsCount: 0,
|
||||
sessionTypes: 'None',
|
||||
dropPolicySummary: 'Unknown',
|
||||
hasActiveDropPolicy: false,
|
||||
positionPoints: [],
|
||||
bonusPoints: [],
|
||||
hasBonusPoints: false,
|
||||
}} />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,205 +1,25 @@
|
||||
'use client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueScheduleAdminPageQuery } from '@/lib/page-queries/LeagueScheduleAdminPageQuery';
|
||||
import { LeagueAdminSchedulePageClient } from './LeagueAdminSchedulePageClient';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { LeagueAdminScheduleTemplate } from '@/templates/LeagueAdminScheduleTemplate';
|
||||
import {
|
||||
useLeagueAdminStatus,
|
||||
useLeagueSeasons,
|
||||
useLeagueAdminSchedule
|
||||
} from "@/lib/hooks/league/useLeagueScheduleAdminPageData";
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
interface Props {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default function LeagueAdminSchedulePage() {
|
||||
const params = useParams();
|
||||
const leagueId = params.id as string;
|
||||
const currentDriverId = useEffectiveDriverId() || '';
|
||||
const queryClient = useQueryClient();
|
||||
export default async function Page({ params }: Props) {
|
||||
const { id } = await params;
|
||||
|
||||
const leagueService = useInject(LEAGUE_SERVICE_TOKEN);
|
||||
|
||||
// Form state
|
||||
const [seasonId, setSeasonId] = useState<string>('');
|
||||
const [track, setTrack] = useState('');
|
||||
const [car, setCar] = useState('');
|
||||
const [scheduledAtIso, setScheduledAtIso] = useState('');
|
||||
const [editingRaceId, setEditingRaceId] = useState<string | null>(null);
|
||||
|
||||
// Check admin status using domain hook
|
||||
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
|
||||
|
||||
// Load seasons using domain hook
|
||||
const { data: seasonsData, isLoading: seasonsLoading } = useLeagueSeasons(leagueId, !!isAdmin);
|
||||
|
||||
// Auto-select season
|
||||
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
|
||||
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
|
||||
: '');
|
||||
|
||||
// Load schedule using domain hook
|
||||
const { data: schedule, isLoading: scheduleLoading, refetch: refetchSchedule } = useLeagueAdminSchedule(leagueId, selectedSeasonId, !!isAdmin);
|
||||
|
||||
// Mutations
|
||||
const publishMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!schedule || !selectedSeasonId) return null;
|
||||
return schedule.published
|
||||
? await leagueService.unpublishAdminSchedule(leagueId, selectedSeasonId)
|
||||
: await leagueService.publishAdminSchedule(leagueId, selectedSeasonId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
},
|
||||
});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!selectedSeasonId || !scheduledAtIso) return null;
|
||||
|
||||
if (!editingRaceId) {
|
||||
return await leagueService.createAdminScheduleRace(leagueId, selectedSeasonId, {
|
||||
track,
|
||||
car,
|
||||
scheduledAtIso,
|
||||
});
|
||||
} else {
|
||||
return await leagueService.updateAdminScheduleRace(leagueId, selectedSeasonId, editingRaceId, {
|
||||
...(track ? { track } : {}),
|
||||
...(car ? { car } : {}),
|
||||
...(scheduledAtIso ? { scheduledAtIso } : {}),
|
||||
});
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
// Reset form
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
setEditingRaceId(null);
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (raceId: string) => {
|
||||
return await leagueService.deleteAdminScheduleRace(leagueId, selectedSeasonId, raceId);
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['adminSchedule', leagueId, selectedSeasonId] });
|
||||
},
|
||||
});
|
||||
|
||||
// Derived states
|
||||
const isLoading = isAdminLoading || seasonsLoading || scheduleLoading;
|
||||
const isPublishing = publishMutation.isPending;
|
||||
const isSaving = saveMutation.isPending;
|
||||
const isDeleting = deleteMutation.variables || null;
|
||||
|
||||
// Handlers
|
||||
const handleSeasonChange = (newSeasonId: string) => {
|
||||
setSeasonId(newSeasonId);
|
||||
setEditingRaceId(null);
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
};
|
||||
|
||||
const handlePublishToggle = () => {
|
||||
publishMutation.mutate();
|
||||
};
|
||||
|
||||
const handleAddOrSave = () => {
|
||||
if (!scheduledAtIso) return;
|
||||
saveMutation.mutate();
|
||||
};
|
||||
|
||||
const handleEdit = (raceId: string) => {
|
||||
if (!schedule) return;
|
||||
const race = schedule.races.find((r) => r.id === raceId);
|
||||
if (!race) return;
|
||||
|
||||
setEditingRaceId(raceId);
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso(race.scheduledAt.toISOString());
|
||||
};
|
||||
|
||||
const handleDelete = (raceId: string) => {
|
||||
const confirmed = window.confirm('Delete this race?');
|
||||
if (!confirmed) return;
|
||||
deleteMutation.mutate(raceId);
|
||||
};
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRaceId(null);
|
||||
setTrack('');
|
||||
setCar('');
|
||||
setScheduledAtIso('');
|
||||
};
|
||||
|
||||
// Prepare template data
|
||||
const templateData = schedule && seasonsData && selectedSeasonId
|
||||
? {
|
||||
schedule,
|
||||
seasons: seasonsData,
|
||||
seasonId: selectedSeasonId,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
// Render admin access required if not admin
|
||||
if (!isLoading && !isAdmin) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-deep-graphite border border-charcoal-outline rounded-lg p-6 text-center">
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">Only league admins can manage the schedule.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
// Execute the PageQuery
|
||||
const result = await LeagueScheduleAdminPageQuery.execute({ leagueId: id });
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For other errors, we still render the client component which handles its own loading/error states
|
||||
// or we could render an error banner here.
|
||||
}
|
||||
|
||||
// Template component that wraps the actual template with all props
|
||||
const TemplateWrapper = ({ data }: { data: typeof templateData }) => {
|
||||
if (!data) return null;
|
||||
|
||||
return (
|
||||
<LeagueAdminScheduleTemplate
|
||||
data={data}
|
||||
onSeasonChange={handleSeasonChange}
|
||||
onPublishToggle={handlePublishToggle}
|
||||
onAddOrSave={handleAddOrSave}
|
||||
onEdit={handleEdit}
|
||||
onDelete={handleDelete}
|
||||
onCancelEdit={handleCancelEdit}
|
||||
track={track}
|
||||
car={car}
|
||||
scheduledAtIso={scheduledAtIso}
|
||||
editingRaceId={editingRaceId}
|
||||
isPublishing={isPublishing}
|
||||
isSaving={isSaving}
|
||||
isDeleting={isDeleting}
|
||||
setTrack={setTrack}
|
||||
setCar={setCar}
|
||||
setScheduledAtIso={setScheduledAtIso}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
data={templateData}
|
||||
isLoading={isLoading}
|
||||
error={null}
|
||||
Template={TemplateWrapper}
|
||||
loading={{ variant: 'full-screen', message: 'Loading schedule admin...' }}
|
||||
empty={{
|
||||
title: 'No schedule data available',
|
||||
description: 'Unable to load schedule administration data',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <LeagueAdminSchedulePageClient />;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LeagueSchedulePageQuery } from '@/lib/page-queries/page-queries/LeagueSchedulePageQuery';
|
||||
import { LeagueSchedulePageQuery } from '@/lib/page-queries/LeagueSchedulePageQuery';
|
||||
import { LeagueScheduleTemplate } from '@/templates/LeagueScheduleTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function LeagueSchedulePage({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LeagueSettingsPageQuery } from '@/lib/page-queries/page-queries/LeagueSettingsPageQuery';
|
||||
import { LeagueSettingsPageQuery } from '@/lib/page-queries/LeagueSettingsPageQuery';
|
||||
import { LeagueSettingsTemplate } from '@/templates/LeagueSettingsTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function LeagueSettingsPage({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
@@ -29,8 +29,8 @@ export default async function LeagueSettingsPage({ params }: Props) {
|
||||
description: 'League information unavailable',
|
||||
visibility: 'private',
|
||||
ownerId: 'unknown',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
createdAt: '1970-01-01T00:00:00Z',
|
||||
updatedAt: '1970-01-01T00:00:00Z',
|
||||
},
|
||||
config: {
|
||||
maxDrivers: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/page-queries/LeagueSponsorshipsPageQuery';
|
||||
import { LeagueSponsorshipsPageQuery } from '@/lib/page-queries/LeagueSponsorshipsPageQuery';
|
||||
import { LeagueSponsorshipsTemplate } from '@/templates/LeagueSponsorshipsTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -17,12 +17,14 @@ export default async function LeagueSponsorshipsPage({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <LeagueSponsorshipsTemplate viewData={{
|
||||
leagueId,
|
||||
activeTab: 'overview',
|
||||
onTabChange: () => {},
|
||||
league: {
|
||||
id: leagueId,
|
||||
name: 'Unknown League',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { LeagueStandingsPageQuery } from '@/lib/page-queries/page-queries/LeagueStandingsPageQuery';
|
||||
import { LeagueStandingsPageQuery } from '@/lib/page-queries/LeagueStandingsPageQuery';
|
||||
import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
@@ -17,7 +17,7 @@ export default async function Page({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { LeagueStewardingPageQuery } from '@/lib/page-queries/page-queries/LeagueStewardingPageQuery';
|
||||
import { StewardingTemplate } from '@/templates/StewardingTemplate';
|
||||
import { LeagueStewardingPageQuery } from '@/lib/page-queries/LeagueStewardingPageQuery';
|
||||
import { StewardingPageClient } from './StewardingPageClient';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
@@ -17,19 +17,31 @@ export default async function LeagueStewardingPage({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <StewardingTemplate viewData={{
|
||||
leagueId,
|
||||
totalPending: 0,
|
||||
totalResolved: 0,
|
||||
totalPenalties: 0,
|
||||
races: [],
|
||||
drivers: []
|
||||
}} />;
|
||||
return <StewardingPageClient
|
||||
leagueId={leagueId}
|
||||
currentDriverId=""
|
||||
onRefetch={() => {}}
|
||||
data={{
|
||||
leagueId,
|
||||
totalPending: 0,
|
||||
totalResolved: 0,
|
||||
totalPenalties: 0,
|
||||
races: [],
|
||||
drivers: []
|
||||
}}
|
||||
/>;
|
||||
}
|
||||
|
||||
return <StewardingTemplate viewData={result.unwrap()} />;
|
||||
const data = result.unwrap();
|
||||
|
||||
return <StewardingPageClient
|
||||
data={data}
|
||||
leagueId={leagueId}
|
||||
currentDriverId="" // Should be fetched or passed
|
||||
onRefetch={() => {}} // Should be handled
|
||||
/>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,737 +1,25 @@
|
||||
'use client';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeagueProtestDetailPageQuery } from '@/lib/page-queries/LeagueProtestDetailPageQuery';
|
||||
import { ProtestDetailPageClient } from './ProtestDetailPageClient';
|
||||
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId";
|
||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel';
|
||||
import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel';
|
||||
import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel';
|
||||
import {
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
CheckCircle,
|
||||
ChevronDown,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Flag,
|
||||
Gavel,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
MessageCircle,
|
||||
Send,
|
||||
Shield,
|
||||
ShieldAlert,
|
||||
TrendingDown,
|
||||
User,
|
||||
Video,
|
||||
XCircle
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
// Shared state components
|
||||
import { StateContainer } from '@/components/shared/state/StateContainer';
|
||||
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
|
||||
import { useLeagueAdminStatus } from "@/lib/hooks/league/useLeagueAdminStatus";
|
||||
import { useProtestDetail } from "@/lib/hooks/league/useProtestDetail";
|
||||
|
||||
// Timeline event types
|
||||
interface TimelineEvent {
|
||||
id: string;
|
||||
type: 'protest_filed' | 'defense_requested' | 'defense_submitted' | 'steward_comment' | 'decision' | 'penalty_applied';
|
||||
timestamp: Date;
|
||||
actor: ProtestDriverViewModel | null;
|
||||
content: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
interface Props {
|
||||
params: Promise<{ id: string; protestId: string }>;
|
||||
}
|
||||
|
||||
type PenaltyUiConfig = {
|
||||
label: string;
|
||||
description: string;
|
||||
icon: typeof Gavel;
|
||||
color: string;
|
||||
defaultValue?: number;
|
||||
};
|
||||
|
||||
const PENALTY_UI: Record<string, PenaltyUiConfig> = {
|
||||
time_penalty: {
|
||||
label: 'Time Penalty',
|
||||
description: 'Add seconds to race result',
|
||||
icon: Clock,
|
||||
color: 'text-blue-400 bg-blue-500/10 border-blue-500/20',
|
||||
defaultValue: 5,
|
||||
},
|
||||
grid_penalty: {
|
||||
label: 'Grid Penalty',
|
||||
description: 'Grid positions for next race',
|
||||
icon: Grid3x3,
|
||||
color: 'text-purple-400 bg-purple-500/10 border-purple-500/20',
|
||||
defaultValue: 3,
|
||||
},
|
||||
points_deduction: {
|
||||
label: 'Points Deduction',
|
||||
description: 'Deduct championship points',
|
||||
icon: TrendingDown,
|
||||
color: 'text-red-400 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 5,
|
||||
},
|
||||
disqualification: {
|
||||
label: 'Disqualification',
|
||||
description: 'Disqualify from race',
|
||||
icon: XCircle,
|
||||
color: 'text-red-500 bg-red-500/10 border-red-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
warning: {
|
||||
label: 'Warning',
|
||||
description: 'Official warning only',
|
||||
icon: AlertTriangle,
|
||||
color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/20',
|
||||
defaultValue: 0,
|
||||
},
|
||||
license_points: {
|
||||
label: 'License Points',
|
||||
description: 'Safety rating penalty',
|
||||
icon: ShieldAlert,
|
||||
color: 'text-orange-400 bg-orange-500/10 border-orange-500/20',
|
||||
defaultValue: 2,
|
||||
},
|
||||
};
|
||||
|
||||
export default function ProtestReviewPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const leagueId = params.id as string;
|
||||
const protestId = params.protestId as string;
|
||||
const currentDriverId = useEffectiveDriverId();
|
||||
const protestService = useInject(PROTEST_SERVICE_TOKEN);
|
||||
|
||||
// Decision state
|
||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||
const [penaltyType, setPenaltyType] = useState<string>('time_penalty');
|
||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||
const [stewardNotes, setStewardNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
// Check admin status using hook
|
||||
const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || '');
|
||||
|
||||
// Load protest detail using hook
|
||||
const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false);
|
||||
|
||||
// Set initial penalty values when data loads
|
||||
useMemo(() => {
|
||||
if (detail?.initialPenaltyType) {
|
||||
setPenaltyType(detail.initialPenaltyType);
|
||||
setPenaltyValue(detail.initialPenaltyValue);
|
||||
export default async function Page({ params }: Props) {
|
||||
const { id, protestId } = await params;
|
||||
|
||||
// Execute the PageQuery
|
||||
const result = await LeagueProtestDetailPageQuery.execute({ leagueId: id, protestId });
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
}, [detail]);
|
||||
|
||||
const penaltyTypes = useMemo(() => {
|
||||
const referenceItems = detail?.penaltyTypes ?? [];
|
||||
return referenceItems.map((ref) => {
|
||||
const ui = PENALTY_UI[ref.type] ?? {
|
||||
icon: Gavel,
|
||||
color: 'text-gray-400 bg-gray-500/10 border-gray-500/20',
|
||||
};
|
||||
|
||||
return {
|
||||
...ref,
|
||||
icon: ui.icon,
|
||||
color: ui.color,
|
||||
};
|
||||
});
|
||||
}, [detail?.penaltyTypes]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
return penaltyTypes.find((p) => p.type === penaltyType);
|
||||
}, [penaltyTypes, penaltyType]);
|
||||
|
||||
const handleSubmitDecision = async () => {
|
||||
if (!decision || !stewardNotes.trim() || !detail || !currentDriverId) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const protest = detail.protest;
|
||||
|
||||
const defaultUpheldReason = detail.defaultReasons?.upheld;
|
||||
const defaultDismissedReason = detail.defaultReasons?.dismissed;
|
||||
|
||||
if (decision === 'uphold') {
|
||||
const requiresValue = selectedPenalty?.requiresValue ?? true;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType,
|
||||
penaltyValue,
|
||||
stewardNotes,
|
||||
});
|
||||
|
||||
const options: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { requiresValue };
|
||||
|
||||
if (defaultUpheldReason) {
|
||||
options.defaultUpheldReason = defaultUpheldReason;
|
||||
}
|
||||
if (defaultDismissedReason) {
|
||||
options.defaultDismissedReason = defaultDismissedReason;
|
||||
}
|
||||
|
||||
const penaltyCommand = commandModel.toApplyPenaltyCommand(
|
||||
protest.raceId,
|
||||
protest.accusedDriverId,
|
||||
currentDriverId,
|
||||
protest.id,
|
||||
options,
|
||||
);
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
} else {
|
||||
const warningRef = detail.penaltyTypes.find((p) => p.type === 'warning');
|
||||
const requiresValue = warningRef?.requiresValue ?? false;
|
||||
|
||||
const commandModel = new ProtestDecisionCommandModel({
|
||||
decision,
|
||||
penaltyType: 'warning',
|
||||
penaltyValue: 0,
|
||||
stewardNotes,
|
||||
});
|
||||
|
||||
const options: {
|
||||
requiresValue?: boolean;
|
||||
defaultUpheldReason?: string;
|
||||
defaultDismissedReason?: string;
|
||||
} = { requiresValue };
|
||||
|
||||
if (defaultUpheldReason) {
|
||||
options.defaultUpheldReason = defaultUpheldReason;
|
||||
}
|
||||
if (defaultDismissedReason) {
|
||||
options.defaultDismissedReason = defaultDismissedReason;
|
||||
}
|
||||
|
||||
const penaltyCommand = commandModel.toApplyPenaltyCommand(
|
||||
protest.raceId,
|
||||
protest.accusedDriverId,
|
||||
currentDriverId,
|
||||
protest.id,
|
||||
options,
|
||||
);
|
||||
|
||||
await protestService.applyPenalty(penaltyCommand);
|
||||
}
|
||||
|
||||
router.push(`/leagues/${leagueId}/stewarding`);
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to submit decision');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestDefense = async () => {
|
||||
if (!detail || !currentDriverId) return;
|
||||
|
||||
try {
|
||||
// Request defense
|
||||
await protestService.requestDefense({
|
||||
protestId: detail.protest.id,
|
||||
stewardId: currentDriverId,
|
||||
});
|
||||
|
||||
// Reload page to show updated status
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
alert(err instanceof Error ? err.message : 'Failed to request defense');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return { label: 'Pending Review', color: 'bg-warning-amber/20 text-warning-amber border-warning-amber/30', icon: Clock };
|
||||
case 'under_review':
|
||||
return { label: 'Under Review', color: 'bg-blue-500/20 text-blue-400 border-blue-500/30', icon: Shield };
|
||||
case 'awaiting_defense':
|
||||
return { label: 'Awaiting Defense', color: 'bg-purple-500/20 text-purple-400 border-purple-500/30', icon: MessageCircle };
|
||||
case 'upheld':
|
||||
return { label: 'Upheld', color: 'bg-red-500/20 text-red-400 border-red-500/30', icon: CheckCircle };
|
||||
case 'dismissed':
|
||||
return { label: 'Dismissed', color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: XCircle };
|
||||
default:
|
||||
return { label: status, color: 'bg-gray-500/20 text-gray-400 border-gray-500/30', icon: AlertCircle };
|
||||
}
|
||||
};
|
||||
|
||||
// Show loading for admin check
|
||||
if (adminLoading) {
|
||||
return <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
|
||||
}
|
||||
|
||||
// Show access denied if not admin
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<Card>
|
||||
<div className="text-center py-12">
|
||||
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-iron-gray/50 flex items-center justify-center">
|
||||
<AlertTriangle className="w-8 h-8 text-warning-amber" />
|
||||
</div>
|
||||
<h3 className="text-lg font-medium text-white mb-2">Admin Access Required</h3>
|
||||
<p className="text-sm text-gray-400">
|
||||
Only league admins can review protests.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
const viewData = result.isOk() ? result.unwrap() : null;
|
||||
|
||||
return (
|
||||
<StateContainer
|
||||
data={detail}
|
||||
isLoading={detailLoading}
|
||||
error={error}
|
||||
retry={retry}
|
||||
config={{
|
||||
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||
error: { variant: 'full-screen' },
|
||||
}}
|
||||
>
|
||||
{(protestDetail) => {
|
||||
if (!protestDetail) return null;
|
||||
|
||||
const protest = protestDetail.protest;
|
||||
const race = protestDetail.race;
|
||||
const protestingDriver = protestDetail.protestingDriver;
|
||||
const accusedDriver = protestDetail.accusedDriver;
|
||||
|
||||
const statusConfig = getStatusConfig(protest.status);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const isPending = protest.status === 'pending';
|
||||
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{/* Compact Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<div className="flex-1 flex items-center gap-3">
|
||||
<h1 className="text-xl font-bold text-white">Protest Review</h1>
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
{daysSinceFiled > 2 && isPending && (
|
||||
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
||||
<AlertTriangle className="w-3 h-3" />
|
||||
{daysSinceFiled}d old
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Layout: Feed + Sidebar */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||
{/* Left Sidebar - Incident Info */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{/* Drivers Involved */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Protesting Driver */}
|
||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Accused Driver */}
|
||||
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
|
||||
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
|
||||
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-orange-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-orange-400 font-medium">Accused</p>
|
||||
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
|
||||
</div>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Race Info */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
|
||||
|
||||
<Link
|
||||
href={`/races/${race.id}`}
|
||||
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-white">{race.name}</span>
|
||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Calendar className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">{race.formattedDate}</span>
|
||||
</div>
|
||||
{protest.incident?.lap && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Flag className="w-4 h-4 text-gray-500" />
|
||||
<span className="text-gray-300">Lap {protest.incident.lap}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{protest.proofVideoUrl && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
|
||||
<a
|
||||
href={protest.proofVideoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
|
||||
>
|
||||
<Video className="w-4 h-4" />
|
||||
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Filed</span>
|
||||
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Age</span>
|
||||
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
|
||||
</div>
|
||||
{protest.reviewedAt && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">Resolved</span>
|
||||
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Center - Discussion Feed */}
|
||||
<div className="lg:col-span-6 space-y-4">
|
||||
{/* Timeline / Feed */}
|
||||
<Card className="p-0 overflow-hidden">
|
||||
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
|
||||
<h2 className="text-sm font-semibold text-white">Discussion</h2>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-charcoal-outline/50">
|
||||
{/* Initial Protest Filing */}
|
||||
<div className="p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
|
||||
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
|
||||
|
||||
{protest.comment && (
|
||||
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Defense placeholder - will be populated when defense system is implemented */}
|
||||
{protest.status === 'awaiting_defense' && (
|
||||
<div className="p-4 bg-purple-500/5">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
|
||||
<MessageCircle className="w-5 h-5 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
|
||||
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Decision (if resolved) */}
|
||||
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
|
||||
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
|
||||
<div className="flex gap-3">
|
||||
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
|
||||
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
|
||||
}`}>
|
||||
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-white text-sm">Steward Decision</span>
|
||||
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
|
||||
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
|
||||
</span>
|
||||
{protest.reviewedAt && (
|
||||
<>
|
||||
<span className="text-xs text-gray-500">•</span>
|
||||
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={`rounded-lg p-4 border ${
|
||||
protest.status === 'upheld'
|
||||
? 'bg-red-500/10 border-red-500/20'
|
||||
: 'bg-gray-500/10 border-gray-500/20'
|
||||
}`}>
|
||||
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Comment (future feature) */}
|
||||
{isPending && (
|
||||
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
|
||||
<div className="flex gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
|
||||
<User className="w-5 h-5 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<textarea
|
||||
value={newComment}
|
||||
onChange={(e) => setNewComment(e.target.value)}
|
||||
placeholder="Add a comment or request more information..."
|
||||
rows={2}
|
||||
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
|
||||
/>
|
||||
<div className="flex justify-end mt-2">
|
||||
<Button variant="secondary" disabled={!newComment.trim()}>
|
||||
<Send className="w-3 h-3 mr-1" />
|
||||
Comment
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Right Sidebar - Actions */}
|
||||
<div className="lg:col-span-3 space-y-4">
|
||||
{isPending && (
|
||||
<>
|
||||
{/* Quick Actions */}
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full justify-start"
|
||||
onClick={handleRequestDefense}
|
||||
>
|
||||
<MessageCircle className="w-4 h-4 mr-2" />
|
||||
Request Defense
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full justify-start"
|
||||
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
|
||||
>
|
||||
<Gavel className="w-4 h-4 mr-2" />
|
||||
Make Decision
|
||||
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Decision Panel */}
|
||||
{showDecisionPanel && (
|
||||
<Card className="p-4">
|
||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
|
||||
|
||||
{/* Decision Selection */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
||||
<button
|
||||
onClick={() => setDecision('uphold')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'uphold'
|
||||
? 'border-red-500 bg-red-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision('dismiss')}
|
||||
className={`p-3 rounded-lg border-2 transition-all ${
|
||||
decision === 'dismiss'
|
||||
? 'border-gray-500 bg-gray-500/10'
|
||||
: 'border-charcoal-outline hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Penalty Selection (if upholding) */}
|
||||
{decision === 'uphold' && (
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
||||
|
||||
{penaltyTypes.length === 0 ? (
|
||||
<div className="text-xs text-gray-500">
|
||||
Loading penalty types...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-2 gap-1.5">
|
||||
{penaltyTypes.map((penalty) => {
|
||||
const Icon = penalty.icon;
|
||||
const isSelected = penaltyType === penalty.type;
|
||||
return (
|
||||
<button
|
||||
key={penalty.type}
|
||||
onClick={() => {
|
||||
setPenaltyType(penalty.type);
|
||||
setPenaltyValue(penalty.defaultValue);
|
||||
}}
|
||||
className={`p-2 rounded-lg border transition-all text-left ${
|
||||
isSelected
|
||||
? `${penalty.color} border`
|
||||
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
|
||||
}`}
|
||||
title={penalty.description}
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
|
||||
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
|
||||
{penalty.label}
|
||||
</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{selectedPenalty?.requiresValue && (
|
||||
<div className="mt-3">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
||||
Value ({selectedPenalty.valueLabel})
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={penaltyValue}
|
||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
||||
min="1"
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Steward Notes */}
|
||||
<div className="mb-4">
|
||||
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
|
||||
<textarea
|
||||
value={stewardNotes}
|
||||
onChange={(e) => setStewardNotes(e.target.value)}
|
||||
placeholder="Explain your decision..."
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Submit */}
|
||||
<Button
|
||||
variant="primary"
|
||||
className="w-full"
|
||||
onClick={handleSubmitDecision}
|
||||
disabled={!decision || !stewardNotes.trim() || submitting}
|
||||
>
|
||||
{submitting ? 'Submitting...' : 'Submit Decision'}
|
||||
</Button>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Already Resolved Info */}
|
||||
{!isPending && (
|
||||
<Card className="p-4">
|
||||
<div className={`text-center py-4 ${
|
||||
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
|
||||
}`}>
|
||||
<Gavel className="w-8 h-8 mx-auto mb-2" />
|
||||
<p className="font-semibold">Case Closed</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</StateContainer>
|
||||
);
|
||||
return <ProtestDetailPageClient initialViewData={viewData} />;
|
||||
}
|
||||
|
||||
374
apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx
Normal file
374
apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { LeagueWalletPageQuery } from '@/lib/page-queries/page-queries/LeagueWalletPageQuery';
|
||||
import { LeagueWalletTemplate } from '@/templates/LeagueWalletTemplate';
|
||||
import { LeagueWalletPageQuery } from '@/lib/page-queries/LeagueWalletPageQuery';
|
||||
import { LeagueWalletPageClient } from './LeagueWalletPageClient';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface Props {
|
||||
params: { id: string };
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function LeagueWalletPage({ params }: Props) {
|
||||
const leagueId = params.id;
|
||||
const { id: leagueId } = await params;
|
||||
|
||||
if (!leagueId) {
|
||||
notFound();
|
||||
@@ -17,17 +17,24 @@ export default async function LeagueWalletPage({ params }: Props) {
|
||||
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error.type === 'notFound') {
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For serverError, show the template with empty data
|
||||
return <LeagueWalletTemplate viewData={{
|
||||
return <LeagueWalletPageClient viewData={{
|
||||
leagueId,
|
||||
balance: 0,
|
||||
formattedBalance: '$0.00',
|
||||
totalRevenue: 0,
|
||||
formattedTotalRevenue: '$0.00',
|
||||
totalFees: 0,
|
||||
formattedTotalFees: '$0.00',
|
||||
pendingPayouts: 0,
|
||||
formattedPendingPayouts: '$0.00',
|
||||
currency: 'USD',
|
||||
transactions: [],
|
||||
}} />;
|
||||
}
|
||||
|
||||
return <LeagueWalletTemplate viewData={result.unwrap()} />;
|
||||
return <LeagueWalletPageClient viewData={result.unwrap()} />;
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import {
|
||||
AlertCircle,
|
||||
@@ -22,22 +26,22 @@ import {
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { FormEvent, useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { LeagueWizardCommandModel } from '@/lib/command-models/leagues/LeagueWizardCommandModel';
|
||||
|
||||
import { useCreateLeagueWizard } from "@/lib/hooks/useLeagueWizardService";
|
||||
import { useLeagueScoringPresets } from "@/lib/hooks/useLeagueScoringPresets";
|
||||
import { LeagueBasicsSection } from './LeagueBasicsSection';
|
||||
import { LeagueDropSection } from './LeagueDropSection';
|
||||
import { useCreateLeagueWizard } from "@/hooks/useLeagueWizardService";
|
||||
import { useLeagueScoringPresets } from "@/hooks/useLeagueScoringPresets";
|
||||
import { LeagueBasicsSection } from '@/components/leagues/LeagueBasicsSection';
|
||||
import { LeagueDropSection } from '@/components/leagues/LeagueDropSection';
|
||||
import {
|
||||
ChampionshipsSection,
|
||||
ScoringPatternSection
|
||||
} from './LeagueScoringSection';
|
||||
import { LeagueStewardingSection } from './LeagueStewardingSection';
|
||||
import { LeagueStructureSection } from './LeagueStructureSection';
|
||||
import { LeagueTimingsSection } from './LeagueTimingsSection';
|
||||
import { LeagueVisibilitySection } from './LeagueVisibilitySection';
|
||||
} from '@/components/leagues/LeagueScoringSection';
|
||||
import { LeagueStewardingSection } from '@/components/leagues/LeagueStewardingSection';
|
||||
import { LeagueStructureSection } from '@/components/leagues/LeagueStructureSection';
|
||||
import { LeagueTimingsSection } from '@/components/leagues/LeagueTimingsSection';
|
||||
import { LeagueVisibilitySection } from '@/components/leagues/LeagueVisibilitySection';
|
||||
import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel';
|
||||
import type { LeagueScoringPresetViewModel } from '@/lib/view-models/LeagueScoringPresetViewModel';
|
||||
import type { Weekday } from '@/lib/types/Weekday';
|
||||
@@ -237,7 +241,7 @@ function createDefaultForm(): LeagueWizardFormModel {
|
||||
};
|
||||
}
|
||||
|
||||
export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
|
||||
export function CreateLeagueWizard({ stepName, onStepChange }: CreateLeagueWizardProps) {
|
||||
const router = useRouter();
|
||||
const { session } = useAuth();
|
||||
|
||||
@@ -285,7 +289,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
// Sync presets from query to local state
|
||||
useEffect(() => {
|
||||
if (queryPresets) {
|
||||
setPresets(queryPresets);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
setPresets(queryPresets as any);
|
||||
const firstPreset = queryPresets[0];
|
||||
if (firstPreset && !form.scoring?.patternId) {
|
||||
setForm((prev) => ({
|
||||
@@ -316,7 +321,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
const validateStep = (currentStep: Step): boolean => {
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formData: any = {
|
||||
leagueId: form.leagueId || '',
|
||||
basics: {
|
||||
name: form.basics?.name || '',
|
||||
@@ -365,7 +371,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
},
|
||||
};
|
||||
|
||||
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const stepErrors = LeagueWizardCommandModel.validateLeagueWizardStep(formData, currentStep as any);
|
||||
setErrors((prev) => ({
|
||||
...prev,
|
||||
...stepErrors,
|
||||
@@ -409,7 +416,8 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
}
|
||||
|
||||
// Convert form to LeagueWizardFormData for validation
|
||||
const formData: LeagueWizardCommandModel.LeagueWizardFormData = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const formData: any = {
|
||||
leagueId: form.leagueId || '',
|
||||
basics: {
|
||||
name: form.basics?.name || '',
|
||||
@@ -471,7 +479,7 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
|
||||
setLoading(true);
|
||||
setErrors((prev) => {
|
||||
const { submit, ...rest } = prev;
|
||||
const { submit: _, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
|
||||
@@ -587,39 +595,45 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
const CurrentStepIcon = currentStepData?.icon ?? FileText;
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-4xl mx-auto pb-8">
|
||||
<Box as="form" onSubmit={handleSubmit} maxWidth="4xl" mx="auto" pb={8}>
|
||||
{/* Header with icon */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-3 mb-3">
|
||||
<div className="flex h-11 w-11 items-center justify-center rounded-xl bg-gradient-to-br from-primary-blue/20 to-primary-blue/5 border border-primary-blue/20">
|
||||
<Sparkles className="w-5 h-5 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<Heading level={1} className="text-2xl sm:text-3xl">
|
||||
<Box mb={8}>
|
||||
<Stack direction="row" align="center" gap={3} mb={3}>
|
||||
<Box display="flex" h="11" w="11" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/20" border borderColor="border-primary-blue/20">
|
||||
<Icon icon={Sparkles} size={5} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1} fontSize={{ base: '2xl', sm: '3xl' }}>
|
||||
Create a new league
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-500">
|
||||
We'll also set up your first season in {steps.length} easy steps.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<Text size="sm" color="text-gray-500" block>
|
||||
We'll also set up your first season in {steps.length} easy steps.
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
A league is your long-term brand. Each season is a block of races you can run again and again.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Desktop Progress Bar */}
|
||||
<div className="hidden md:block mb-8">
|
||||
<div className="relative">
|
||||
<Box display={{ base: 'none', md: 'block' }} mb={8}>
|
||||
<Box position="relative">
|
||||
{/* Background track */}
|
||||
<div className="absolute top-5 left-6 right-6 h-0.5 bg-charcoal-outline rounded-full" />
|
||||
<Box position="absolute" top="5" left="6" right="6" h="0.5" bg="bg-charcoal-outline" rounded="full" />
|
||||
{/* Progress fill */}
|
||||
<div
|
||||
className="absolute top-5 left-6 h-0.5 bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)` }}
|
||||
<Box
|
||||
position="absolute"
|
||||
top="5"
|
||||
left="6"
|
||||
h="0.5"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
width={`calc(${((step - 1) / (steps.length - 1)) * 100}% - 48px)`}
|
||||
/>
|
||||
|
||||
<div className="relative flex justify-between">
|
||||
<Box position="relative" display="flex" justifyContent="between">
|
||||
{steps.map((wizardStep) => {
|
||||
const isCompleted = wizardStep.id < step;
|
||||
const isCurrent = wizardStep.id === step;
|
||||
@@ -627,148 +641,153 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
const StepIcon = wizardStep.icon;
|
||||
|
||||
return (
|
||||
<button
|
||||
<Box
|
||||
as="button"
|
||||
key={wizardStep.id}
|
||||
type="button"
|
||||
onClick={() => goToStep(wizardStep.id)}
|
||||
disabled={!isAccessible}
|
||||
className="flex flex-col items-center bg-transparent border-0 cursor-pointer disabled:cursor-not-allowed"
|
||||
display="flex"
|
||||
flexDirection="col"
|
||||
alignItems="center"
|
||||
bg="bg-transparent"
|
||||
borderStyle="none"
|
||||
cursor={isAccessible ? 'pointer' : 'not-allowed'}
|
||||
opacity={!isAccessible ? 0.6 : 1}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
relative z-10 flex h-10 w-10 items-center justify-center rounded-full
|
||||
transition-all duration-300 ease-out
|
||||
${isCurrent
|
||||
? 'bg-primary-blue text-white shadow-[0_0_24px_rgba(25,140,255,0.5)] scale-110'
|
||||
: isCompleted
|
||||
? 'bg-primary-blue text-white hover:scale-105'
|
||||
: isAccessible
|
||||
? 'bg-iron-gray text-gray-400 border-2 border-charcoal-outline hover:border-primary-blue/50'
|
||||
: 'bg-iron-gray text-gray-500 border-2 border-charcoal-outline opacity-60'
|
||||
}
|
||||
`}
|
||||
<Box
|
||||
position="relative"
|
||||
zIndex={10}
|
||||
display="flex"
|
||||
h="10"
|
||||
w="10"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
rounded="full"
|
||||
transition
|
||||
bg={isCurrent || isCompleted ? 'bg-primary-blue' : 'bg-iron-gray'}
|
||||
color={isCurrent || isCompleted ? 'text-white' : 'text-gray-400'}
|
||||
border={!isCurrent && !isCompleted}
|
||||
borderColor="border-charcoal-outline"
|
||||
shadow={isCurrent ? '0_0_24px_rgba(25,140,255,0.5)' : undefined}
|
||||
transform={isCurrent ? 'scale-110' : isCompleted ? 'hover:scale-105' : undefined}
|
||||
>
|
||||
{isCompleted ? (
|
||||
<Check className="w-4 h-4" strokeWidth={3} />
|
||||
<Icon icon={Check} size={4} strokeWidth={3} />
|
||||
) : (
|
||||
<StepIcon className="w-4 h-4" />
|
||||
<Icon icon={StepIcon} size={4} />
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 text-center">
|
||||
<p
|
||||
className={`text-xs font-medium transition-colors duration-200 ${
|
||||
isCurrent
|
||||
? 'text-white'
|
||||
: isCompleted
|
||||
? 'text-primary-blue'
|
||||
: isAccessible
|
||||
? 'text-gray-400'
|
||||
: 'text-gray-500'
|
||||
}`}
|
||||
</Box>
|
||||
<Box mt={2} textAlign="center">
|
||||
<Text
|
||||
size="xs"
|
||||
weight="medium"
|
||||
transition
|
||||
color={isCurrent ? 'text-white' : isCompleted ? 'text-primary-blue' : isAccessible ? 'text-gray-400' : 'text-gray-500'}
|
||||
>
|
||||
{wizardStep.label}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Mobile Progress */}
|
||||
<div className="md:hidden mb-6">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<CurrentStepIcon className="w-4 h-4 text-primary-blue" />
|
||||
<span className="text-sm font-medium text-white">{currentStepData?.label}</span>
|
||||
</div>
|
||||
<span className="text-xs text-gray-500">
|
||||
<Box display={{ base: 'block', md: 'none' }} mb={6}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Icon icon={CurrentStepIcon} size={4} color="text-primary-blue" />
|
||||
<Text size="sm" weight="medium" color="text-white">{currentStepData?.label}</Text>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500">
|
||||
{step}/{steps.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 bg-charcoal-outline rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-primary-blue to-neon-aqua rounded-full transition-all duration-500 ease-out"
|
||||
style={{ width: `${(step / steps.length) * 100}%` }}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box h="1.5" bg="bg-charcoal-outline" rounded="full" overflow="hidden">
|
||||
<Box
|
||||
h="full"
|
||||
bg="bg-gradient-to-r from-primary-blue to-neon-aqua"
|
||||
rounded="full"
|
||||
transition
|
||||
height="full"
|
||||
width={`${(step / steps.length) * 100}%`}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
{/* Step dots */}
|
||||
<div className="flex justify-between mt-2 px-0.5">
|
||||
<Box display="flex" justifyContent="between" mt={2} px={0.5}>
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
<Box
|
||||
key={s.id}
|
||||
className={`
|
||||
h-1.5 rounded-full transition-all duration-300
|
||||
${s.id === step
|
||||
? 'w-4 bg-primary-blue'
|
||||
: s.id < step
|
||||
? 'w-1.5 bg-primary-blue/60'
|
||||
: 'w-1.5 bg-charcoal-outline'
|
||||
}
|
||||
`}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '4' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/60' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Main Card */}
|
||||
<Card className="relative overflow-hidden">
|
||||
<Card position="relative" overflow="hidden">
|
||||
{/* Top gradient accent */}
|
||||
<div className="absolute top-0 left-0 right-0 h-1 bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
|
||||
<Box position="absolute" top="0" left="0" right="0" h="1" bg="bg-gradient-to-r from-transparent via-primary-blue to-transparent" />
|
||||
|
||||
{/* Step header */}
|
||||
<div className="flex items-start gap-4 mb-6">
|
||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10 shrink-0 transition-transform duration-300">
|
||||
<CurrentStepIcon className="w-6 h-6 text-primary-blue" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Heading level={2} className="text-xl sm:text-2xl text-white leading-tight">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span>{getStepTitle(step)}</span>
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full border border-charcoal-outline bg-iron-gray/60 text-[11px] font-medium text-gray-300">
|
||||
<Box display="flex" alignItems="start" gap={4} mb={6}>
|
||||
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" flexShrink={0} transition>
|
||||
<Icon icon={CurrentStepIcon} size={6} color="text-primary-blue" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Heading level={2} fontSize={{ base: 'xl', md: '2xl' }} color="text-white">
|
||||
<Stack direction="row" align="center" gap={2} flexWrap="wrap">
|
||||
<Text>{getStepTitle(step)}</Text>
|
||||
<Text size="xs" weight="medium" px={2} py={0.5} rounded="full" border borderColor="border-charcoal-outline" bg="bg-iron-gray/60" color="text-gray-300">
|
||||
{getStepContextLabel(step)}
|
||||
</span>
|
||||
</div>
|
||||
</Text>
|
||||
</Stack>
|
||||
</Heading>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
<Text size="sm" color="text-gray-400" block mt={1}>
|
||||
{getStepSubtitle(step)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-deep-graphite border border-charcoal-outline">
|
||||
<span className="text-xs text-gray-500">Step</span>
|
||||
<span className="text-sm font-semibold text-white">{step}</span>
|
||||
<span className="text-xs text-gray-500">/ {steps.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
<Box display={{ base: 'none', sm: 'flex' }} alignItems="center" gap={1.5} px={3} py={1.5} rounded="full" bg="bg-deep-graphite" border borderColor="border-charcoal-outline">
|
||||
<Text size="xs" color="text-gray-500">Step</Text>
|
||||
<Text size="sm" weight="semibold" color="text-white">{step}</Text>
|
||||
<Text size="xs" color="text-gray-500">/ {steps.length}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent mb-6" />
|
||||
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" mb={6} />
|
||||
|
||||
{/* Step content with min-height for consistency */}
|
||||
<div className="min-h-[320px]">
|
||||
<Box minHeight="320px">
|
||||
{step === 1 && (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<Box animate="fade-in" gap={8} display="flex" flexDirection="col">
|
||||
<LeagueBasicsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.basics ?? {}}
|
||||
/>
|
||||
<div className="rounded-xl border border-charcoal-outline bg-iron-gray/40 p-4">
|
||||
<div className="flex items-center justify-between gap-2 mb-2">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-gray-300 uppercase tracking-wide">
|
||||
<Box rounded="xl" border borderColor="border-charcoal-outline" bg="bg-iron-gray/40" p={4}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" gap={2} mb={2}>
|
||||
<Box>
|
||||
<Text size="xs" weight="semibold" color="text-gray-300" uppercase letterSpacing="wide">
|
||||
First season
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Name the first season that will run in this league.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mt-2">
|
||||
<label className="text-sm font-medium text-gray-300">
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box mt={2} display="flex" flexDirection="col" gap={2}>
|
||||
<Text as="label" size="sm" weight="medium" color="text-gray-300" block>
|
||||
Season name
|
||||
</label>
|
||||
</Text>
|
||||
<Input
|
||||
value={form.seasonName ?? ''}
|
||||
onChange={(e) =>
|
||||
@@ -779,16 +798,16 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
}
|
||||
placeholder="e.g., Season 1 (2025)"
|
||||
/>
|
||||
<p className="text-xs text-gray-500">
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Seasons are the individual competitive runs inside your league. You can run Season 2, Season 3, or parallel seasons later.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="animate-fade-in">
|
||||
<Box animate="fade-in">
|
||||
<LeagueVisibilitySection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
@@ -798,55 +817,55 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
: {}
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueStructureSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 4 && (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueTimingsSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
errors={errors.timings ?? {}}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 5 && (
|
||||
<div className="animate-fade-in space-y-8">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={8}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
{/* Scoring Pattern Selection */}
|
||||
<ScoringPatternSection
|
||||
scoring={form.scoring || {}}
|
||||
@@ -866,81 +885,81 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
/>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="h-px bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
|
||||
<Box h="px" bg="bg-gradient-to-r from-transparent via-charcoal-outline to-transparent" />
|
||||
|
||||
{/* Championships & Drop Rules side by side on larger screens */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={6}>
|
||||
<ChampionshipsSection form={form} onChange={setForm} readOnly={presetsLoading} />
|
||||
<LeagueDropSection form={form} onChange={setForm} readOnly={false} />
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{errors.submit && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning-amber">{errors.submit}</p>
|
||||
</div>
|
||||
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 6 && (
|
||||
<div className="animate-fade-in space-y-4">
|
||||
<div className="mb-2">
|
||||
<p className="text-xs text-gray-500">
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={4}>
|
||||
<Box mb={2}>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
Applies to: First season of this league.
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
These settings only affect this season. Future seasons can use different formats.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
<LeagueStewardingSection
|
||||
form={form}
|
||||
onChange={setForm}
|
||||
readOnly={false}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{step === 7 && (
|
||||
<div className="animate-fade-in space-y-6">
|
||||
<Box animate="fade-in" display="flex" flexDirection="col" gap={6}>
|
||||
<LeagueReviewSummary form={form} presets={presets} />
|
||||
{errors.submit && (
|
||||
<div className="flex items-start gap-3 rounded-lg bg-warning-amber/10 p-4 border border-warning-amber/20">
|
||||
<AlertCircle className="w-5 h-5 text-warning-amber shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning-amber">{errors.submit}</p>
|
||||
</div>
|
||||
<Box display="flex" alignItems="start" gap={3} rounded="lg" bg="bg-warning-amber/10" p={4} border borderColor="border-warning-amber/20">
|
||||
<Icon icon={AlertCircle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} />
|
||||
<Text size="sm" color="text-warning-amber">{errors.submit}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex justify-between items-center mt-6">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" mt={6}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
disabled={step === 1 || loading}
|
||||
onClick={goToPreviousStep}
|
||||
className="flex items-center gap-2"
|
||||
icon={<Icon icon={ChevronLeft} size={4} />}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Back</span>
|
||||
<Text display={{ base: 'none', md: 'inline-block' }}>Back</Text>
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
{/* Mobile step dots */}
|
||||
<div className="flex sm:hidden items-center gap-1">
|
||||
<Box display={{ base: 'flex', sm: 'none' }} alignItems="center" gap={1}>
|
||||
{steps.map((s) => (
|
||||
<div
|
||||
<Box
|
||||
key={s.id}
|
||||
className={`
|
||||
h-1.5 rounded-full transition-all duration-300
|
||||
${s.id === step ? 'w-3 bg-primary-blue' : s.id < step ? 'w-1.5 bg-primary-blue/50' : 'w-1.5 bg-charcoal-outline'}
|
||||
`}
|
||||
h="1.5"
|
||||
rounded="full"
|
||||
transition
|
||||
width={s.id === step ? '3' : '1.5'}
|
||||
bg={s.id === step ? 'bg-primary-blue' : s.id < step ? 'bg-primary-blue/50' : 'bg-charcoal-outline'}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{step < 7 ? (
|
||||
<Button
|
||||
@@ -948,38 +967,34 @@ export default function CreateLeagueWizard({ stepName, onStepChange }: CreateLea
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
onClick={goToNextStep}
|
||||
className="flex items-center gap-2"
|
||||
icon={<Icon icon={ChevronRight} size={4} />}
|
||||
flexDirection="row-reverse"
|
||||
>
|
||||
<span>Continue</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
<Text>Continue</Text>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 min-w-[150px] justify-center"
|
||||
minWidth="150px"
|
||||
justifyContent="center"
|
||||
icon={loading ? <Icon icon={Loader2} size={4} animate="spin" /> : <Icon icon={Sparkles} size={4} />}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
<span>Creating…</span>
|
||||
</>
|
||||
<Text>Creating…</Text>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
<span>Create League</span>
|
||||
</>
|
||||
<Text>Create League</Text>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Helper text */}
|
||||
<p className="text-center text-xs text-gray-500 mt-4">
|
||||
<Text size="xs" color="text-gray-500" align="center" block mt={4}>
|
||||
This will create your league and its first season. You can edit both later.
|
||||
</p>
|
||||
</form>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,47 +2,42 @@
|
||||
|
||||
import React from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard';
|
||||
import { CreateLeagueWizard } from './CreateLeagueWizard';
|
||||
import { Section } from '@/ui/Section';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { SearchParamParser } from '@/lib/routing/search-params/SearchParamParser';
|
||||
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
|
||||
|
||||
type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review';
|
||||
|
||||
function normalizeStepName(raw: string | null): StepName {
|
||||
switch (raw) {
|
||||
case 'basics':
|
||||
case 'visibility':
|
||||
case 'structure':
|
||||
case 'schedule':
|
||||
case 'scoring':
|
||||
case 'stewarding':
|
||||
case 'review':
|
||||
return raw;
|
||||
default:
|
||||
return 'basics';
|
||||
}
|
||||
}
|
||||
|
||||
export default function CreateLeaguePage() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const currentStepName = normalizeStepName(
|
||||
searchParams && typeof searchParams.get === 'function'
|
||||
? searchParams.get('step')
|
||||
: null,
|
||||
);
|
||||
const wizardParams = SearchParamParser.parseWizard(searchParams as any).unwrap();
|
||||
const rawStep = wizardParams.step;
|
||||
|
||||
let currentStepName: StepName = 'basics';
|
||||
if (rawStep === 'basics' ||
|
||||
rawStep === 'visibility' ||
|
||||
rawStep === 'structure' ||
|
||||
rawStep === 'schedule' ||
|
||||
rawStep === 'scoring' ||
|
||||
rawStep === 'stewarding' ||
|
||||
rawStep === 'review') {
|
||||
currentStepName = rawStep;
|
||||
}
|
||||
|
||||
const handleStepChange = (stepName: StepName) => {
|
||||
const params = new URLSearchParams(
|
||||
searchParams && typeof searchParams.toString === 'function'
|
||||
? searchParams.toString()
|
||||
: '',
|
||||
);
|
||||
params.set('step', stepName);
|
||||
const query = params.toString();
|
||||
const href = query ? `/leagues/create?${query}` : '/leagues/create';
|
||||
router.push(href);
|
||||
const builder = new SearchParamBuilder();
|
||||
// Copy existing params if needed, but here we just want to set the step
|
||||
if (searchParams) {
|
||||
searchParams.forEach((value, key) => {
|
||||
if (key !== 'step') builder.set(key, value);
|
||||
});
|
||||
}
|
||||
builder.step(stepName);
|
||||
router.push(`/leagues/create${builder.build()}`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { LeaguesClient } from '@/components/leagues/LeaguesClient';
|
||||
import { LeaguesPageQuery } from '@/lib/page-queries/page-queries/LeaguesPageQuery';
|
||||
import { LeaguesPageClient } from './LeaguesPageClient';
|
||||
import { LeaguesPageQuery } from '@/lib/page-queries/LeaguesPageQuery';
|
||||
|
||||
export default async function Page() {
|
||||
// Execute the PageQuery
|
||||
@@ -20,11 +20,11 @@ export default async function Page() {
|
||||
case 'UNKNOWN_ERROR':
|
||||
default:
|
||||
// Return error state - use LeaguesTemplate with empty data
|
||||
return <LeaguesClient viewData={{ leagues: [] }} />;
|
||||
return <LeaguesPageClient viewData={{ leagues: [] }} />;
|
||||
}
|
||||
}
|
||||
|
||||
const viewData = result.unwrap();
|
||||
|
||||
return <LeaguesClient viewData={viewData} />;
|
||||
return <LeaguesPageClient viewData={viewData} />;
|
||||
}
|
||||
@@ -1,22 +1,35 @@
|
||||
import Link from 'next/link';
|
||||
'use client';
|
||||
|
||||
import { Link } from '@/ui/Link';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<main className="min-h-screen flex items-center justify-center bg-deep-graphite text-white px-6">
|
||||
<div className="max-w-md text-center space-y-4">
|
||||
<h1 className="text-3xl font-semibold">Page not found</h1>
|
||||
<p className="text-sm text-gray-400">
|
||||
The page you requested doesn't exist (or isn't available in this mode).
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Link
|
||||
href="/"
|
||||
className="inline-flex items-center justify-center rounded-md bg-primary-blue px-4 py-2 text-sm font-medium text-white hover:bg-primary-blue/80 transition-colors"
|
||||
>
|
||||
Drive home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" color="text-white" px={6}>
|
||||
<Box maxWidth="md" textAlign="center">
|
||||
<Stack gap={4}>
|
||||
<Heading level={1} fontSize="3xl" weight="semibold">Page not found</Heading>
|
||||
<Text size="sm" color="text-gray-400" block>
|
||||
The page you requested doesn't exist (or isn't available in this mode).
|
||||
</Text>
|
||||
<Box pt={2}>
|
||||
<Link
|
||||
href="/"
|
||||
variant="primary"
|
||||
size="sm"
|
||||
weight="medium"
|
||||
rounded="md"
|
||||
px={4}
|
||||
py={2}
|
||||
>
|
||||
Drive home
|
||||
</Link>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { OnboardingWizard } from './OnboardingWizard';
|
||||
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction';
|
||||
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { redirect } from 'next/navigation';
|
||||
import { OnboardingWizardClient } from '@/components/onboarding/OnboardingWizardClient';
|
||||
import { OnboardingPageQuery } from '@/lib/page-queries/page-queries/OnboardingPageQuery';
|
||||
import { OnboardingWizardClient } from './OnboardingWizardClient';
|
||||
import { OnboardingPageQuery } from '@/lib/page-queries/OnboardingPageQuery';
|
||||
import { SearchParamBuilder } from '@/lib/routing/search-params/SearchParamBuilder';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
|
||||
@@ -1,15 +1,27 @@
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import HomeTemplate from '@/templates/HomeTemplate';
|
||||
import { HomeTemplate, type HomeViewData } from '@/templates/HomeTemplate';
|
||||
import { PageDataFetcher } from '@/lib/page/PageDataFetcher';
|
||||
import { getHomeData } from '@/lib/services/home/getHomeData';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { HomeService } from '@/lib/services/home/HomeService'; // @server-safe
|
||||
import { notFound, redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function Page() {
|
||||
const data = await PageDataFetcher.fetchManual(async () => getHomeData());
|
||||
const homeService = new HomeService();
|
||||
|
||||
if (await homeService.shouldRedirectToDashboard()) {
|
||||
redirect(routes.protected.dashboard);
|
||||
}
|
||||
|
||||
const data = await PageDataFetcher.fetchManual(async () => {
|
||||
const result = await homeService.getHomeData();
|
||||
return result.isOk() ? result.unwrap() : null;
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <PageWrapper data={data} Template={HomeTemplate} />;
|
||||
}
|
||||
const Template = ({ data }: { data: HomeViewData }) => <HomeTemplate viewData={data} />;
|
||||
|
||||
return <PageWrapper data={data} Template={Template} />;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesPageQuery } from '@/lib/page-queries/ProfileLeaguesPageQuery';
|
||||
import { ProfileLeaguesTemplate } from '@/templates/ProfileLeaguesTemplate';
|
||||
|
||||
export default async function ProfileLeaguesPage() {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import Link from 'next/link';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Grid } from '@/ui/Grid';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { LiveryCard } from '@/ui/LiveryCard';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
|
||||
export default async function ProfileLiveriesPage() {
|
||||
const mockLiveries = [
|
||||
@@ -14,7 +16,7 @@ export default async function ProfileLiveriesPage() {
|
||||
carId: 'gt3-r',
|
||||
carName: 'Porsche 911 GT3 R (992)',
|
||||
thumbnailUrl: '',
|
||||
uploadedAt: new Date(),
|
||||
uploadedAt: new globalThis.Date(),
|
||||
isValidated: true,
|
||||
},
|
||||
{
|
||||
@@ -22,22 +24,22 @@ export default async function ProfileLiveriesPage() {
|
||||
carId: 'f3',
|
||||
carName: 'Dallara F3',
|
||||
thumbnailUrl: '',
|
||||
uploadedAt: new Date(),
|
||||
uploadedAt: new globalThis.Date(),
|
||||
isValidated: false,
|
||||
}
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py={8}>
|
||||
<div className="flex items-center justify-between mb-8">
|
||||
<div>
|
||||
<Stack direction="row" align="center" justify="between" mb={8}>
|
||||
<Box>
|
||||
<Heading level={1}>My Liveries</Heading>
|
||||
<p className="text-gray-400 mt-1">Manage your custom car liveries</p>
|
||||
</div>
|
||||
<Text color="text-gray-400" mt={1} block>Manage your custom car liveries</Text>
|
||||
</Box>
|
||||
<Link href={routes.protected.profileLiveryUpload}>
|
||||
<Button variant="primary">Upload livery</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Grid cols={3} gap={6}>
|
||||
{mockLiveries.map((livery) => (
|
||||
@@ -45,11 +47,11 @@ export default async function ProfileLiveriesPage() {
|
||||
))}
|
||||
</Grid>
|
||||
|
||||
<div className="mt-12">
|
||||
<Box mt={12}>
|
||||
<Link href={routes.protected.profile}>
|
||||
<Button variant="secondary">Back to profile</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Box>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileLiveryUploadPage() {
|
||||
@@ -10,7 +11,7 @@ export default async function ProfileLiveryUploadPage() {
|
||||
<Container size="md">
|
||||
<Heading level={1}>Upload livery</Heading>
|
||||
<Card>
|
||||
<p>Livery upload is currently unavailable.</p>
|
||||
<Text block mb={4}>Livery upload is currently unavailable.</Text>
|
||||
<Link href={routes.protected.profileLiveries}>
|
||||
<Button variant="secondary">Back to liveries</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ProfilePageQuery } from '@/lib/page-queries/page-queries/ProfilePageQuery';
|
||||
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { updateProfileAction } from './actions';
|
||||
import { ProfilePageClient } from './ProfilePageClient';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Button } from '@/ui/Button';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Container } from '@/ui/Container';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
|
||||
export default async function ProfileSettingsPage() {
|
||||
@@ -10,7 +11,7 @@ export default async function ProfileSettingsPage() {
|
||||
<Container size="md">
|
||||
<Heading level={1}>Settings</Heading>
|
||||
<Card>
|
||||
<p>Settings are currently unavailable.</p>
|
||||
<Text block mb={4}>Settings are currently unavailable.</Text>
|
||||
<Link href={routes.protected.profile}>
|
||||
<Button variant="secondary">Back to profile</Button>
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/page-queries/SponsorshipRequestsPageQuery';
|
||||
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery';
|
||||
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
|
||||
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';
|
||||
|
||||
|
||||
@@ -34,8 +34,6 @@ export function RaceDetailPageClient({
|
||||
onDriverClick,
|
||||
isOwnerOrAdmin
|
||||
}: RaceDetailPageClientProps) {
|
||||
const [showProtestModal, setShowProtestModal] = useState(false);
|
||||
const [showEndRaceModal, setShowEndRaceModal] = useState(false);
|
||||
const [animatedRatingChange, setAnimatedRatingChange] = useState(0);
|
||||
|
||||
const ratingChange = viewData.userResult?.ratingChange ?? null;
|
||||
|
||||
@@ -32,11 +32,11 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
return (
|
||||
<PageWrapper
|
||||
data={null}
|
||||
Template={({ data: _data }) => (
|
||||
Template={() => (
|
||||
<RaceDetailTemplate
|
||||
viewData={undefined}
|
||||
isLoading={false}
|
||||
error={new Error('Failed to load race details')}
|
||||
error={new globalThis.Error('Failed to load race details')}
|
||||
onBack={() => {}}
|
||||
onRegister={() => {}}
|
||||
onWithdraw={() => {}}
|
||||
@@ -77,7 +77,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
|
||||
return (
|
||||
<PageWrapper
|
||||
data={viewData}
|
||||
Template={({ data: _data }) => (
|
||||
Template={() => (
|
||||
<RaceDetailTemplate
|
||||
viewData={viewData}
|
||||
isLoading={false}
|
||||
|
||||
@@ -34,20 +34,21 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
|
||||
<StatefulPageWrapper
|
||||
data={null}
|
||||
isLoading={false}
|
||||
error={new Error('Failed to load race results')}
|
||||
error={new globalThis.Error('Failed to load race results')}
|
||||
retry={() => Promise.resolve()}
|
||||
Template={({ data: _data }) => (
|
||||
Template={() => (
|
||||
<RaceResultsTemplate
|
||||
raceTrack={undefined}
|
||||
raceScheduledAt={undefined}
|
||||
totalDrivers={undefined}
|
||||
leagueName={undefined}
|
||||
raceSOF={null}
|
||||
results={[]}
|
||||
penalties={[]}
|
||||
pointsSystem={{}}
|
||||
fastestLapTime={0}
|
||||
currentDriverId={''}
|
||||
viewData={{
|
||||
raceTrack: '',
|
||||
raceScheduledAt: '',
|
||||
totalDrivers: 0,
|
||||
leagueName: '',
|
||||
raceSOF: null,
|
||||
results: [],
|
||||
penalties: [],
|
||||
pointsSystem: {},
|
||||
fastestLapTime: 0,
|
||||
}}
|
||||
isAdmin={false}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
@@ -82,18 +83,9 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
|
||||
isLoading={false}
|
||||
error={null}
|
||||
retry={() => Promise.resolve()}
|
||||
Template={({ data: _data }) => (
|
||||
Template={() => (
|
||||
<RaceResultsTemplate
|
||||
raceTrack={viewData.raceTrack}
|
||||
raceScheduledAt={viewData.raceScheduledAt}
|
||||
totalDrivers={viewData.totalDrivers}
|
||||
leagueName={viewData.leagueName}
|
||||
raceSOF={viewData.raceSOF}
|
||||
results={viewData.results}
|
||||
penalties={viewData.penalties}
|
||||
pointsSystem={viewData.pointsSystem}
|
||||
fastestLapTime={viewData.fastestLapTime}
|
||||
currentDriverId={''}
|
||||
viewData={viewData}
|
||||
isAdmin={false}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
@@ -117,4 +109,4 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { notFound } from 'next/navigation';
|
||||
import { PageWrapper } from '@/components/shared/state/PageWrapper';
|
||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { RaceStewardingTemplate, type StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||
import { RaceStewardingPageQuery } from '@/lib/page-queries/races/RaceStewardingPageQuery';
|
||||
import { type RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData';
|
||||
import { Gavel } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
interface RaceStewardingPageProps {
|
||||
params: {
|
||||
@@ -20,12 +23,12 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
|
||||
}
|
||||
|
||||
// Data state
|
||||
const [pageData, setPageData] = useState<any>(null);
|
||||
const [pageData, setPageData] = useState<RaceStewardingViewData | undefined>(undefined);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch function
|
||||
const fetchData = async () => {
|
||||
const fetchData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
@@ -33,40 +36,31 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
|
||||
const result = await RaceStewardingPageQuery.execute({ raceId });
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to fetch stewarding data');
|
||||
throw new globalThis.Error('Failed to fetch stewarding data');
|
||||
}
|
||||
|
||||
setPageData(result.unwrap());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
setError(err instanceof globalThis.Error ? err : new globalThis.Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, [raceId]);
|
||||
|
||||
// Transform data for template
|
||||
const templateData = pageData ? {
|
||||
race: pageData.race,
|
||||
league: pageData.league,
|
||||
pendingProtests: pageData.pendingProtests,
|
||||
resolvedProtests: pageData.resolvedProtests,
|
||||
penalties: pageData.penalties,
|
||||
driverMap: pageData.driverMap,
|
||||
pendingCount: pageData.pendingCount,
|
||||
resolvedCount: pageData.resolvedCount,
|
||||
penaltiesCount: pageData.penaltiesCount,
|
||||
} : undefined;
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
// Actions
|
||||
const handleBack = () => {
|
||||
const handleBack = useCallback(() => {
|
||||
window.history.back();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReviewProtest = (protestId: string) => {
|
||||
if (templateData?.league?.id) {
|
||||
window.location.href = `/leagues/${templateData.league.id}/stewarding/protests/${protestId}`;
|
||||
const handleReviewProtest = useCallback((protestId: string) => {
|
||||
if (pageData?.league?.id) {
|
||||
window.location.href = `/leagues/${pageData.league.id}/stewarding/protests/${protestId}`;
|
||||
}
|
||||
};
|
||||
}, [pageData?.league?.id]);
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
@@ -74,9 +68,9 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={fetchData}
|
||||
Template={({ data: _data }) => (
|
||||
Template={({ data }) => (
|
||||
<RaceStewardingTemplate
|
||||
stewardingData={templateData}
|
||||
viewData={data as RaceStewardingViewData}
|
||||
isLoading={false}
|
||||
error={null}
|
||||
onBack={handleBack}
|
||||
@@ -96,4 +90,4 @@ export default function RaceStewardingPage({ params }: RaceStewardingPageProps)
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
140
apps/website/app/races/all/RacesAllPageClient.tsx
Normal file
140
apps/website/app/races/all/RacesAllPageClient.tsx
Normal 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',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { StatefulPageWrapper } from '@/components/shared/state/StatefulPageWrapper';
|
||||
import { RacesAllTemplate } from '@/templates/RacesAllTemplate';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { RacesAllPageQuery } from '@/lib/page-queries/races/RacesAllPageQuery';
|
||||
import { Flag } from 'lucide-react';
|
||||
import { RacesAllPageClient } from './RacesAllPageClient';
|
||||
|
||||
const ITEMS_PER_PAGE = 10;
|
||||
|
||||
interface Race {
|
||||
id: string;
|
||||
track: string;
|
||||
car: string;
|
||||
scheduledAt: string;
|
||||
status: 'scheduled' | 'running' | 'completed' | 'cancelled';
|
||||
sessionType: string;
|
||||
leagueId?: string;
|
||||
leagueName?: string;
|
||||
strengthOfField?: number;
|
||||
}
|
||||
|
||||
export default function RacesAllPage() {
|
||||
const router = useRouter();
|
||||
export default async function Page() {
|
||||
// Execute the PageQuery
|
||||
const result = await RacesAllPageQuery.execute();
|
||||
|
||||
// Client-side state for filters and pagination
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [statusFilter, setStatusFilter] = useState<'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'>('all');
|
||||
const [leagueFilter, setLeagueFilter] = useState<string>('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showFilters, setShowFilters] = useState(false);
|
||||
const [showFilterModal, setShowFilterModal] = useState(false);
|
||||
|
||||
// Data state
|
||||
const [pageData, setPageData] = useState<any>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
// Fetch data
|
||||
const fetchData = async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const result = await RacesAllPageQuery.execute();
|
||||
|
||||
if (result.isErr()) {
|
||||
throw new Error('Failed to fetch races');
|
||||
}
|
||||
|
||||
setPageData(result.unwrap());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Unknown error'));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
};
|
||||
// For other errors, we still render the client component which handles its own loading/error states
|
||||
}
|
||||
|
||||
// Fetch on mount
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, []);
|
||||
const viewData = result.isOk() ? result.unwrap() : null;
|
||||
|
||||
// Transform data
|
||||
const races: Race[] = pageData?.races.map((race: any) => ({
|
||||
id: race.id,
|
||||
track: race.track,
|
||||
car: race.car,
|
||||
scheduledAt: race.scheduledAt,
|
||||
status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled',
|
||||
sessionType: 'race',
|
||||
leagueId: race.leagueId,
|
||||
leagueName: race.leagueName,
|
||||
strengthOfField: race.strengthOfField ?? undefined,
|
||||
})) ?? [];
|
||||
|
||||
// Filter and paginate (Note: This should be done by API per contract)
|
||||
const filteredRaces = races.filter((race: Race) => {
|
||||
if (statusFilter !== 'all' && race.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
const matchesTrack = race.track.toLowerCase().includes(query);
|
||||
const matchesCar = race.car.toLowerCase().includes(query);
|
||||
const matchesLeague = race.leagueName?.toLowerCase().includes(query);
|
||||
if (!matchesTrack && !matchesCar && !matchesLeague) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
const totalPages = Math.ceil(filteredRaces.length / ITEMS_PER_PAGE);
|
||||
const paginatedRaces = filteredRaces.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE);
|
||||
|
||||
// Actions
|
||||
const handleRaceClick = (raceId: string) => {
|
||||
router.push(`/races/${raceId}`);
|
||||
};
|
||||
|
||||
const handleLeagueClick = (leagueId: string) => {
|
||||
router.push(`/leagues/${leagueId}`);
|
||||
};
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
return (
|
||||
<StatefulPageWrapper
|
||||
data={pageData}
|
||||
isLoading={isLoading}
|
||||
error={error}
|
||||
retry={fetchData}
|
||||
Template={({ data: _data }) => (
|
||||
<RacesAllTemplate
|
||||
viewData={pageData}
|
||||
races={paginatedRaces as any}
|
||||
totalFilteredCount={filteredRaces.length}
|
||||
isLoading={false}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
itemsPerPage={ITEMS_PER_PAGE}
|
||||
onPageChange={handlePageChange}
|
||||
statusFilter={statusFilter}
|
||||
setStatusFilter={setStatusFilter}
|
||||
leagueFilter={leagueFilter}
|
||||
setLeagueFilter={setLeagueFilter}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
showFilters={showFilters}
|
||||
setShowFilters={setShowFilters}
|
||||
showFilterModal={showFilterModal}
|
||||
setShowFilterModal={setShowFilterModal}
|
||||
onRaceClick={handleRaceClick}
|
||||
onLeagueClick={handleLeagueClick}
|
||||
/>
|
||||
)}
|
||||
loading={{ variant: 'skeleton', message: 'Loading races...' }}
|
||||
errorConfig={{ variant: 'full-screen' }}
|
||||
empty={{
|
||||
icon: Flag,
|
||||
title: 'No races found',
|
||||
description: 'There are no races available at the moment',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <RacesAllPageClient initialViewData={viewData} />;
|
||||
}
|
||||
|
||||
@@ -5,12 +5,16 @@ import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { StatCard } from '@/ui/StatCard';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { StatusBadge } from '@/ui/StatusBadge';
|
||||
import { InfoBanner } from '@/ui/InfoBanner';
|
||||
import { PageHeader } from '@/ui/PageHeader';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { siteConfig } from '@/lib/siteConfig';
|
||||
import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling";
|
||||
import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
|
||||
import {
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
@@ -20,7 +24,6 @@ import {
|
||||
Check,
|
||||
AlertTriangle,
|
||||
FileText,
|
||||
ArrowRight,
|
||||
TrendingUp,
|
||||
Receipt,
|
||||
Building2,
|
||||
@@ -29,62 +32,21 @@ import {
|
||||
ChevronRight,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Percent
|
||||
Percent,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
// ============================================================================
|
||||
|
||||
interface PaymentMethod {
|
||||
id: string;
|
||||
type: 'card' | 'bank' | 'sepa';
|
||||
last4: string;
|
||||
brand?: string;
|
||||
isDefault: boolean;
|
||||
expiryMonth?: number;
|
||||
expiryYear?: number;
|
||||
bankName?: string;
|
||||
}
|
||||
|
||||
interface Invoice {
|
||||
id: string;
|
||||
invoiceNumber: string;
|
||||
date: Date;
|
||||
dueDate: Date;
|
||||
amount: number;
|
||||
vatAmount: number;
|
||||
totalAmount: number;
|
||||
status: 'paid' | 'pending' | 'overdue' | 'failed';
|
||||
description: string;
|
||||
sponsorshipType: 'league' | 'team' | 'driver' | 'race' | 'platform';
|
||||
pdfUrl: string;
|
||||
}
|
||||
|
||||
interface BillingStats {
|
||||
totalSpent: number;
|
||||
pendingAmount: number;
|
||||
nextPaymentDate: Date;
|
||||
nextPaymentAmount: number;
|
||||
activeSponsorships: number;
|
||||
averageMonthlySpend: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Mock Data
|
||||
// ============================================================================
|
||||
|
||||
import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO';
|
||||
|
||||
// ============================================================================
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
function PaymentMethodCard({
|
||||
function PaymentMethodCardComponent({
|
||||
method,
|
||||
onSetDefault,
|
||||
onRemove
|
||||
}: {
|
||||
method: any;
|
||||
method: PaymentMethodDTO;
|
||||
onSetDefault: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
@@ -95,68 +57,81 @@ function PaymentMethodCard({
|
||||
return CreditCard;
|
||||
};
|
||||
|
||||
const Icon = getIcon();
|
||||
const MethodIcon = getIcon();
|
||||
|
||||
const getLabel = () => {
|
||||
if (method.type === 'sepa' && method.bankName) {
|
||||
return `${method.bankName} •••• ${method.last4}`;
|
||||
}
|
||||
return `${method.brand} •••• ${method.last4}`;
|
||||
};
|
||||
const displayLabel = method.type === 'sepa' && method.bankName
|
||||
? `${method.bankName} •••• ${method.last4}`
|
||||
: `${method.brand} •••• ${method.last4}`;
|
||||
|
||||
const expiryDisplay = method.expiryMonth && method.expiryYear
|
||||
? `${method.expiryMonth}/${method.expiryYear}`
|
||||
: null;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
|
||||
className={`p-4 rounded-xl border transition-all ${
|
||||
method.isDefault
|
||||
? 'border-primary-blue/50 bg-gradient-to-r from-primary-blue/10 to-transparent shadow-[0_0_20px_rgba(25,140,255,0.1)]'
|
||||
: 'border-charcoal-outline bg-iron-gray/30 hover:border-charcoal-outline/80'
|
||||
}`}
|
||||
p={4}
|
||||
rounded="xl"
|
||||
border
|
||||
borderColor={method.isDefault ? 'border-primary-blue/50' : 'border-charcoal-outline'}
|
||||
bg={method.isDefault ? 'bg-gradient-to-r from-primary-blue/10 to-transparent' : 'bg-iron-gray/30'}
|
||||
shadow={method.isDefault ? '0_0_20px_rgba(25,140,255,0.1)' : undefined}
|
||||
hoverBorderColor={!method.isDefault ? 'border-charcoal-outline/80' : undefined}
|
||||
transition-all
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className={`w-12 h-12 rounded-xl flex items-center justify-center ${
|
||||
method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'
|
||||
}`}>
|
||||
<Icon className={`w-6 h-6 ${method.isDefault ? 'text-primary-blue' : 'text-gray-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-white">{method.displayLabel}</span>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Box display="flex" alignItems="center" gap={4}>
|
||||
<Box
|
||||
w="12"
|
||||
h="12"
|
||||
rounded="xl"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
bg={method.isDefault ? 'bg-primary-blue/20' : 'bg-iron-gray'}
|
||||
>
|
||||
<Icon icon={MethodIcon} size={6} color={method.isDefault ? 'text-primary-blue' : 'text-gray-400'} />
|
||||
</Box>
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
<Text weight="medium" color="text-white">{displayLabel}</Text>
|
||||
{method.isDefault && (
|
||||
<span className="px-2 py-0.5 rounded-full text-xs bg-primary-blue/20 text-primary-blue font-medium">
|
||||
Default
|
||||
</span>
|
||||
<Box px={2} py={0.5} rounded="full" bg="bg-primary-blue/20">
|
||||
<Text size="xs" color="text-primary-blue" weight="medium">
|
||||
Default
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
{method.expiryDisplay && (
|
||||
<span className="text-sm text-gray-500">
|
||||
Expires {method.expiryDisplay}
|
||||
</span>
|
||||
</Box>
|
||||
{expiryDisplay && (
|
||||
<Text size="sm" color="text-gray-500" block>
|
||||
Expires {expiryDisplay}
|
||||
</Text>
|
||||
)}
|
||||
{method.type === 'sepa' && (
|
||||
<span className="text-sm text-gray-500">SEPA Direct Debit</span>
|
||||
<Text size="sm" color="text-gray-500" block>SEPA Direct Debit</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{!method.isDefault && (
|
||||
<Button variant="secondary" onClick={onSetDefault} className="text-xs">
|
||||
<Button variant="secondary" onClick={onSetDefault} size="sm">
|
||||
Set Default
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="secondary" onClick={onRemove} className="text-xs text-gray-400 hover:text-racing-red">
|
||||
<Button variant="secondary" onClick={onRemove} size="sm" color="text-gray-400" hoverTextColor="text-racing-red">
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
function InvoiceRowComponent({ invoice, index }: { invoice: InvoiceDTO; index: number }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const statusConfig = {
|
||||
@@ -202,54 +177,64 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) {
|
||||
const StatusIcon = status.icon;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
<Box
|
||||
as={motion.div}
|
||||
initial={{ opacity: 0, x: -10 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: shouldReduceMotion ? 0 : 0.2, delay: shouldReduceMotion ? 0 : index * 0.05 }}
|
||||
className="flex items-center justify-between p-4 border-b border-charcoal-outline/50 last:border-b-0 hover:bg-iron-gray/20 transition-colors group"
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
justifyContent="between"
|
||||
p={4}
|
||||
borderBottom
|
||||
borderColor="border-charcoal-outline/50"
|
||||
hoverBg="bg-iron-gray/20"
|
||||
transition-colors
|
||||
group
|
||||
>
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<div className="w-10 h-10 rounded-lg bg-iron-gray flex items-center justify-center">
|
||||
<Receipt className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-0.5">
|
||||
<span className="font-medium text-white truncate">{invoice.description}</span>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-iron-gray text-gray-400 flex-shrink-0">
|
||||
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500">
|
||||
<span>{invoice.invoiceNumber}</span>
|
||||
<span>•</span>
|
||||
<span>
|
||||
{invoice.formattedDate}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Box display="flex" alignItems="center" gap={4} flexGrow={1}>
|
||||
<Box w="10" h="10" rounded="lg" bg="bg-iron-gray" display="flex" alignItems="center" justifyContent="center">
|
||||
<Icon icon={Receipt} size={5} color="text-gray-400" />
|
||||
</Box>
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} mb={0.5}>
|
||||
<Text weight="medium" color="text-white" truncate>{invoice.description}</Text>
|
||||
<Box px={2} py={0.5} rounded bg="bg-iron-gray">
|
||||
<Text size="xs" color="text-gray-400">
|
||||
{typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Text size="sm" color="text-gray-500">{invoice.invoiceNumber}</Text>
|
||||
<Text size="sm" color="text-gray-500">•</Text>
|
||||
<Text size="sm" color="text-gray-500">
|
||||
{new globalThis.Date(invoice.date).toLocaleDateString()}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-white">
|
||||
{invoice.formattedTotalAmount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
incl. {invoice.formattedVatAmount} VAT
|
||||
</div>
|
||||
</div>
|
||||
<Box display="flex" alignItems="center" gap={6}>
|
||||
<Box textAlign="right">
|
||||
<Text weight="semibold" color="text-white" block>
|
||||
${invoice.totalAmount.toFixed(2)}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block>
|
||||
incl. ${invoice.vatAmount.toFixed(2)} VAT
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${status.bg} ${status.color} border ${status.border}`}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{status.label}
|
||||
</div>
|
||||
<Box display="flex" alignItems="center" gap={1.5} px={2.5} py={1} rounded="full" bg={status.bg} border borderColor={status.border}>
|
||||
<Icon icon={StatusIcon} size={3} color={status.color} />
|
||||
<Text size="xs" weight="medium" color={status.color}>{status.label}</Text>
|
||||
</Box>
|
||||
|
||||
<Button variant="secondary" className="text-xs opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
<Button variant="secondary" size="sm" opacity={0} groupHoverTextColor="opacity-100" transition-opacity icon={<Icon icon={Download} size={3} />}>
|
||||
PDF
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -265,27 +250,27 @@ export default function SponsorBillingPage() {
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" />
|
||||
<p className="text-gray-400">Loading billing data...</p>
|
||||
</div>
|
||||
</div>
|
||||
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
|
||||
<Box textAlign="center">
|
||||
<Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
|
||||
<Text color="text-gray-400">Loading billing data...</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !billingData) {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]">
|
||||
<div className="text-center">
|
||||
<p className="text-gray-400">{error?.getUserMessage() || 'No billing data available'}</p>
|
||||
<Box maxWidth="5xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
|
||||
<Box textAlign="center">
|
||||
<Text color="text-gray-400" block>{error?.message || 'No billing data available'}</Text>
|
||||
{error && (
|
||||
<Button variant="secondary" onClick={retry} className="mt-4">
|
||||
<Button variant="secondary" onClick={retry} mt={4}>
|
||||
Retry
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -297,7 +282,7 @@ export default function SponsorBillingPage() {
|
||||
};
|
||||
|
||||
const handleRemoveMethod = (methodId: string) => {
|
||||
if (confirm('Remove this payment method?')) {
|
||||
if (window.confirm('Remove this payment method?')) {
|
||||
// In a real app, this would call an API
|
||||
console.log('Removing payment method:', methodId);
|
||||
}
|
||||
@@ -319,14 +304,19 @@ export default function SponsorBillingPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="max-w-5xl mx-auto py-8 px-4"
|
||||
<Box
|
||||
maxWidth="5xl"
|
||||
mx="auto"
|
||||
py={8}
|
||||
px={4}
|
||||
as={motion.div}
|
||||
// @ts-ignore
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<PageHeader
|
||||
icon={Wallet}
|
||||
title="Billing & Payments"
|
||||
@@ -334,190 +324,178 @@ export default function SponsorBillingPage() {
|
||||
iconGradient="from-warning-amber/20 to-warning-amber/5"
|
||||
iconBorder="border-warning-amber/30"
|
||||
/>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2, lg: 4 }} gap={4} mb={8}>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
label="Total Spent"
|
||||
value={data.stats.formattedTotalSpent}
|
||||
value={`$${data.stats.totalSpent.toFixed(2)}`}
|
||||
subValue="All time"
|
||||
color="text-performance-green"
|
||||
bgColor="bg-performance-green/10"
|
||||
variant="green"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Pending Payments"
|
||||
value={data.stats.formattedPendingAmount}
|
||||
value={`$${data.stats.pendingAmount.toFixed(2)}`}
|
||||
subValue={`${data.invoices.filter((i: { status: string }) => i.status === 'pending' || i.status === 'overdue').length} invoices`}
|
||||
color="text-warning-amber"
|
||||
bgColor="bg-warning-amber/10"
|
||||
variant="orange"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Calendar}
|
||||
label="Next Payment"
|
||||
value={data.stats.formattedNextPaymentDate}
|
||||
subValue={data.stats.formattedNextPaymentAmount}
|
||||
color="text-primary-blue"
|
||||
bgColor="bg-primary-blue/10"
|
||||
value={new globalThis.Date(data.stats.nextPaymentDate).toLocaleDateString()}
|
||||
subValue={`$${data.stats.nextPaymentAmount.toFixed(2)}`}
|
||||
variant="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label="Monthly Average"
|
||||
value={data.stats.formattedAverageMonthlySpend}
|
||||
value={`$${data.stats.averageMonthlySpend.toFixed(2)}`}
|
||||
subValue="Last 6 months"
|
||||
color="text-gray-400"
|
||||
bgColor="bg-iron-gray"
|
||||
variant="blue"
|
||||
/>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Payment Methods */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-8 overflow-hidden">
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card mb={8} overflow="hidden">
|
||||
<SectionHeader
|
||||
icon={CreditCard}
|
||||
title="Payment Methods"
|
||||
action={
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
|
||||
Add Payment Method
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="p-5 space-y-3">
|
||||
{data.paymentMethods.map((method: { id: string; type: string; last4: string; brand: string; default: boolean }) => (
|
||||
<PaymentMethodCard
|
||||
<Box p={5} display="flex" flexDirection="col" gap={3}>
|
||||
{data.paymentMethods.map((method: PaymentMethodDTO) => (
|
||||
<PaymentMethodCardComponent
|
||||
key={method.id}
|
||||
method={method}
|
||||
onSetDefault={() => handleSetDefault(method.id)}
|
||||
onRemove={() => handleRemoveMethod(method.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-5 pb-5">
|
||||
</Box>
|
||||
<Box px={5} pb={5}>
|
||||
<InfoBanner type="info">
|
||||
<p className="mb-1">We support Visa, Mastercard, American Express, and SEPA Direct Debit.</p>
|
||||
<p>All payment information is securely processed and stored by our payment provider.</p>
|
||||
<Text block mb={1}>We support Visa, Mastercard, American Express, and SEPA Direct Debit.</Text>
|
||||
<Text block>All payment information is securely processed and stored by our payment provider.</Text>
|
||||
</InfoBanner>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Billing History */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Card className="mb-8 overflow-hidden">
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card mb={8} overflow="hidden">
|
||||
<SectionHeader
|
||||
icon={FileText}
|
||||
title="Billing History"
|
||||
color="text-warning-amber"
|
||||
action={
|
||||
<Button variant="secondary" className="text-sm">
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
<Button variant="secondary" size="sm" icon={<Icon icon={Download} size={4} />}>
|
||||
Export All
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: { id: string; date: string; amount: number; status: string }, index: number) => (
|
||||
<InvoiceRow key={invoice.id} invoice={invoice} index={index} />
|
||||
<Box>
|
||||
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: InvoiceDTO, index: number) => (
|
||||
<InvoiceRowComponent key={invoice.id} invoice={invoice} index={index} />
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
{data.invoices.length > 4 && (
|
||||
<div className="p-4 border-t border-charcoal-outline">
|
||||
<Box p={4} borderTop borderColor="border-charcoal-outline">
|
||||
<Button
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
fullWidth
|
||||
onClick={() => setShowAllInvoices(!showAllInvoices)}
|
||||
icon={<Icon icon={ChevronRight} size={4} className={showAllInvoices ? 'rotate-90' : ''} />}
|
||||
flexDirection="row-reverse"
|
||||
>
|
||||
{showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`}
|
||||
<ChevronRight className={`w-4 h-4 ml-2 transition-transform ${showAllInvoices ? 'rotate-90' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Platform Fee & VAT Information */}
|
||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
{/* Platform Fee */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
|
||||
<h3 className="font-semibold text-white flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50">
|
||||
<Percent className="w-4 h-4 text-primary-blue" />
|
||||
</div>
|
||||
<Card overflow="hidden">
|
||||
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
|
||||
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Percent} size={4} color="text-primary-blue" /></Box>}>
|
||||
Platform Fee
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<div className="text-3xl font-bold text-white mb-2">
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box p={5}>
|
||||
<Text size="3xl" weight="bold" color="text-white" block mb={2}>
|
||||
{siteConfig.fees.platformFeePercent}%
|
||||
</div>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
</Text>
|
||||
<Text size="sm" color="text-gray-400" block mb={4}>
|
||||
{siteConfig.fees.description}
|
||||
</p>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>• Applied to all sponsorship payments</p>
|
||||
<p>• Covers platform maintenance and analytics</p>
|
||||
<p>• Ensures quality sponsorship placements</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
<Box display="flex" flexDirection="col" gap={1}>
|
||||
<Text size="xs" color="text-gray-500" block>• Applied to all sponsorship payments</Text>
|
||||
<Text size="xs" color="text-gray-500" block>• Covers platform maintenance and analytics</Text>
|
||||
<Text size="xs" color="text-gray-500" block>• Ensures quality sponsorship placements</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
|
||||
{/* VAT Information */}
|
||||
<Card className="overflow-hidden">
|
||||
<div className="p-5 border-b border-charcoal-outline bg-gradient-to-r from-iron-gray/30 to-transparent">
|
||||
<h3 className="font-semibold text-white flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-iron-gray/50">
|
||||
<Receipt className="w-4 h-4 text-performance-green" />
|
||||
</div>
|
||||
<Card overflow="hidden">
|
||||
<Box p={5} borderBottom borderColor="border-charcoal-outline" bg="bg-gradient-to-r from-iron-gray/30 to-transparent">
|
||||
<Heading level={3} fontSize="base" weight="semibold" color="text-white" icon={<Box p={2} rounded="lg" bg="bg-iron-gray/50"><Icon icon={Receipt} size={4} color="text-performance-green" /></Box>}>
|
||||
VAT Information
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box p={5}>
|
||||
<Text size="sm" color="text-gray-400" block mb={4}>
|
||||
{siteConfig.vat.notice}
|
||||
</p>
|
||||
<div className="space-y-3 text-sm">
|
||||
<div className="flex justify-between items-center py-2 border-b border-charcoal-outline/50">
|
||||
<span className="text-gray-500">Standard VAT Rate</span>
|
||||
<span className="text-white font-medium">{siteConfig.vat.standardRate}%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-gray-500">B2B Reverse Charge</span>
|
||||
<span className="text-performance-green font-medium">Available</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-4">
|
||||
</Text>
|
||||
<Box display="flex" flexDirection="col" gap={3}>
|
||||
<Box display="flex" justifyContent="between" alignItems="center" py={2} borderBottom borderColor="border-charcoal-outline/50">
|
||||
<Text color="text-gray-500">Standard VAT Rate</Text>
|
||||
<Text color="text-white" weight="medium">{siteConfig.vat.standardRate}%</Text>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="between" alignItems="center" py={2}>
|
||||
<Text color="text-gray-500">B2B Reverse Charge</Text>
|
||||
<Text color="text-performance-green" weight="medium">Available</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
<Text size="xs" color="text-gray-500" block mt={4}>
|
||||
Enter your VAT ID in Settings to enable reverse charge for B2B transactions.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Billing Support */}
|
||||
<motion.div variants={itemVariants} className="mt-6">
|
||||
<Card className="p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 rounded-xl bg-iron-gray">
|
||||
<Info className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-white">Need help with billing?</h3>
|
||||
<p className="text-sm text-gray-500">
|
||||
<Box as={motion.div} variants={itemVariants} mt={6}>
|
||||
<Card p={5}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box p={3} rounded="xl" bg="bg-iron-gray">
|
||||
<Icon icon={Info} size={5} color="text-gray-400" />
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="base" weight="medium" color="text-white">Need help with billing?</Heading>
|
||||
<Text size="sm" color="text-gray-500" block>
|
||||
Contact our billing support for questions about invoices, payments, or refunds.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
|
||||
Contact Support
|
||||
<ExternalLink className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,16 @@
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { motion, useReducedMotion, AnimatePresence } from 'framer-motion';
|
||||
import { motion, useReducedMotion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { InfoBanner } from '@/ui/InfoBanner';
|
||||
import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships";
|
||||
import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
|
||||
import {
|
||||
Megaphone,
|
||||
Trophy,
|
||||
@@ -23,17 +27,14 @@ import {
|
||||
Car,
|
||||
Flag,
|
||||
Search,
|
||||
Filter,
|
||||
TrendingUp,
|
||||
BarChart3,
|
||||
ArrowUpRight,
|
||||
ArrowDownRight,
|
||||
AlertCircle,
|
||||
Send,
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
RefreshCw,
|
||||
Handshake
|
||||
} from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
@@ -98,26 +99,27 @@ const STATUS_CONFIG = {
|
||||
// Components
|
||||
// ============================================================================
|
||||
|
||||
function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
function SponsorshipCard({ sponsorship }: { sponsorship: unknown }) {
|
||||
const shouldReduceMotion = useReducedMotion();
|
||||
|
||||
const typeConfig = TYPE_CONFIG[sponsorship.type as keyof typeof TYPE_CONFIG];
|
||||
const statusConfig = STATUS_CONFIG[sponsorship.status as keyof typeof STATUS_CONFIG];
|
||||
const s = sponsorship as any; // Temporary cast to avoid breaking logic
|
||||
const typeConfig = TYPE_CONFIG[s.type as keyof typeof TYPE_CONFIG];
|
||||
const statusConfig = STATUS_CONFIG[s.status as keyof typeof STATUS_CONFIG];
|
||||
const TypeIcon = typeConfig.icon;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
const daysRemaining = Math.ceil((sponsorship.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const daysRemaining = Math.ceil((s.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
|
||||
const isExpiringSoon = daysRemaining > 0 && daysRemaining <= 30;
|
||||
const isPending = sponsorship.status === 'pending_approval';
|
||||
const isRejected = sponsorship.status === 'rejected';
|
||||
const isApproved = sponsorship.status === 'approved';
|
||||
const isPending = s.status === 'pending_approval';
|
||||
const isRejected = s.status === 'rejected';
|
||||
const isApproved = s.status === 'approved';
|
||||
|
||||
const getEntityLink = () => {
|
||||
switch (sponsorship.type) {
|
||||
case 'leagues': return `/leagues/${sponsorship.entityId}`;
|
||||
case 'teams': return `/teams/${sponsorship.entityId}`;
|
||||
case 'drivers': return `/drivers/${sponsorship.entityId}`;
|
||||
case 'races': return `/races/${sponsorship.entityId}`;
|
||||
switch (s.type) {
|
||||
case 'leagues': return `/leagues/${s.entityId}`;
|
||||
case 'teams': return `/teams/${s.entityId}`;
|
||||
case 'drivers': return `/drivers/${s.entityId}`;
|
||||
case 'races': return `/races/${s.entityId}`;
|
||||
default: return '#';
|
||||
}
|
||||
};
|
||||
@@ -135,172 +137,166 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
isApproved ? 'border-primary-blue/30' : ''
|
||||
}`}>
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-10 h-10 rounded-lg ${typeConfig.bgColor} flex items-center justify-center`}>
|
||||
<Stack direction="row" align="start" justify="between" mb={4}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Box w="10" h="10" rounded="lg" bg={typeConfig.bgColor} display="flex" alignItems="center" justifyContent="center">
|
||||
<TypeIcon className={`w-5 h-5 ${typeConfig.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${typeConfig.bgColor} ${typeConfig.color}`}>
|
||||
</Box>
|
||||
<Box>
|
||||
<Box display="flex" alignItems="center" gap={2} flexWrap="wrap">
|
||||
<Text size="xs" weight="medium" px={2} py={0.5} rounded="md" bg={typeConfig.bgColor} color={typeConfig.color}>
|
||||
{typeConfig.label}
|
||||
</span>
|
||||
{sponsorship.tier && (
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded ${
|
||||
sponsorship.tier === 'main'
|
||||
? 'bg-primary-blue/20 text-primary-blue'
|
||||
: 'bg-purple-400/20 text-purple-400'
|
||||
}`}>
|
||||
{sponsorship.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
|
||||
</span>
|
||||
</Text>
|
||||
{s.tier && (
|
||||
<Text size="xs" weight="medium" px={2} py={0.5} rounded="md" bg={s.tier === 'main' ? 'bg-primary-blue/20' : 'bg-purple-400/20'} color={s.tier === 'main' ? 'text-primary-blue' : 'text-purple-400'}>
|
||||
{s.tier === 'main' ? 'Main Sponsor' : 'Secondary'}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`flex items-center gap-1 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.bgColor} ${statusConfig.color} ${statusConfig.borderColor}`}>
|
||||
</Box>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Box display="flex" alignItems="center" gap={1} px={2.5} py={1} rounded="full" border bg={statusConfig.bgColor} color={statusConfig.color} borderColor={statusConfig.borderColor}>
|
||||
<StatusIcon className="w-3 h-3" />
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs" weight="medium">{statusConfig.label}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Entity Name */}
|
||||
<h3 className="text-lg font-semibold text-white mb-1">{sponsorship.entityName}</h3>
|
||||
{sponsorship.details && (
|
||||
<p className="text-sm text-gray-500 mb-3">{sponsorship.details}</p>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={1}>{s.entityName}</Heading>
|
||||
{s.details && (
|
||||
<Text size="sm" color="text-gray-500" block mb={3}>{s.details}</Text>
|
||||
)}
|
||||
|
||||
{/* Application/Approval Info for non-active states */}
|
||||
{isPending && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-warning-amber/5 border border-warning-amber/20">
|
||||
<div className="flex items-center gap-2 text-warning-amber text-sm mb-2">
|
||||
<Box mb={4} p={3} rounded="lg" bg="bg-warning-amber/5" border borderColor="border-warning-amber/20">
|
||||
<Stack direction="row" align="center" gap={2} color="text-warning-amber" mb={2}>
|
||||
<Send className="w-4 h-4" />
|
||||
<span className="font-medium">Application Pending</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 mb-2">
|
||||
Sent to <span className="text-gray-300">{sponsorship.entityOwner}</span> on{' '}
|
||||
{sponsorship.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
{sponsorship.applicationMessage && (
|
||||
<p className="text-xs text-gray-500 italic">"{sponsorship.applicationMessage}"</p>
|
||||
<Text size="sm" weight="medium">Application Pending</Text>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-400" block mb={2}>
|
||||
Sent to <Text color="text-gray-300">{s.entityOwner}</Text> on{' '}
|
||||
{s.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</Text>
|
||||
{s.applicationMessage && (
|
||||
<Text size="xs" color="text-gray-500" italic block>"{s.applicationMessage}"</Text>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isApproved && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-primary-blue/5 border border-primary-blue/20">
|
||||
<div className="flex items-center gap-2 text-primary-blue text-sm mb-1">
|
||||
<Box mb={4} p={3} rounded="lg" bg="bg-primary-blue/5" border borderColor="border-primary-blue/20">
|
||||
<Stack direction="row" align="center" gap={2} color="text-primary-blue" mb={1}>
|
||||
<ThumbsUp className="w-4 h-4" />
|
||||
<span className="font-medium">Approved!</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
Approved by <span className="text-gray-300">{sponsorship.entityOwner}</span> on{' '}
|
||||
{sponsorship.approvalDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Starts {sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</p>
|
||||
</div>
|
||||
<Text size="sm" weight="medium">Approved!</Text>
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-400" block>
|
||||
Approved by <Text color="text-gray-300">{s.entityOwner}</Text> on{' '}
|
||||
{s.approvalDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
|
||||
</Text>
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
Starts {s.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{isRejected && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-racing-red/5 border border-racing-red/20">
|
||||
<div className="flex items-center gap-2 text-racing-red text-sm mb-1">
|
||||
<Box mb={4} p={3} rounded="lg" bg="bg-racing-red/5" border borderColor="border-racing-red/20">
|
||||
<Stack direction="row" align="center" gap={2} color="text-racing-red" mb={1}>
|
||||
<ThumbsDown className="w-4 h-4" />
|
||||
<span className="font-medium">Application Declined</span>
|
||||
</div>
|
||||
{sponsorship.rejectionReason && (
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
Reason: <span className="text-gray-300">{sponsorship.rejectionReason}</span>
|
||||
</p>
|
||||
<Text size="sm" weight="medium">Application Declined</Text>
|
||||
</Stack>
|
||||
{s.rejectionReason && (
|
||||
<Text size="xs" color="text-gray-400" block mt={1}>
|
||||
Reason: <Text color="text-gray-300">{s.rejectionReason}</Text>
|
||||
</Text>
|
||||
)}
|
||||
<Button variant="secondary" className="mt-2 text-xs">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />
|
||||
Reapply
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Metrics Grid - Only show for active sponsorships */}
|
||||
{sponsorship.status === 'active' && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-4">
|
||||
<div className="bg-iron-gray/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
|
||||
{s.status === 'active' && (
|
||||
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={3} mb={4}>
|
||||
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
|
||||
<Eye className="w-3 h-3" />
|
||||
Impressions
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white font-semibold">{sponsorship.formattedImpressions}</span>
|
||||
{sponsorship.impressionsChange !== undefined && sponsorship.impressionsChange !== 0 && (
|
||||
<span className={`text-xs flex items-center ${
|
||||
sponsorship.impressionsChange > 0 ? 'text-performance-green' : 'text-racing-red'
|
||||
}`}>
|
||||
{sponsorship.impressionsChange > 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{Math.abs(sponsorship.impressionsChange)}%
|
||||
</span>
|
||||
<Text size="xs">Impressions</Text>
|
||||
</Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Text color="text-white" weight="semibold">{s.formattedImpressions}</Text>
|
||||
{s.impressionsChange !== undefined && s.impressionsChange !== 0 && (
|
||||
<Text size="xs" display="flex" alignItems="center" color={s.impressionsChange > 0 ? 'text-performance-green' : 'text-racing-red'}>
|
||||
{s.impressionsChange > 0 ? <ArrowUpRight className="w-3 h-3" /> : <ArrowDownRight className="w-3 h-3" />}
|
||||
{Math.abs(s.impressionsChange)}%
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{sponsorship.engagement && (
|
||||
<div className="bg-iron-gray/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
|
||||
{s.engagement && (
|
||||
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
Engagement
|
||||
</div>
|
||||
<div className="text-white font-semibold">{sponsorship.engagement}%</div>
|
||||
</div>
|
||||
<Text size="xs">Engagement</Text>
|
||||
</Box>
|
||||
<Text color="text-white" weight="semibold">{s.engagement}%</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<div className="bg-iron-gray/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
|
||||
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
|
||||
<Calendar className="w-3 h-3" />
|
||||
Period
|
||||
</div>
|
||||
<div className="text-white font-semibold text-xs">
|
||||
{sponsorship.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {sponsorship.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs">Period</Text>
|
||||
</Box>
|
||||
<Text color="text-white" weight="semibold" size="xs">
|
||||
{s.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {s.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="bg-iron-gray/50 rounded-lg p-3">
|
||||
<div className="flex items-center gap-1 text-gray-400 text-xs mb-1">
|
||||
<Box bg="bg-iron-gray/50" rounded="lg" p={3}>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400" mb={1}>
|
||||
<Trophy className="w-3 h-3" />
|
||||
Investment
|
||||
</div>
|
||||
<div className="text-white font-semibold">{sponsorship.formattedPrice}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Text size="xs">Investment</Text>
|
||||
</Box>
|
||||
<Text color="text-white" weight="semibold">{s.formattedPrice}</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Basic info for non-active */}
|
||||
{sponsorship.status !== 'active' && (
|
||||
<div className="flex items-center gap-4 mb-4 text-sm">
|
||||
<div className="flex items-center gap-1 text-gray-400">
|
||||
{s.status !== 'active' && (
|
||||
<Stack direction="row" align="center" gap={4} mb={4}>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400">
|
||||
<Calendar className="w-3.5 h-3.5" />
|
||||
{sponsorship.periodDisplay}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 text-gray-400">
|
||||
<Text size="sm">{s.periodDisplay}</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={1} color="text-gray-400">
|
||||
<Trophy className="w-3.5 h-3.5" />
|
||||
{sponsorship.formattedPrice}
|
||||
</div>
|
||||
</div>
|
||||
<Text size="sm">{s.formattedPrice}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between pt-3 border-t border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-2">
|
||||
{sponsorship.status === 'active' && (
|
||||
<span className={`text-xs ${isExpiringSoon ? 'text-warning-amber' : 'text-gray-500'}`}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between" pt={3} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Box display="flex" alignItems="center" gap={2}>
|
||||
{s.status === 'active' && (
|
||||
<Text size="xs" color={isExpiringSoon ? 'text-warning-amber' : 'text-gray-500'}>
|
||||
{daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Ended'}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
{isPending && (
|
||||
<span className="text-xs text-gray-500">
|
||||
<Text size="xs" color="text-gray-500">
|
||||
Waiting for response...
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{sponsorship.type !== 'platform' && (
|
||||
</Box>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
{s.type !== 'platform' && (
|
||||
<Link href={getEntityLink()}>
|
||||
<Button variant="secondary" className="text-xs">
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
@@ -313,14 +309,14 @@ function SponsorshipCard({ sponsorship }: { sponsorship: any }) {
|
||||
Cancel Application
|
||||
</Button>
|
||||
)}
|
||||
{sponsorship.status === 'active' && (
|
||||
{s.status === 'active' && (
|
||||
<Button variant="secondary" className="text-xs">
|
||||
Details
|
||||
<ChevronRight className="w-3 h-3 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
);
|
||||
@@ -370,53 +366,65 @@ export default function SponsorCampaignsPage() {
|
||||
const data = sponsorshipsData;
|
||||
|
||||
// Filter sponsorships
|
||||
const filteredSponsorships = data.sponsorships.filter((s: any) => {
|
||||
if (typeFilter !== 'all' && s.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && s.status !== statusFilter) return false;
|
||||
if (searchQuery && !s.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
const filteredSponsorships = data.sponsorships.filter((s: unknown) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sponsorship = s as any;
|
||||
if (typeFilter !== 'all' && sponsorship.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && sponsorship.status !== statusFilter) return false;
|
||||
if (searchQuery && !sponsorship.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
// Calculate stats
|
||||
const stats = {
|
||||
total: data.sponsorships.length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
active: data.sponsorships.filter((s: any) => s.status === 'active').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
pending: data.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
approved: data.sponsorships.filter((s: any) => s.status === 'approved').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
rejected: data.sponsorships.filter((s: any) => s.status === 'rejected').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
totalInvestment: data.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
totalImpressions: data.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
|
||||
};
|
||||
|
||||
// Stats by type
|
||||
const statsByType = {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leagues: data.sponsorships.filter((s: any) => s.type === 'leagues').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
teams: data.sponsorships.filter((s: any) => s.type === 'teams').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
drivers: data.sponsorships.filter((s: any) => s.type === 'drivers').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
races: data.sponsorships.filter((s: any) => s.type === 'races').length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
platform: data.sponsorships.filter((s: any) => s.type === 'platform').length,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto py-8 px-4">
|
||||
<Box maxWidth="7xl" mx="auto" py={8} px={4}>
|
||||
{/* Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white flex items-center gap-3">
|
||||
<Megaphone className="w-7 h-7 text-primary-blue" />
|
||||
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4} mb={8}>
|
||||
<Box>
|
||||
<Heading level={1} fontSize="2xl" weight="bold" color="text-white" icon={<Megaphone className="w-7 h-7 text-primary-blue" />}>
|
||||
My Sponsorships
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">Manage applications and active sponsorship campaigns</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
</Heading>
|
||||
<Text color="text-gray-400" mt={1} block>Manage applications and active sponsorship campaigns</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center" gap={3}>
|
||||
<Link href="/leagues">
|
||||
<Button variant="primary">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Find Opportunities
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Info Banner about how sponsorships work */}
|
||||
{stats.pending > 0 && (
|
||||
@@ -426,23 +434,23 @@ export default function SponsorCampaignsPage() {
|
||||
className="mb-6"
|
||||
>
|
||||
<InfoBanner type="info" title="Sponsorship Applications">
|
||||
<p>
|
||||
You have <strong className="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</strong> waiting for approval.
|
||||
<Text size="sm">
|
||||
You have <Text weight="bold" color="text-white">{stats.pending} pending application{stats.pending !== 1 ? 's' : ''}</Text> waiting for approval.
|
||||
League admins, team owners, and drivers review applications before accepting sponsorships.
|
||||
</p>
|
||||
</Text>
|
||||
</InfoBanner>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-6 gap-4 mb-8">
|
||||
<Box display="grid" gridCols={{ base: 2, md: 6 }} gap={4} mb={8}>
|
||||
<motion.div
|
||||
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-white">{stats.total}</div>
|
||||
<div className="text-sm text-gray-400">Total</div>
|
||||
<Text size="2xl" weight="bold" color="text-white" block>{stats.total}</Text>
|
||||
<Text size="sm" color="text-gray-400">Total</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -451,8 +459,8 @@ export default function SponsorCampaignsPage() {
|
||||
transition={{ delay: 0.05 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-performance-green">{stats.active}</div>
|
||||
<div className="text-sm text-gray-400">Active</div>
|
||||
<Text size="2xl" weight="bold" color="text-performance-green" block>{stats.active}</Text>
|
||||
<Text size="sm" color="text-gray-400">Active</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -461,8 +469,8 @@ export default function SponsorCampaignsPage() {
|
||||
transition={{ delay: 0.1 }}
|
||||
>
|
||||
<Card className={`p-4 ${stats.pending > 0 ? 'border-warning-amber/30' : ''}`}>
|
||||
<div className="text-2xl font-bold text-warning-amber">{stats.pending}</div>
|
||||
<div className="text-sm text-gray-400">Pending</div>
|
||||
<Text size="2xl" weight="bold" color="text-warning-amber" block>{stats.pending}</Text>
|
||||
<Text size="sm" color="text-gray-400">Pending</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -471,8 +479,8 @@ export default function SponsorCampaignsPage() {
|
||||
transition={{ delay: 0.15 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-primary-blue">{stats.approved}</div>
|
||||
<div className="text-sm text-gray-400">Approved</div>
|
||||
<Text size="2xl" weight="bold" color="text-primary-blue" block>{stats.approved}</Text>
|
||||
<Text size="sm" color="text-gray-400">Approved</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -481,8 +489,8 @@ export default function SponsorCampaignsPage() {
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-white">${stats.totalInvestment.toLocaleString()}</div>
|
||||
<div className="text-sm text-gray-400">Active Investment</div>
|
||||
<Text size="2xl" weight="bold" color="text-white" block>${stats.totalInvestment.toLocaleString()}</Text>
|
||||
<Text size="sm" color="text-gray-400">Active Investment</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
<motion.div
|
||||
@@ -491,16 +499,16 @@ export default function SponsorCampaignsPage() {
|
||||
transition={{ delay: 0.25 }}
|
||||
>
|
||||
<Card className="p-4">
|
||||
<div className="text-2xl font-bold text-primary-blue">{(stats.totalImpressions / 1000).toFixed(0)}k</div>
|
||||
<div className="text-sm text-gray-400">Impressions</div>
|
||||
<Text size="2xl" weight="bold" color="text-primary-blue" block>{(stats.totalImpressions / 1000).toFixed(0)}k</Text>
|
||||
<Text size="sm" color="text-gray-400">Impressions</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col lg:flex-row gap-4 mb-6">
|
||||
<Stack direction={{ base: 'col', lg: 'row' }} gap={4} mb={6}>
|
||||
{/* Search */}
|
||||
<div className="relative flex-1">
|
||||
<Box position="relative" flexGrow={1}>
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
@@ -509,10 +517,10 @@ export default function SponsorCampaignsPage() {
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Type Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto pb-2 lg:pb-0">
|
||||
<Box display="flex" alignItems="center" gap={2} overflow="auto" pb={{ base: 2, lg: 0 }}>
|
||||
{(['all', 'leagues', 'teams', 'drivers', 'races', 'platform'] as const).map((type) => {
|
||||
const config = TYPE_CONFIG[type];
|
||||
const Icon = config.icon;
|
||||
@@ -525,22 +533,20 @@ export default function SponsorCampaignsPage() {
|
||||
typeFilter === type
|
||||
? 'bg-primary-blue text-white'
|
||||
: 'bg-iron-gray/50 text-gray-400 hover:bg-iron-gray'
|
||||
}`}
|
||||
} border-0 cursor-pointer`}
|
||||
>
|
||||
<Icon className="w-4 h-4" />
|
||||
{config.label}
|
||||
<span className={`px-1.5 py-0.5 rounded text-xs ${
|
||||
typeFilter === type ? 'bg-white/20' : 'bg-charcoal-outline'
|
||||
}`}>
|
||||
<Text size="xs" px={1.5} py={0.5} rounded="sm" bg={typeFilter === type ? 'bg-white/20' : 'bg-charcoal-outline'}>
|
||||
{count}
|
||||
</span>
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-2 overflow-x-auto">
|
||||
<Box display="flex" alignItems="center" gap={2} overflow="auto">
|
||||
{(['all', 'active', 'pending_approval', 'approved', 'rejected'] as const).map((status) => {
|
||||
const config = status === 'all'
|
||||
? { label: 'All', color: 'text-gray-400' }
|
||||
@@ -556,33 +562,31 @@ export default function SponsorCampaignsPage() {
|
||||
statusFilter === status
|
||||
? 'bg-iron-gray text-white border border-charcoal-outline'
|
||||
: 'text-gray-500 hover:text-gray-300'
|
||||
}`}
|
||||
} border-0 cursor-pointer`}
|
||||
>
|
||||
{config.label}
|
||||
{count > 0 && status !== 'all' && (
|
||||
<span className={`ml-1.5 px-1.5 py-0.5 rounded text-xs ${
|
||||
status === 'pending_approval' ? 'bg-warning-amber/20 text-warning-amber' : 'bg-charcoal-outline'
|
||||
}`}>
|
||||
<Text size="xs" ml={1.5} px={1.5} py={0.5} rounded="sm" bg={status === 'pending_approval' ? 'bg-warning-amber/20' : 'bg-charcoal-outline'} color={status === 'pending_approval' ? 'text-warning-amber' : ''}>
|
||||
{count}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
{/* Sponsorship List */}
|
||||
{filteredSponsorships.length === 0 ? (
|
||||
<Card className="text-center py-16">
|
||||
<Megaphone className="w-12 h-12 text-gray-600 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-white mb-2">No sponsorships found</h3>
|
||||
<p className="text-gray-400 mb-6 max-w-md mx-auto">
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={2}>No sponsorships found</Heading>
|
||||
<Text color="text-gray-400" mb={6} maxWidth="md" mx="auto" block>
|
||||
{searchQuery || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
? 'Try adjusting your filters to see more results.'
|
||||
: 'Start sponsoring leagues, teams, or drivers to grow your brand visibility.'}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-3 justify-center">
|
||||
</Text>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={3} justify="center">
|
||||
<Link href="/leagues">
|
||||
<Button variant="primary">
|
||||
<Trophy className="w-4 h-4 mr-2" />
|
||||
@@ -601,15 +605,15 @@ export default function SponsorCampaignsPage() {
|
||||
Browse Drivers
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
|
||||
{filteredSponsorships.map((sponsorship: any) => (
|
||||
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</div>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -3,15 +3,18 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { SponsorLeaguesTemplate, type SortOption, type TierFilter, type AvailabilityFilter } from '@/templates/SponsorLeaguesTemplate';
|
||||
|
||||
export default function SponsorLeaguesPageClient({ data }: { data: any }) {
|
||||
export default function SponsorLeaguesPageClient({ data }: { data: unknown }) {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [tierFilter, setTierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter, setAvailabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOption>('rating');
|
||||
const [tierFilter] = useState<TierFilter>('all');
|
||||
const [availabilityFilter] = useState<AvailabilityFilter>('all');
|
||||
const [sortBy] = useState<SortOption>('rating');
|
||||
|
||||
const filteredLeagues = useMemo(() => {
|
||||
if (!data?.leagues) return [];
|
||||
return data.leagues
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const d = data as any;
|
||||
if (!d?.leagues) return [];
|
||||
return d.leagues
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.filter((league: any) => {
|
||||
if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
@@ -27,6 +30,7 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
|
||||
}
|
||||
return true;
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sort((a: any, b: any) => {
|
||||
switch (sortBy) {
|
||||
case 'rating': return b.rating - a.rating;
|
||||
@@ -36,20 +40,15 @@ export default function SponsorLeaguesPageClient({ data }: { data: any }) {
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
}, [data?.leagues, searchQuery, tierFilter, availabilityFilter, sortBy]);
|
||||
}, [data, searchQuery, tierFilter, availabilityFilter, sortBy]);
|
||||
|
||||
return (
|
||||
<SponsorLeaguesTemplate
|
||||
viewData={data}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
viewData={data as any}
|
||||
filteredLeagues={filteredLeagues}
|
||||
searchQuery={searchQuery}
|
||||
setSearchQuery={setSearchQuery}
|
||||
tierFilter={tierFilter}
|
||||
setTierFilter={setTierFilter}
|
||||
availabilityFilter={availabilityFilter}
|
||||
setAvailabilityFilter={setAvailabilityFilter}
|
||||
sortBy={sortBy}
|
||||
setSortBy={setSortBy}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,10 +29,14 @@ export default async function Page() {
|
||||
// Calculate summary stats (business logic moved from view model)
|
||||
const stats = {
|
||||
total: leaguesData.length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
mainAvailable: leaguesData.filter((l: any) => l.mainSponsorSlot.available).length,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
secondaryAvailable: leaguesData.reduce((sum: number, l: any) => sum + l.secondarySlots.available, 0),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
totalDrivers: leaguesData.reduce((sum: number, l: any) => sum + l.drivers, 0),
|
||||
avgCpm: Math.round(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
leaguesData.reduce((sum: number, l: any) => sum + l.cpm, 0) / leaguesData.length
|
||||
),
|
||||
};
|
||||
|
||||
@@ -6,9 +6,14 @@ import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Toggle } from '@/ui/Toggle';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { SectionHeader } from '@/ui/SectionHeader';
|
||||
import { FormField } from '@/ui/FormField';
|
||||
import { PageHeader } from '@/ui/PageHeader';
|
||||
import { Image } from '@/ui/Image';
|
||||
import {
|
||||
Settings,
|
||||
Building2,
|
||||
@@ -201,33 +206,38 @@ export default function SponsorSettingsPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="max-w-4xl mx-auto py-8 px-4"
|
||||
<Box
|
||||
maxWidth="4xl"
|
||||
mx="auto"
|
||||
py={8}
|
||||
px={4}
|
||||
as={motion.div}
|
||||
// @ts-ignore
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{/* Header */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<PageHeader
|
||||
icon={Settings}
|
||||
title="Sponsor Settings"
|
||||
description="Manage your company profile, notifications, and security preferences"
|
||||
action={<SavedIndicator visible={saved} />}
|
||||
/>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Company Profile */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Building2}
|
||||
title="Company Profile"
|
||||
description="Your public-facing company information"
|
||||
/>
|
||||
<div className="p-6 space-y-6">
|
||||
<Box p={6} className="space-y-6">
|
||||
{/* Company Basic Info */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
<FormField label="Company Name" icon={Building2} required>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -238,24 +248,32 @@ export default function SponsorSettingsPage() {
|
||||
</FormField>
|
||||
|
||||
<FormField label="Industry">
|
||||
<select
|
||||
<Box as="select"
|
||||
value={profile.industry}
|
||||
onChange={(e) => setProfile({ ...profile, industry: e.target.value })}
|
||||
className="w-full px-3 py-2 bg-iron-gray border border-charcoal-outline rounded-lg text-white focus:outline-none focus:border-primary-blue"
|
||||
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setProfile({ ...profile, industry: e.target.value })}
|
||||
w="full"
|
||||
px={3}
|
||||
py={2}
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
className="focus:outline-none focus:border-primary-blue"
|
||||
>
|
||||
{INDUSTRY_OPTIONS.map(industry => (
|
||||
<option key={industry} value={industry}>{industry}</option>
|
||||
))}
|
||||
</select>
|
||||
</Box>
|
||||
</FormField>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
||||
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
|
||||
Contact Information
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
</Heading>
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
<FormField label="Contact Name" icon={User} required>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -291,16 +309,16 @@ export default function SponsorSettingsPage() {
|
||||
placeholder="https://company.com"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Address */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
||||
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
|
||||
Business Address
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="md:col-span-2">
|
||||
</Heading>
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
|
||||
<Box colSpan={{ base: 1, md: 2 }}>
|
||||
<FormField label="Street Address" icon={MapPin}>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -312,7 +330,7 @@ export default function SponsorSettingsPage() {
|
||||
placeholder="123 Main Street"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<FormField label="City">
|
||||
<Input
|
||||
@@ -358,31 +376,39 @@ export default function SponsorSettingsPage() {
|
||||
placeholder="XX12-3456789"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Description */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50">
|
||||
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
|
||||
<FormField label="Company Description">
|
||||
<textarea
|
||||
<Box as="textarea"
|
||||
value={profile.description}
|
||||
onChange={(e) => setProfile({ ...profile, description: e.target.value })}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setProfile({ ...profile, description: e.target.value })}
|
||||
placeholder="Tell potential sponsorship partners about your company, products, and what you're looking for in sponsorship opportunities..."
|
||||
rows={4}
|
||||
className="w-full px-4 py-3 bg-iron-gray border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue resize-none"
|
||||
w="full"
|
||||
px={4}
|
||||
py={3}
|
||||
bg="bg-iron-gray"
|
||||
border
|
||||
borderColor="border-charcoal-outline"
|
||||
rounded="lg"
|
||||
color="text-white"
|
||||
className="placeholder-gray-500 focus:outline-none focus:border-primary-blue resize-none"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
This description appears on your public sponsor profile.
|
||||
</p>
|
||||
</Text>
|
||||
</FormField>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Social Links */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50">
|
||||
<h3 className="text-sm font-semibold text-gray-400 uppercase tracking-wider mb-4">
|
||||
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
|
||||
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
|
||||
Social Media
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
</Heading>
|
||||
<Box display="grid" gridCols={{ base: 1, md: 3 }} gap={6}>
|
||||
<FormField label="Twitter / X" icon={LinkIcon}>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -418,49 +444,49 @@ export default function SponsorSettingsPage() {
|
||||
placeholder="@username"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Logo Upload */}
|
||||
<div className="pt-4 border-t border-charcoal-outline/50">
|
||||
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
|
||||
<FormField label="Company Logo" icon={ImageIcon}>
|
||||
<div className="flex items-start gap-6">
|
||||
<div className="w-24 h-24 rounded-xl bg-gradient-to-br from-iron-gray to-deep-graphite border-2 border-dashed border-charcoal-outline flex items-center justify-center overflow-hidden">
|
||||
<Stack direction="row" align="start" gap={6}>
|
||||
<Box w="24" h="24" rounded="xl" bg="bg-gradient-to-br from-iron-gray to-deep-graphite" border borderColor="border-charcoal-outline" borderStyle="dashed" display="flex" alignItems="center" justifyContent="center" overflow="hidden">
|
||||
{profile.logoUrl ? (
|
||||
<img src={profile.logoUrl} alt="Company logo" className="w-full h-full object-cover" />
|
||||
<Image src={profile.logoUrl} alt="Company logo" width={96} height={96} objectFit="cover" />
|
||||
) : (
|
||||
<Building2 className="w-10 h-10 text-gray-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="cursor-pointer">
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<Stack direction="row" align="center" gap={3}>
|
||||
<Text as="label" cursor="pointer">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
className="hidden"
|
||||
/>
|
||||
<div className="px-4 py-2 rounded-lg bg-iron-gray border border-charcoal-outline text-gray-300 hover:bg-charcoal-outline transition-colors flex items-center gap-2">
|
||||
<Box px={4} py={2} rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" color="text-gray-300" transition className="hover:bg-charcoal-outline" display="flex" alignItems="center" gap={2}>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload Logo
|
||||
</div>
|
||||
</label>
|
||||
<Text>Upload Logo</Text>
|
||||
</Box>
|
||||
</Text>
|
||||
{profile.logoUrl && (
|
||||
<Button variant="secondary" className="text-sm text-gray-400">
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 mt-2">
|
||||
</Stack>
|
||||
<Text size="xs" color="text-gray-500" block mt={2}>
|
||||
PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</FormField>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
{/* Save Button */}
|
||||
<div className="pt-6 border-t border-charcoal-outline flex items-center justify-end gap-4">
|
||||
<Box pt={6} borderTop borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="end" gap={4}>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSaveProfile}
|
||||
@@ -468,24 +494,24 @@ export default function SponsorSettingsPage() {
|
||||
className="min-w-[160px]"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
|
||||
Saving...
|
||||
</span>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Box w="4" h="4" border borderColor="border-white/30" borderTopColor="border-t-white" rounded="full" animate="spin" />
|
||||
<Text>Saving...</Text>
|
||||
</Stack>
|
||||
) : (
|
||||
<span className="flex items-center gap-2">
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Save className="w-4 h-4" />
|
||||
Save Profile
|
||||
</span>
|
||||
<Text>Save Profile</Text>
|
||||
</Stack>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Notification Preferences */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Bell}
|
||||
@@ -493,8 +519,8 @@ export default function SponsorSettingsPage() {
|
||||
description="Control which emails you receive from GridPilot"
|
||||
color="text-warning-amber"
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="space-y-1">
|
||||
<Box p={6}>
|
||||
<Box className="space-y-1">
|
||||
<Toggle
|
||||
checked={notifications.emailNewSponsorships}
|
||||
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
|
||||
@@ -531,13 +557,13 @@ export default function SponsorSettingsPage() {
|
||||
label="Contract Expiry Reminders"
|
||||
description="Receive reminders before your sponsorship contracts expire"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Privacy & Visibility */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Eye}
|
||||
@@ -545,8 +571,8 @@ export default function SponsorSettingsPage() {
|
||||
description="Control how your profile appears to others"
|
||||
color="text-performance-green"
|
||||
/>
|
||||
<div className="p-6">
|
||||
<div className="space-y-1">
|
||||
<Box p={6}>
|
||||
<Box className="space-y-1">
|
||||
<Toggle
|
||||
checked={privacy.publicProfile}
|
||||
onChange={(checked) => setPrivacy({ ...privacy, publicProfile: checked })}
|
||||
@@ -571,13 +597,13 @@ export default function SponsorSettingsPage() {
|
||||
label="Allow Direct Contact"
|
||||
description="Enable leagues and teams to send you sponsorship proposals"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Security */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card className="mb-6 overflow-hidden">
|
||||
<SectionHeader
|
||||
icon={Shield}
|
||||
@@ -585,80 +611,77 @@ export default function SponsorSettingsPage() {
|
||||
description="Protect your sponsor account"
|
||||
color="text-primary-blue"
|
||||
/>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-iron-gray">
|
||||
<Box p={6} className="space-y-4">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box p={2} rounded="lg" bg="bg-iron-gray">
|
||||
<Key className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-200 font-medium">Password</p>
|
||||
<p className="text-sm text-gray-500">Last changed 3 months ago</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-gray-200" weight="medium" block>Password</Text>
|
||||
<Text size="sm" color="text-gray-500" block>Last changed 3 months ago</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary">
|
||||
Change Password
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center justify-between py-3 border-b border-charcoal-outline/50">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-iron-gray">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" py={3} borderBottom borderColor="border-charcoal-outline/50">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box p={2} rounded="lg" bg="bg-iron-gray">
|
||||
<Smartphone className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-200 font-medium">Two-Factor Authentication</p>
|
||||
<p className="text-sm text-gray-500">Add an extra layer of security to your account</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-gray-200" weight="medium" block>Two-Factor Authentication</Text>
|
||||
<Text size="sm" color="text-gray-500" block>Add an extra layer of security to your account</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary">
|
||||
Enable 2FA
|
||||
</Button>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center justify-between py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-iron-gray">
|
||||
<Box display="flex" alignItems="center" justifyContent="between" py={3}>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box p={2} rounded="lg" bg="bg-iron-gray">
|
||||
<Lock className="w-5 h-5 text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-200 font-medium">Active Sessions</p>
|
||||
<p className="text-sm text-gray-500">Manage devices where you're logged in</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-gray-200" weight="medium" block>Active Sessions</Text>
|
||||
<Text size="sm" color="text-gray-500" block>Manage devices where you're logged in</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button variant="secondary">
|
||||
View Sessions
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</Box>
|
||||
|
||||
{/* Danger Zone */}
|
||||
<motion.div variants={itemVariants}>
|
||||
<Box as={motion.div} variants={itemVariants}>
|
||||
<Card className="border-racing-red/30 overflow-hidden">
|
||||
<div className="p-5 border-b border-racing-red/30 bg-gradient-to-r from-racing-red/10 to-transparent">
|
||||
<h2 className="text-lg font-semibold text-racing-red flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-racing-red/10">
|
||||
<AlertCircle className="w-5 h-5 text-racing-red" />
|
||||
</div>
|
||||
<Box p={5} borderBottom borderColor="border-racing-red/30" bg="bg-gradient-to-r from-racing-red/10 to-transparent">
|
||||
<Heading level={2} fontSize="lg" weight="semibold" color="text-racing-red" icon={<Box p={2} rounded="lg" bg="bg-racing-red/10"><AlertCircle className="w-5 h-5 text-racing-red" /></Box>}>
|
||||
Danger Zone
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 rounded-lg bg-racing-red/10">
|
||||
</Heading>
|
||||
</Box>
|
||||
<Box p={6}>
|
||||
<Box display="flex" alignItems="center" justifyContent="between">
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box p={2} rounded="lg" bg="bg-racing-red/10">
|
||||
<Trash2 className="w-5 h-5 text-racing-red" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-200 font-medium">Delete Sponsor Account</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
</Box>
|
||||
<Box>
|
||||
<Text color="text-gray-200" weight="medium" block>Delete Sponsor Account</Text>
|
||||
<Text size="sm" color="text-gray-500" block>
|
||||
Permanently delete your account and all associated sponsorship data.
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleDeleteAccount}
|
||||
@@ -666,10 +689,10 @@ export default function SponsorSettingsPage() {
|
||||
>
|
||||
Delete Account
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,10 @@ import { motion, useReducedMotion } from 'framer-motion';
|
||||
import { Card } from '@/ui/Card';
|
||||
import { Button } from '@/ui/Button';
|
||||
import { Input } from '@/ui/Input';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Heading } from '@/ui/Heading';
|
||||
import { SponsorHero } from '@/components/sponsors/SponsorHero';
|
||||
import { SponsorWorkflowMockup } from '@/components/sponsors/SponsorWorkflowMockup';
|
||||
import { SponsorBenefitCard } from '@/components/sponsors/SponsorBenefitCard';
|
||||
@@ -25,7 +29,6 @@ import {
|
||||
BarChart3,
|
||||
Shield,
|
||||
CheckCircle2,
|
||||
Star,
|
||||
Megaphone
|
||||
} from 'lucide-react';
|
||||
|
||||
@@ -238,13 +241,13 @@ export default function SponsorSignupPage() {
|
||||
// Landing page for sponsors
|
||||
if (mode === 'landing') {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite">
|
||||
<Box minHeight="screen" bg="bg-deep-graphite">
|
||||
{/* Hero Section */}
|
||||
<SponsorHero
|
||||
title="Connect Your Brand with Sim Racing"
|
||||
subtitle="Reach passionate racing communities through league, team, driver, and race sponsorships. Real exposure, measurable results."
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={4} justify="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setMode('signup')}
|
||||
@@ -261,12 +264,12 @@ export default function SponsorSignupPage() {
|
||||
Sign In
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</Button>
|
||||
</div>
|
||||
</Stack>
|
||||
</SponsorHero>
|
||||
|
||||
{/* Platform Stats */}
|
||||
<div className="max-w-6xl mx-auto px-4 -mt-8 relative z-10">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<Box maxWidth="6xl" mx="auto" px={4} mt={-8} position="relative" zIndex={10}>
|
||||
<Box display="grid" gridCols={{ base: 2, md: 4 }} gap={4}>
|
||||
{PLATFORM_STATS.map((stat, index) => (
|
||||
<motion.div
|
||||
key={stat.label}
|
||||
@@ -275,27 +278,27 @@ export default function SponsorSignupPage() {
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="text-center p-4 bg-iron-gray/80 backdrop-blur-sm">
|
||||
<div className="text-2xl sm:text-3xl font-bold text-white mb-1">{stat.value}</div>
|
||||
<div className="text-xs sm:text-sm text-gray-400">{stat.label}</div>
|
||||
<Text size={{ base: '2xl', sm: '3xl' }} weight="bold" color="text-white" block mb={1}>{stat.value}</Text>
|
||||
<Text size={{ base: 'xs', sm: 'sm' }} color="text-gray-400">{stat.label}</Text>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Sponsorship Types Section */}
|
||||
<section className="max-w-6xl mx-auto px-4 py-16 sm:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
<Box as="section" maxWidth="6xl" mx="auto" px={4} py={{ base: 16, md: 24 }}>
|
||||
<Box textAlign="center" mb={12}>
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
|
||||
Sponsorship Opportunities
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
</Heading>
|
||||
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
|
||||
Choose how you want to connect with the sim racing community.
|
||||
Multiple sponsorship tiers and types to fit every budget and goal.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
{SPONSORSHIP_TYPES.map((type, index) => (
|
||||
<motion.div
|
||||
key={type.id}
|
||||
@@ -304,60 +307,60 @@ export default function SponsorSignupPage() {
|
||||
transition={{ delay: index * 0.1 }}
|
||||
>
|
||||
<Card className="h-full hover:border-primary-blue/50 transition-all duration-300 group">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className={`w-12 h-12 rounded-xl bg-iron-gray border border-charcoal-outline flex items-center justify-center group-hover:border-primary-blue/50 transition-colors`}>
|
||||
<Stack direction="row" align="start" gap={4} mb={4}>
|
||||
<Box w="12" h="12" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="center" group hoverBorderColor="primary-blue/50" transition>
|
||||
<type.icon className={`w-6 h-6 ${type.color}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{type.title}</h3>
|
||||
<p className="text-sm text-primary-blue font-medium">{type.priceRange}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">{type.title}</Heading>
|
||||
<Text size="sm" color="text-primary-blue" weight="medium" block>{type.priceRange}</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
|
||||
<p className="text-sm text-gray-400 mb-4">{type.description}</p>
|
||||
<Text size="sm" color="text-gray-400" block mb={4}>{type.description}</Text>
|
||||
|
||||
<ul className="space-y-2">
|
||||
<Box as="ul" className="space-y-2">
|
||||
{type.benefits.map((benefit, i) => (
|
||||
<li key={i} className="flex items-center gap-2 text-sm text-gray-300">
|
||||
<Box as="li" key={i} display="flex" alignItems="center" gap={2}>
|
||||
<CheckCircle2 className="w-4 h-4 text-performance-green flex-shrink-0" />
|
||||
{benefit}
|
||||
</li>
|
||||
<Text size="sm" color="text-gray-300">{benefit}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</ul>
|
||||
</Box>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Workflow Mockup Section */}
|
||||
<section className="bg-iron-gray/30 py-16 sm:py-24">
|
||||
<div className="max-w-6xl mx-auto px-4">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
<Box as="section" bg="bg-iron-gray/30" py={{ base: 16, md: 24 }}>
|
||||
<Box maxWidth="6xl" mx="auto" px={4}>
|
||||
<Box textAlign="center" mb={12}>
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
|
||||
How It Works
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
</Heading>
|
||||
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
|
||||
From discovery to results tracking — a seamless sponsorship experience.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<SponsorWorkflowMockup />
|
||||
</div>
|
||||
</section>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Benefits Grid */}
|
||||
<section className="max-w-6xl mx-auto px-4 py-16 sm:py-24">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
<Box as="section" maxWidth="6xl" mx="auto" px={4} py={{ base: 16, md: 24 }}>
|
||||
<Box textAlign="center" mb={12}>
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
|
||||
Why Sponsor on GridPilot?
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto">
|
||||
</Heading>
|
||||
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block>
|
||||
Professional tools and genuine community engagement make your sponsorship worthwhile.
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
||||
<SponsorBenefitCard
|
||||
icon={Eye}
|
||||
title="Real Visibility"
|
||||
@@ -395,18 +398,18 @@ export default function SponsorSignupPage() {
|
||||
description="Scale your sponsorships as you see results. Start small or go big from day one."
|
||||
delay={0.5}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="max-w-4xl mx-auto px-4 py-16 sm:py-24 text-center">
|
||||
<h2 className="text-3xl sm:text-4xl font-bold text-white mb-4">
|
||||
<Box as="section" maxWidth="4xl" mx="auto" px={4} py={{ base: 16, md: 24 }} textAlign="center">
|
||||
<Heading level={2} fontSize={{ base: '3xl', md: '4xl' }} weight="bold" color="text-white" mb={4}>
|
||||
Ready to Grow Your Brand?
|
||||
</h2>
|
||||
<p className="text-gray-400 max-w-2xl mx-auto mb-8">
|
||||
</Heading>
|
||||
<Text color="text-gray-400" maxWidth="2xl" mx="auto" block mb={8}>
|
||||
Join sponsors connecting with sim racing communities worldwide.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
</Text>
|
||||
<Stack direction={{ base: 'col', md: 'row' }} gap={4} justify="center">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => setMode('signup')}
|
||||
@@ -424,45 +427,45 @@ export default function SponsorSignupPage() {
|
||||
<Mail className="w-5 h-5 mr-2" />
|
||||
Contact Sales
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Login form
|
||||
if (mode === 'login') {
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite flex items-center justify-center px-4 py-12">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8">
|
||||
<Box minHeight="screen" bg="bg-deep-graphite" display="flex" alignItems="center" justifyContent="center" px={4} py={12}>
|
||||
<Box fullWidth maxWidth="md">
|
||||
<Box mb={8}>
|
||||
<button
|
||||
onClick={() => setMode('landing')}
|
||||
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2"
|
||||
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2 bg-transparent border-0 cursor-pointer"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 rotate-180" />
|
||||
Back to overview
|
||||
</button>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20">
|
||||
<Stack direction="row" align="center" gap={4} mb={2}>
|
||||
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
|
||||
<Building2 className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Sponsor Sign In</h1>
|
||||
<p className="text-sm text-gray-400">Access your sponsor dashboard</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1} fontSize="2xl" weight="bold" color="text-white">Sponsor Sign In</Heading>
|
||||
<Text size="sm" color="text-gray-400">Access your sponsor dashboard</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Card className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box as="form" onSubmit={handleSubmit} className="space-y-5">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Mail className="w-4 h-4 text-gray-500" />
|
||||
Email Address
|
||||
</div>
|
||||
</label>
|
||||
</Stack>
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.contactEmail}
|
||||
@@ -471,12 +474,12 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.contactEmail ? 'error' : 'default'}
|
||||
errorMessage={errors.contactEmail}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
Password
|
||||
</label>
|
||||
</Text>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
@@ -485,17 +488,17 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.password ? 'error' : 'default'}
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<label className="flex items-center gap-2 text-gray-400">
|
||||
<Stack direction="row" align="center" justify="between">
|
||||
<Text as="label" display="flex" alignItems="center" gap={2} color="text-gray-400">
|
||||
<input type="checkbox" className="rounded border-charcoal-outline bg-iron-gray" />
|
||||
Remember me
|
||||
</label>
|
||||
<button type="button" className="text-primary-blue hover:underline">
|
||||
</Text>
|
||||
<button type="button" className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer">
|
||||
Forgot password?
|
||||
</button>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -505,63 +508,62 @@ export default function SponsorSignupPage() {
|
||||
>
|
||||
{submitting ? 'Signing in...' : 'Sign In'}
|
||||
</Button>
|
||||
</form>
|
||||
</Box>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline">
|
||||
<p className="text-sm text-gray-400 text-center mb-4">
|
||||
<Box mt={6} pt={6} borderTop borderColor="border-charcoal-outline">
|
||||
<Text size="sm" color="text-gray-400" align="center" block mb={4}>
|
||||
Don't have an account?{' '}
|
||||
<button
|
||||
onClick={() => setMode('signup')}
|
||||
className="text-primary-blue hover:underline"
|
||||
className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer"
|
||||
>
|
||||
Create one
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Signup form
|
||||
return (
|
||||
<div className="min-h-screen bg-deep-graphite py-12 px-4">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Box minHeight="screen" bg="bg-deep-graphite" py={12} px={4}>
|
||||
<Box maxWidth="2xl" mx="auto">
|
||||
{/* Header */}
|
||||
<div className="mb-8">
|
||||
<Box mb={8}>
|
||||
<button
|
||||
onClick={() => setMode('landing')}
|
||||
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2"
|
||||
className="text-sm text-gray-400 hover:text-white mb-6 flex items-center gap-2 bg-transparent border-0 cursor-pointer"
|
||||
>
|
||||
<ArrowRight className="w-4 h-4 rotate-180" />
|
||||
Back to overview
|
||||
</button>
|
||||
<div className="flex items-center gap-4 mb-2">
|
||||
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-primary-blue/10 border border-primary-blue/20">
|
||||
<Stack direction="row" align="center" gap={4} mb={2}>
|
||||
<Box display="flex" h="14" w="14" alignItems="center" justifyContent="center" rounded="2xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
|
||||
<Building2 className="w-7 h-7 text-primary-blue" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-white">Create Sponsor Account</h1>
|
||||
<p className="text-sm text-gray-400">Register your company to sponsor racing entities</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
<Box>
|
||||
<Heading level={1} fontSize="2xl" weight="bold" color="text-white">Create Sponsor Account</Heading>
|
||||
<Text size="sm" color="text-gray-400">Register your company to sponsor racing entities</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<Card className="p-6">
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<Box as="form" onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Company Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Building2 className="w-5 h-5 text-primary-blue" />
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Building2 className="w-5 h-5 text-primary-blue" />}>
|
||||
Company Information
|
||||
</h3>
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
|
||||
<Box colSpan={{ base: 1, md: 2 }}>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
Company Name *
|
||||
</label>
|
||||
</Text>
|
||||
<Input
|
||||
type="text"
|
||||
value={formData.companyName}
|
||||
@@ -570,15 +572,15 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.companyName ? 'error' : 'default'}
|
||||
errorMessage={errors.companyName}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Mail className="w-4 h-4 text-gray-500" />
|
||||
Contact Email *
|
||||
</div>
|
||||
</label>
|
||||
</Stack>
|
||||
</Text>
|
||||
<Input
|
||||
type="email"
|
||||
value={formData.contactEmail}
|
||||
@@ -587,36 +589,35 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.contactEmail ? 'error' : 'default'}
|
||||
errorMessage={errors.contactEmail}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Globe className="w-4 h-4 text-gray-500" />
|
||||
Website URL
|
||||
</div>
|
||||
</label>
|
||||
</Stack>
|
||||
</Text>
|
||||
<Input
|
||||
type="url"
|
||||
value={formData.websiteUrl}
|
||||
onChange={(e) => setFormData({ ...formData, websiteUrl: e.target.value })}
|
||||
placeholder="https://company.com"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Sponsorship Interests */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 text-purple-400" />
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Target className="w-5 h-5 text-purple-400" />}>
|
||||
Sponsorship Interests
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 mb-4">
|
||||
</Heading>
|
||||
<Text size="sm" color="text-gray-400" block mb={4}>
|
||||
Select the types of sponsorships you're interested in (optional)
|
||||
</p>
|
||||
</Text>
|
||||
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
<Box display="grid" gridCols={{ base: 2, sm: 3 }} gap={3}>
|
||||
{SPONSORSHIP_TYPES.map((type) => {
|
||||
const isSelected = formData.interests.includes(type.id);
|
||||
return (
|
||||
@@ -633,28 +634,28 @@ export default function SponsorSignupPage() {
|
||||
`}
|
||||
>
|
||||
<type.icon className={`w-5 h-5 ${isSelected ? type.color : 'text-gray-500'} mb-2`} />
|
||||
<p className={`text-sm font-medium ${isSelected ? 'text-white' : 'text-gray-400'}`}>
|
||||
<Text size="sm" weight="medium" color={isSelected ? 'text-white' : 'text-gray-400'} block>
|
||||
{type.title.replace(' Sponsorship', '').replace(' Advertising', '')}
|
||||
</p>
|
||||
</Text>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Company Logo */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
<Stack direction="row" align="center" gap={2}>
|
||||
<Upload className="w-4 h-4 text-gray-500" />
|
||||
Company Logo (optional)
|
||||
</div>
|
||||
</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 rounded-lg bg-iron-gray border border-charcoal-outline flex items-center justify-center flex-shrink-0">
|
||||
</Stack>
|
||||
</Text>
|
||||
<Stack direction="row" align="center" gap={4}>
|
||||
<Box w="16" h="16" rounded="lg" bg="bg-iron-gray" border borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="center" flexShrink={0}>
|
||||
<Building2 className="w-6 h-6 text-gray-500" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
</Box>
|
||||
<Box flexGrow={1}>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/svg+xml"
|
||||
@@ -664,25 +665,24 @@ export default function SponsorSignupPage() {
|
||||
}}
|
||||
className="block w-full text-sm text-gray-400 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-primary-blue/10 file:text-primary-blue hover:file:bg-primary-blue/20"
|
||||
/>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
<Text size="xs" color="text-gray-500" block mt={1}>
|
||||
PNG, JPEG, or SVG. Recommended: 500x500px transparent background.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
{/* Account Security */}
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white mb-4 flex items-center gap-2">
|
||||
<Shield className="w-5 h-5 text-performance-green" />
|
||||
<Box>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white" mb={4} icon={<Shield className="w-5 h-5 text-performance-green" />}>
|
||||
Account Security
|
||||
</h3>
|
||||
</Heading>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={4}>
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
Password *
|
||||
</label>
|
||||
</Text>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
@@ -691,12 +691,12 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.password ? 'error' : 'default'}
|
||||
errorMessage={errors.password}
|
||||
/>
|
||||
</div>
|
||||
</Box>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-300 mb-2">
|
||||
<Box>
|
||||
<Text as="label" block size="sm" weight="medium" color="text-gray-300" mb={2}>
|
||||
Confirm Password *
|
||||
</label>
|
||||
</Text>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
@@ -705,49 +705,49 @@ export default function SponsorSignupPage() {
|
||||
variant={errors.confirmPassword ? 'error' : 'default'}
|
||||
errorMessage={errors.confirmPassword}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Legal Agreements */}
|
||||
<div className="space-y-3 pt-4 border-t border-charcoal-outline">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<Stack gap={3} pt={4} borderTop borderColor="border-charcoal-outline">
|
||||
<Text as="label" display="flex" alignItems="start" gap={3} cursor="pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.acceptTerms}
|
||||
onChange={(e) => setFormData({ ...formData, acceptTerms: e.target.checked })}
|
||||
className="mt-1 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
I accept the{' '}
|
||||
<a href="/legal/terms" className="text-primary-blue hover:underline">Terms of Service</a>
|
||||
{' '}and{' '}
|
||||
<a href="/legal/privacy" className="text-primary-blue hover:underline">Privacy Policy</a>
|
||||
{' '}*
|
||||
</span>
|
||||
</label>
|
||||
</Text>
|
||||
</Text>
|
||||
{errors.acceptTerms && (
|
||||
<p className="text-sm text-warning-amber">{errors.acceptTerms}</p>
|
||||
<Text size="sm" color="text-warning-amber">{errors.acceptTerms}</Text>
|
||||
)}
|
||||
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<Text as="label" display="flex" alignItems="start" gap={3} cursor="pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={formData.acceptVat}
|
||||
onChange={(e) => setFormData({ ...formData, acceptVat: e.target.checked })}
|
||||
className="mt-1 rounded border-charcoal-outline bg-iron-gray text-primary-blue focus:ring-primary-blue"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
{siteConfig.vat.notice} A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorships. *
|
||||
</span>
|
||||
</label>
|
||||
</Text>
|
||||
</Text>
|
||||
{errors.acceptVat && (
|
||||
<p className="text-sm text-warning-amber">{errors.acceptVat}</p>
|
||||
<Text size="sm" color="text-warning-amber">{errors.acceptVat}</Text>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Stack direction="row" gap={3} pt={4}>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
@@ -764,22 +764,22 @@ export default function SponsorSignupPage() {
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Stack>
|
||||
</Box>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-charcoal-outline text-center">
|
||||
<p className="text-sm text-gray-400">
|
||||
<Box mt={6} pt={6} borderTop="1px solid" borderColor="border-charcoal-outline" textAlign="center">
|
||||
<Text size="sm" color="text-gray-400">
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => setMode('login')}
|
||||
className="text-primary-blue hover:underline"
|
||||
className="text-primary-blue hover:underline bg-transparent border-0 cursor-pointer"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</Text>
|
||||
</Box>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +1,18 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamDetailPageQuery } from '@/lib/page-queries/page-queries/TeamDetailPageQuery';
|
||||
import { TeamDetailViewDataBuilder } from '@/lib/builders/view-data/TeamDetailViewDataBuilder';
|
||||
import { TeamDetailPageQuery } from '@/lib/page-queries/TeamDetailPageQuery';
|
||||
import { TeamDetailPageClient } from './TeamDetailPageClient';
|
||||
|
||||
export default async function Page({ params }: { params: { id: string } }) {
|
||||
const result = await TeamDetailPageQuery.execute(params.id);
|
||||
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
const viewData = TeamDetailViewDataBuilder.build(result.dto);
|
||||
return <TeamDetailPageClient viewData={viewData} />;
|
||||
case 'notFound':
|
||||
notFound();
|
||||
case 'redirect':
|
||||
// This would typically use redirect() from next/navigation
|
||||
// but we need to handle it at the page level
|
||||
return null;
|
||||
case 'error':
|
||||
// For now, treat errors as not found
|
||||
// In production, you might want a proper error page
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
if (error === 'notFound') {
|
||||
notFound();
|
||||
}
|
||||
// For other errors, treat as not found for now
|
||||
notFound();
|
||||
}
|
||||
}
|
||||
|
||||
return <TeamDetailPageClient viewData={result.unwrap()} />;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Trophy } from 'lucide-react';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { routes } from '@/lib/routing/RouteConfig';
|
||||
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
|
||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||
|
||||
// ============================================================================
|
||||
// MAIN PAGE COMPONENT
|
||||
@@ -21,7 +22,7 @@ export default async function TeamLeaderboardPage() {
|
||||
let error = null;
|
||||
|
||||
if (result.isOk()) {
|
||||
data = result.unwrap();
|
||||
data = result.unwrap().map(t => new TeamSummaryViewModel(t));
|
||||
} else {
|
||||
const domainError = result.getError();
|
||||
error = new Error(domainError.message);
|
||||
@@ -52,4 +53,4 @@ export default async function TeamLeaderboardPage() {
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { notFound } from 'next/navigation';
|
||||
import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery';
|
||||
import { TeamsPageQuery } from '@/lib/page-queries/TeamsPageQuery';
|
||||
import { TeamsPageClient } from './TeamsPageClient';
|
||||
|
||||
export default async function Page() {
|
||||
|
||||
@@ -10,8 +10,8 @@ import {
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
import type { SessionViewModel } from '@/lib/view-models/SessionViewModel';
|
||||
import { useCurrentSession } from "@/lib/hooks/auth/useCurrentSession";
|
||||
import { useLogout } from "@/lib/hooks/auth/useLogout";
|
||||
import { useCurrentSession } from "@/hooks/auth/useCurrentSession";
|
||||
import { useLogout } from "@/hooks/auth/useLogout";
|
||||
|
||||
export type AuthContextValue = {
|
||||
session: SessionViewModel | null;
|
||||
@@ -124,7 +124,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
|
||||
|
||||
// Trigger a test API error
|
||||
const testError = new Error('This is a test error for debugging');
|
||||
(testError as any).type = 'TEST_ERROR';
|
||||
(testError as Error & { type?: string }).type = 'TEST_ERROR';
|
||||
|
||||
const globalHandler = getGlobalErrorHandler();
|
||||
globalHandler.report(testError, { test: true, timestamp: Date.now() });
|
||||
@@ -138,7 +138,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
|
||||
try {
|
||||
// This will fail and be logged
|
||||
await fetch('https://httpstat.us/500');
|
||||
} catch (error) {
|
||||
} catch (_error) {
|
||||
// Already logged by interceptor
|
||||
console.log('%c[TEST] API call completed', 'color: #00aaff; font-weight: bold;');
|
||||
}
|
||||
@@ -173,7 +173,7 @@ export function DebugModeToggle({ show }: DebugModeToggleProps) {
|
||||
},
|
||||
errors: globalHandler.getStats(),
|
||||
api: apiLogger.getStats(),
|
||||
reactErrors: (window as any).__GRIDPILOT_REACT_ERRORS__ || [],
|
||||
reactErrors: window.__GRIDPILOT_REACT_ERRORS__ || [],
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -350,14 +350,14 @@ export function useDebugMode() {
|
||||
globalHandler.initialize();
|
||||
|
||||
const apiLogger = getGlobalApiLogger();
|
||||
if (!(window as any).__GRIDPILOT_FETCH_LOGGED__) {
|
||||
if (!window.__GRIDPILOT_FETCH_LOGGED__) {
|
||||
const loggedFetch = apiLogger.createLoggedFetch();
|
||||
window.fetch = loggedFetch as any;
|
||||
(window as any).__GRIDPILOT_FETCH_LOGGED__ = true;
|
||||
window.fetch = loggedFetch as typeof fetch;
|
||||
window.__GRIDPILOT_FETCH_LOGGED__ = true;
|
||||
}
|
||||
|
||||
(window as any).__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
|
||||
(window as any).__GRIDPILOT_API_LOGGER__ = apiLogger;
|
||||
window.__GRIDPILOT_GLOBAL_HANDLER__ = globalHandler;
|
||||
window.__GRIDPILOT_API_LOGGER__ = apiLogger;
|
||||
}, []);
|
||||
|
||||
const disable = useCallback(() => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/ui/Link';
|
||||
import { PlaceholderImage } from '@/ui/PlaceholderImage';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Text } from '@/ui/Text';
|
||||
@@ -35,7 +35,8 @@ export function DriverIdentity(props: DriverIdentityProps) {
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
flexShrink={0}
|
||||
style={{ width: avatarSize, height: avatarSize }}
|
||||
w={`${avatarSize}px`}
|
||||
h={`${avatarSize}px`}
|
||||
>
|
||||
{avatarUrl ? (
|
||||
<Image
|
||||
@@ -54,17 +55,17 @@ export function DriverIdentity(props: DriverIdentityProps) {
|
||||
|
||||
<Box flexGrow={1} minWidth="0">
|
||||
<Box display="flex" alignItems="center" gap={2} minWidth="0">
|
||||
<Text size={nameSize} weight="medium" color="text-white" className="truncate">
|
||||
<Text size={nameSize} weight="medium" color="text-white" truncate>
|
||||
{driver.name}
|
||||
</Text>
|
||||
{contextLabel && (
|
||||
<Badge variant="default" className="bg-charcoal-outline/60 text-[10px] md:text-xs">
|
||||
<Badge variant="default" bg="bg-charcoal-outline/60" size="xs">
|
||||
{contextLabel}
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
{meta && (
|
||||
<Text size="xs" color="text-gray-400" mt={0.5} className="truncate" block>
|
||||
<Text size="xs" color="text-gray-400" mt={0.5} truncate block>
|
||||
{meta}
|
||||
</Text>
|
||||
)}
|
||||
@@ -74,7 +75,7 @@ export function DriverIdentity(props: DriverIdentityProps) {
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="flex items-center gap-3 md:gap-4 flex-1 min-w-0">
|
||||
<Link href={href} block variant="ghost">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import React from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { Box } from '@/ui/Box';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Surface } from '@/ui/Surface';
|
||||
|
||||
@@ -5,7 +5,7 @@ import { ActivityFeedItem } from '@/components/feed/ActivityFeedItem';
|
||||
import { Icon } from '@/ui/Icon';
|
||||
import { Text } from '@/ui/Text';
|
||||
import { Stack } from '@/ui/Stack';
|
||||
import { processLeagueActivities } from '@/lib/services/league/LeagueActivityService';
|
||||
import { LeagueActivityService } from '@/lib/services/league/LeagueActivityService';
|
||||
|
||||
export type LeagueActivity =
|
||||
| { type: 'race_completed'; raceId: string; raceName: string; timestamp: Date }
|
||||
@@ -37,8 +37,10 @@ export function LeagueActivityFeed({ leagueId, limit = 10 }: LeagueActivityFeedP
|
||||
const { data: raceList = [], isLoading } = useLeagueRaces(leagueId);
|
||||
|
||||
const activities = useMemo(() => {
|
||||
if (isLoading || raceList.length === 0) return [];
|
||||
return processLeagueActivities(raceList, limit);
|
||||
if (isLoading || !Array.isArray(raceList) || raceList.length === 0) return [];
|
||||
const service = new LeagueActivityService();
|
||||
const result = service.processLeagueActivities(raceList, limit);
|
||||
return result.isOk() ? result.unwrap() : [];
|
||||
}, [raceList, isLoading, limit]);
|
||||
|
||||
if (isLoading) {
|
||||
|
||||
@@ -24,7 +24,6 @@ interface LeagueMemberRowProps {
|
||||
|
||||
export function LeagueMemberRow({
|
||||
driver,
|
||||
driverId,
|
||||
isCurrentUser,
|
||||
isTopPerformer,
|
||||
role,
|
||||
|
||||
@@ -44,16 +44,17 @@ export function LeagueMembers({
|
||||
const membershipData = leagueMembershipService.getLeagueMembers(leagueId);
|
||||
setMembers(membershipData);
|
||||
|
||||
const uniqueDriverIds = Array.from(new Set(membershipData.map((m) => m.driverId)));
|
||||
const uniqueDriverIds = Array.from(new Set(membershipData.map((m: LeagueMembership) => m.driverId)));
|
||||
if (uniqueDriverIds.length > 0) {
|
||||
const driverDtos = await driverService.findByIds(uniqueDriverIds);
|
||||
|
||||
const byId: Record<string, DriverViewModel> = {};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const dto of driverDtos as any[]) {
|
||||
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
|
||||
const result = await driverService.findByIds(uniqueDriverIds);
|
||||
if (result.isOk()) {
|
||||
const driverDtos = result.unwrap();
|
||||
const byId: Record<string, DriverViewModel> = {};
|
||||
for (const dto of driverDtos) {
|
||||
byId[dto.id] = new DriverViewModel({ ...dto, avatarUrl: dto.avatarUrl ?? null });
|
||||
}
|
||||
setDriversById(byId);
|
||||
}
|
||||
setDriversById(byId);
|
||||
} else {
|
||||
setDriversById({});
|
||||
}
|
||||
|
||||
@@ -50,8 +50,9 @@ export function LeagueSponsorshipsSection({
|
||||
const [tempPrice, setTempPrice] = useState<string>('');
|
||||
|
||||
// Load season ID if not provided
|
||||
const { data: seasons = [] } = useLeagueSeasons(leagueId);
|
||||
const activeSeason = seasons.find((s) => s.status === 'active') ?? seasons[0];
|
||||
const { data: seasonsResult } = useLeagueSeasons(leagueId);
|
||||
const seasons = seasonsResult?.isOk() ? seasonsResult.unwrap() : [];
|
||||
const activeSeason = seasons.find((s: any) => s.status === 'active') ?? seasons[0];
|
||||
const seasonId = propSeasonId || activeSeason?.seasonId;
|
||||
|
||||
// Load pending sponsorship requests
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import { DriverViewModel } from "@/lib/view-models/DriverViewModel";
|
||||
import { ProtestViewModel } from "@/lib/view-models/ProtestViewModel";
|
||||
import { RaceViewModel } from "@/lib/view-models/RaceViewModel";
|
||||
import { Box } from "@/ui/Box";
|
||||
import { Card } from "@/ui/Card";
|
||||
import { ProtestListItem } from "./ProtestListItem";
|
||||
@@ -12,17 +11,13 @@ import { Flag } from "lucide-react";
|
||||
|
||||
interface PendingProtestsListProps {
|
||||
protests: ProtestViewModel[];
|
||||
races: Record<string, RaceViewModel>;
|
||||
drivers: Record<string, DriverViewModel>;
|
||||
leagueId: string;
|
||||
onReviewProtest: (protest: ProtestViewModel) => void;
|
||||
onProtestReviewed: () => void;
|
||||
}
|
||||
|
||||
export function PendingProtestsList({
|
||||
protests,
|
||||
drivers,
|
||||
leagueId,
|
||||
onReviewProtest,
|
||||
}: PendingProtestsListProps) {
|
||||
|
||||
|
||||
@@ -57,10 +57,10 @@ export function ReviewProtestModal({
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [showConfirmation, setShowConfirmation] = useState(false);
|
||||
|
||||
const { data: penaltyTypesReference, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
|
||||
const { data: penaltyTypesReferenceResult, isLoading: penaltyTypesLoading } = usePenaltyTypesReference();
|
||||
|
||||
const penaltyOptions = useMemo(() => {
|
||||
const refs = penaltyTypesReference?.penaltyTypes ?? [];
|
||||
const refs = penaltyTypesReferenceResult?.isOk() ? penaltyTypesReferenceResult.unwrap().penaltyTypes : [];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return refs.map((ref: any) => ({
|
||||
type: ref.type as PenaltyType,
|
||||
@@ -71,7 +71,7 @@ export function ReviewProtestModal({
|
||||
Icon: getPenaltyIcon(ref.type),
|
||||
colorClass: getPenaltyColor(ref.type),
|
||||
}));
|
||||
}, [penaltyTypesReference]);
|
||||
}, [penaltyTypesReferenceResult]);
|
||||
|
||||
const selectedPenalty = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -99,7 +99,7 @@ export function ReviewProtestModal({
|
||||
}
|
||||
};
|
||||
|
||||
const getPenaltyIcon = (type: PenaltyType) => {
|
||||
function getPenaltyIcon(type: PenaltyType) {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return Clock;
|
||||
@@ -122,9 +122,9 @@ export function ReviewProtestModal({
|
||||
default:
|
||||
return AlertCircle;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getPenaltyName = (type: PenaltyType) => {
|
||||
function getPenaltyName(type: PenaltyType) {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "Time Penalty";
|
||||
@@ -147,9 +147,9 @@ export function ReviewProtestModal({
|
||||
default:
|
||||
return type.replaceAll("_", " ");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getPenaltyValueLabel = (valueKind: PenaltyValueKindDTO): string => {
|
||||
function getPenaltyValueLabel(valueKind: PenaltyValueKindDTO): string {
|
||||
switch (valueKind) {
|
||||
case "seconds":
|
||||
return "seconds";
|
||||
@@ -162,9 +162,9 @@ export function ReviewProtestModal({
|
||||
case "none":
|
||||
return "";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getPenaltyDefaultValue = (type: PenaltyType, valueKind: PenaltyValueKindDTO): number => {
|
||||
function getPenaltyDefaultValue(type: PenaltyType, valueKind: PenaltyValueKindDTO): number {
|
||||
if (type === "license_points") return 2;
|
||||
if (type === "race_ban") return 1;
|
||||
switch (valueKind) {
|
||||
@@ -179,9 +179,9 @@ export function ReviewProtestModal({
|
||||
case "none":
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getPenaltyColor = (type: PenaltyType) => {
|
||||
function getPenaltyColor(type: PenaltyType) {
|
||||
switch (type) {
|
||||
case "time_penalty":
|
||||
return "text-blue-400 bg-blue-500/10 border-blue-500/30";
|
||||
@@ -204,7 +204,7 @@ export function ReviewProtestModal({
|
||||
default:
|
||||
return "text-warning-amber bg-warning-amber/10 border-warning-amber/30";
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (showConfirmation) {
|
||||
return (
|
||||
@@ -351,7 +351,6 @@ export function ReviewProtestModal({
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
<Box borderTop borderColor="border-gray-800" pt={6}>
|
||||
<Stack gap={4}>
|
||||
<Heading level={3} fontSize="lg" weight="semibold" color="text-white">Stewarding Decision</Heading>
|
||||
@@ -471,4 +470,4 @@ export function ReviewProtestModal({
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -12,7 +12,15 @@ export function useCurrentSession(
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['currentSession'],
|
||||
queryFn: () => sessionService.getSession(),
|
||||
queryFn: async () => {
|
||||
const result = await sessionService.getSession();
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
const session = result.unwrap();
|
||||
return session ? new SessionViewModel(session.user) : null;
|
||||
},
|
||||
initialData: options?.initialData,
|
||||
...options,
|
||||
});
|
||||
|
||||
@@ -9,7 +9,13 @@ export function useLogout(
|
||||
const authService = useInject(AUTH_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, void>({
|
||||
mutationFn: () => authService.logout(),
|
||||
mutationFn: async () => {
|
||||
const result = await authService.logout();
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -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';
|
||||
@@ -3,17 +3,22 @@ import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
type DriverData = any; // Replace with actual type
|
||||
import type { DriverDTO } from '@/lib/types/generated/DriverDTO';
|
||||
|
||||
export function useCurrentDriver(
|
||||
options?: Omit<UseQueryOptions<DriverData, ApiError>, 'queryKey' | 'queryFn'>
|
||||
options?: Omit<UseQueryOptions<DriverDTO | null, ApiError>, 'queryKey' | 'queryFn'>
|
||||
) {
|
||||
const driverService = useInject(DRIVER_SERVICE_TOKEN);
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['currentDriver'],
|
||||
queryFn: () => driverService.getCurrentDriver(),
|
||||
queryFn: async () => {
|
||||
const result = await driverService.getCurrentDriver();
|
||||
if (result.isErr()) {
|
||||
throw result.getError();
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
||||
import { DriverProfileViewModel, type DriverProfileViewModelData } from '@/lib/view-models/DriverProfileViewModel';
|
||||
|
||||
export function useDriverProfile(
|
||||
driverId: string,
|
||||
@@ -13,10 +13,17 @@ export function useDriverProfile(
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['driverProfile', driverId],
|
||||
queryFn: () => driverService.getDriverProfile(driverId),
|
||||
queryFn: async () => {
|
||||
const result = await driverService.getDriverProfile(driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new globalThis.Date().toISOString() });
|
||||
}
|
||||
return new DriverProfileViewModel(result.unwrap() as unknown as DriverProfileViewModelData);
|
||||
},
|
||||
enabled: !!driverId,
|
||||
...options,
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,10 +13,17 @@ export function useFindDriverById(
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['driver', driverId],
|
||||
queryFn: () => driverService.findById(driverId),
|
||||
queryFn: async () => {
|
||||
const result = await driverService.findById(driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
enabled: !!driverId,
|
||||
...options,
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export function useLeagueMembershipMutation() {
|
||||
const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const joinLeagueMutation = useMutation({
|
||||
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
|
||||
leagueMembershipService.joinLeague(leagueId, driverId),
|
||||
mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
|
||||
const result = await leagueMembershipService.joinLeague(leagueId, driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
|
||||
@@ -16,8 +22,13 @@ export function useLeagueMembershipMutation() {
|
||||
});
|
||||
|
||||
const leaveLeagueMutation = useMutation({
|
||||
mutationFn: ({ leagueId, driverId }: { leagueId: string; driverId: string }) =>
|
||||
leagueMembershipService.leaveLeague(leagueId, driverId),
|
||||
mutationFn: async ({ leagueId, driverId }: { leagueId: string; driverId: string }) => {
|
||||
const result = await leagueMembershipService.leaveLeague(leagueId, driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['leagueMemberships'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['allLeagues'] });
|
||||
@@ -28,4 +39,4 @@ export function useLeagueMembershipMutation() {
|
||||
joinLeague: joinLeagueMutation,
|
||||
leaveLeague: leaveLeagueMutation,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,13 @@ export function useLeagueRosterAdmin(leagueId: string, options?: UseQueryOptions
|
||||
|
||||
return useQuery<LeagueRosterMemberDTO[], ApiError>({
|
||||
queryKey: ['league-roster-admin', leagueId],
|
||||
queryFn: () => leagueService.getAdminRosterMembers(leagueId),
|
||||
queryFn: async () => {
|
||||
const result = await leagueService.getAdminRosterMembers(leagueId);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -36,7 +42,13 @@ export function useLeagueJoinRequests(leagueId: string, options?: UseQueryOption
|
||||
|
||||
return useQuery<LeagueRosterJoinRequestDTO[], ApiError>({
|
||||
queryKey: ['league-join-requests', leagueId],
|
||||
queryFn: () => leagueService.getAdminRosterJoinRequests(leagueId),
|
||||
queryFn: async () => {
|
||||
const result = await leagueService.getAdminRosterJoinRequests(leagueId);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
@@ -48,7 +60,11 @@ export function useUpdateMemberRole(
|
||||
|
||||
return useMutation<{ success: boolean }, ApiError, UpdateMemberRoleInput>({
|
||||
mutationFn: async (input) => {
|
||||
return leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole);
|
||||
const result = await leagueService.updateMemberRole(input.leagueId, input.driverId, input.newRole);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -61,7 +77,11 @@ export function useRemoveMember(
|
||||
|
||||
return useMutation<{ success: boolean }, ApiError, RemoveMemberInput>({
|
||||
mutationFn: async (input) => {
|
||||
return leagueService.removeMember(input.leagueId, input.driverId);
|
||||
const result = await leagueService.removeMember(input.leagueId, input.driverId);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -74,7 +94,11 @@ export function useApproveJoinRequest(
|
||||
|
||||
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
|
||||
mutationFn: async (input) => {
|
||||
return leagueService.approveJoinRequest(input.leagueId, input.requestId);
|
||||
const result = await leagueService.approveJoinRequest(input.leagueId, input.requestId);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
@@ -87,8 +111,12 @@ export function useRejectJoinRequest(
|
||||
|
||||
return useMutation<{ success: boolean }, ApiError, JoinRequestActionInput>({
|
||||
mutationFn: async (input) => {
|
||||
return leagueService.rejectJoinRequest(input.leagueId, input.requestId);
|
||||
const result = await leagueService.rejectJoinRequest(input.leagueId, input.requestId);
|
||||
if (result.isErr()) {
|
||||
throw new ApiError(result.getError().message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,21 +3,19 @@ import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { LeagueScheduleViewModel, LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
|
||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
|
||||
function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
||||
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
||||
const now = new Date();
|
||||
const isPast = scheduledAt.getTime() < now.getTime();
|
||||
const isUpcoming = !isPast;
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
scheduledAt,
|
||||
isPast,
|
||||
isUpcoming,
|
||||
isUpcoming: !isPast,
|
||||
status: isPast ? 'completed' : 'scheduled',
|
||||
track: undefined,
|
||||
car: undefined,
|
||||
@@ -32,7 +30,11 @@ export function useLeagueSchedule(leagueId: string) {
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['leagueSchedule', leagueId],
|
||||
queryFn: async (): Promise<LeagueScheduleViewModel> => {
|
||||
const dto = await leagueService.getLeagueSchedule(leagueId);
|
||||
const result = await leagueService.getLeagueSchedule(leagueId);
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
const dto = result.unwrap();
|
||||
const races = dto.races.map(mapRaceDtoToViewModel);
|
||||
return new LeagueScheduleViewModel(races);
|
||||
},
|
||||
@@ -40,4 +42,4 @@ export function useLeagueSchedule(leagueId: string) {
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
||||
import { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel';
|
||||
import { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
||||
import { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel';
|
||||
import type { LeagueScheduleDTO } from '@/lib/types/generated/LeagueScheduleDTO';
|
||||
import type { RaceDTO } from '@/lib/types/generated/RaceDTO';
|
||||
import type { LeagueSeasonSummaryDTO } from '@/lib/types/generated/LeagueSeasonSummaryDTO';
|
||||
|
||||
@@ -13,14 +12,13 @@ function mapRaceDtoToViewModel(race: RaceDTO): LeagueScheduleRaceViewModel {
|
||||
const scheduledAt = race.date ? new Date(race.date) : new Date(0);
|
||||
const now = new Date();
|
||||
const isPast = scheduledAt.getTime() < now.getTime();
|
||||
const isUpcoming = !isPast;
|
||||
|
||||
return {
|
||||
id: race.id,
|
||||
name: race.name,
|
||||
scheduledAt,
|
||||
isPast,
|
||||
isUpcoming,
|
||||
isUpcoming: !isPast,
|
||||
status: isPast ? 'completed' : 'scheduled',
|
||||
track: undefined,
|
||||
car: undefined,
|
||||
@@ -49,7 +47,11 @@ export function useLeagueSeasons(leagueId: string, isAdmin: boolean) {
|
||||
return usePageData({
|
||||
queryKey: ['leagueSeasons', leagueId],
|
||||
queryFn: async (): Promise<LeagueSeasonSummaryViewModel[]> => {
|
||||
const dtos = await leagueService.getLeagueSeasonSummaries(leagueId);
|
||||
const result = await leagueService.getLeagueSeasonSummaries(leagueId);
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
const dtos = result.unwrap();
|
||||
return dtos.map((dto: LeagueSeasonSummaryDTO) => new LeagueSeasonSummaryViewModel(dto));
|
||||
},
|
||||
enabled: !!leagueId && !!isAdmin,
|
||||
@@ -62,7 +64,11 @@ export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: strin
|
||||
return usePageData({
|
||||
queryKey: ['adminSchedule', leagueId, selectedSeasonId],
|
||||
queryFn: async (): Promise<LeagueAdminScheduleViewModel> => {
|
||||
const dto = await leagueService.getAdminSchedule(leagueId, selectedSeasonId);
|
||||
const result = await leagueService.getAdminSchedule(leagueId, selectedSeasonId);
|
||||
if (result.isErr()) {
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
const dto = result.unwrap();
|
||||
const races = dto.races.map(mapRaceDtoToViewModel);
|
||||
return new LeagueAdminScheduleViewModel({
|
||||
seasonId: dto.seasonId,
|
||||
@@ -72,4 +78,4 @@ export function useLeagueAdminSchedule(leagueId: string, selectedSeasonId: strin
|
||||
},
|
||||
enabled: !!leagueId && !!selectedSeasonId && !!isAdmin,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { usePageMutation } from '@/lib/page/usePageData';
|
||||
|
||||
export function useLeagueStewardingMutations(onRefetch: () => void) {
|
||||
const acceptProtestMutation = usePageMutation(
|
||||
async (variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
|
||||
async (_variables: { protestId: string; penaltyType: string; penaltyValue: number; stewardNotes: string; raceId: string; accusedDriverId: string; reason: string }) => {
|
||||
// TODO: Implement protest review and penalty application
|
||||
// await leagueStewardingService.reviewProtest({
|
||||
// protestId: variables.protestId,
|
||||
@@ -28,7 +28,7 @@ export function useLeagueStewardingMutations(onRefetch: () => void) {
|
||||
);
|
||||
|
||||
const rejectProtestMutation = usePageMutation(
|
||||
async (variables: { protestId: string; stewardNotes: string }) => {
|
||||
async (_variables: { protestId: string; stewardNotes: string }) => {
|
||||
// TODO: Implement protest rejection
|
||||
// await leagueStewardingService.reviewProtest({
|
||||
// protestId: variables.protestId,
|
||||
|
||||
@@ -22,7 +22,7 @@ export function useLeagueWalletPageData(leagueId: string) {
|
||||
amount: t.amount,
|
||||
fee: t.fee,
|
||||
netAmount: t.netAmount,
|
||||
date: new Date(t.date),
|
||||
date: new globalThis.Date(t.date),
|
||||
status: t.status,
|
||||
reference: t.reference,
|
||||
}));
|
||||
@@ -48,6 +48,6 @@ export function useLeagueWalletPageData(leagueId: string) {
|
||||
* @deprecated Use useLeagueWalletWithdrawalWithBlockers instead
|
||||
* This wrapper maintains backward compatibility while using the new blocker-aware hook
|
||||
*/
|
||||
export function useLeagueWalletWithdrawal(leagueId: string, data: any, refetch: () => void) {
|
||||
export function useLeagueWalletWithdrawal(leagueId: string, data: LeagueWalletViewModel | null, refetch: () => void) {
|
||||
return useLeagueWalletWithdrawalWithBlockers(leagueId, data, refetch);
|
||||
}
|
||||
@@ -3,13 +3,15 @@
|
||||
import { usePageMutation } from '@/lib/page/usePageData';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { SubmitBlocker, ThrottleBlocker } from '@/lib/blockers';
|
||||
import { SubmitBlocker } from '@/lib/blockers/SubmitBlocker';
|
||||
import { ThrottleBlocker } from '@/lib/blockers/ThrottleBlocker';
|
||||
import type { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel';
|
||||
|
||||
/**
|
||||
* Hook for wallet withdrawals with client-side blockers
|
||||
* Handles UX prevention mechanisms (rate limiting, duplicate submission prevention)
|
||||
*/
|
||||
export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: any, refetch: () => void) {
|
||||
export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: LeagueWalletViewModel | null, refetch: () => void) {
|
||||
const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN);
|
||||
|
||||
// Client-side blockers for UX improvement
|
||||
@@ -18,11 +20,11 @@ export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: an
|
||||
|
||||
const withdrawMutation = usePageMutation(
|
||||
async ({ amount }: { amount: number }) => {
|
||||
if (!data) throw new Error('Wallet data not available');
|
||||
if (!data) throw new globalThis.Error('Wallet data not available');
|
||||
|
||||
// Client-side blockers (UX only, not security)
|
||||
if (!submitBlocker.canExecute() || !throttle.canExecute()) {
|
||||
throw new Error('Request blocked due to rate limiting');
|
||||
throw new globalThis.Error('Request blocked due to rate limiting');
|
||||
}
|
||||
|
||||
submitBlocker.block();
|
||||
@@ -38,7 +40,7 @@ export function useLeagueWalletWithdrawalWithBlockers(leagueId: string, data: an
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.message || 'Withdrawal failed');
|
||||
throw new globalThis.Error(result.message || 'Withdrawal failed');
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@@ -7,7 +7,7 @@ export function usePenaltyMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const applyPenaltyMutation = useMutation({
|
||||
mutationFn: (command: any) => penaltyService.applyPenalty(command),
|
||||
mutationFn: (command: unknown) => penaltyService.applyPenalty(command),
|
||||
onSuccess: () => {
|
||||
// Invalidate relevant queries to refresh data
|
||||
queryClient.invalidateQueries({ queryKey: ['leagueStewardingData'] });
|
||||
|
||||
@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { LEAGUE_STEWARDING_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export function useProtestDetail(leagueId: string, protestId: string, enabled: boolean = true) {
|
||||
const leagueStewardingService = useInject(LEAGUE_STEWARDING_SERVICE_TOKEN);
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['protestDetail', leagueId, protestId],
|
||||
queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId),
|
||||
queryFn: async () => {
|
||||
const result = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
enabled: enabled && !!leagueId && !!protestId,
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { OnboardingService } from '@/lib/services/onboarding/OnboardingService';
|
||||
import { Result } from '@/lib/contracts/Result';
|
||||
import { DomainError } from '@/lib/contracts/services/Service';
|
||||
|
||||
@@ -21,8 +20,7 @@ export function useGenerateAvatars(
|
||||
options?: Omit<UseMutationOptions<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>, 'mutationFn'>
|
||||
) {
|
||||
return useMutation<Result<GenerateAvatarsResult, DomainError>, Error, GenerateAvatarsParams>({
|
||||
mutationFn: async (params) => {
|
||||
const service = new OnboardingService();
|
||||
mutationFn: async (_params) => {
|
||||
// This method doesn't exist in the service yet, but the hook is now created
|
||||
// The service will need to implement this or we need to adjust the architecture
|
||||
return Result.ok({ success: false, errorMessage: 'Not implemented' });
|
||||
|
||||
@@ -10,7 +10,13 @@ export function useFileProtest(
|
||||
const raceService = useInject(RACE_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, FileProtestCommandDTO>({
|
||||
mutationFn: (command) => raceService.fileProtest(command),
|
||||
mutationFn: async (command) => {
|
||||
const result = await raceService.fileProtest(command);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,13 @@ export function useRegisterForRace(
|
||||
const raceService = useInject(RACE_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, RegisterForRaceParams>({
|
||||
mutationFn: (params) => raceService.registerForRace(params.raceId, params.leagueId, params.driverId),
|
||||
mutationFn: async (params) => {
|
||||
const result = await raceService.registerForRace(params.raceId, params.leagueId, params.driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,13 @@ export function useWithdrawFromRace(
|
||||
const raceService = useInject(RACE_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, WithdrawFromRaceParams>({
|
||||
mutationFn: (params) => raceService.withdrawFromRace(params.raceId, params.driverId),
|
||||
mutationFn: async (params) => {
|
||||
const result = await raceService.withdrawFromRace(params.raceId, params.driverId);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export { useAvailableLeagues } from './useAvailableLeagues';
|
||||
export { useSponsorDashboard } from './useSponsorDashboard';
|
||||
export { useSponsorSponsorships } from './useSponsorSponsorships';
|
||||
export { useSponsorBilling } from './useSponsorBilling';
|
||||
export { useSponsorLeagueDetail } from './useSponsorLeagueDetail';
|
||||
@@ -11,7 +11,7 @@ export function useSponsorBilling(sponsorId: string) {
|
||||
queryFn: async () => {
|
||||
const result = await sponsorService.getBilling(sponsorId);
|
||||
if (result.isErr()) {
|
||||
throw result.getError();
|
||||
throw new Error(result.getError().message);
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
@@ -19,4 +19,4 @@ export function useSponsorBilling(sponsorId: string) {
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useAuth } from '@/lib/auth/AuthContext';
|
||||
import React from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useSponsorMode(): boolean {
|
||||
const { session } = useAuth();
|
||||
const [isSponsor, setIsSponsor] = React.useState(false);
|
||||
const [isSponsor, setIsSponsor] = useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
setIsSponsor(false);
|
||||
return;
|
||||
|
||||
@@ -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';
|
||||
@@ -7,7 +7,13 @@ export function useApproveJoinRequest(options?: Omit<UseMutationOptions<void, Ap
|
||||
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, void>({
|
||||
mutationFn: () => teamJoinService.approveJoinRequest(),
|
||||
mutationFn: async () => {
|
||||
const result = await teamJoinService.approveJoinRequest();
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
interface JoinTeamParams {
|
||||
@@ -10,8 +8,6 @@ interface JoinTeamParams {
|
||||
}
|
||||
|
||||
export function useJoinTeam(options?: Omit<UseMutationOptions<void, ApiError, JoinTeamParams>, 'mutationFn'>) {
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, JoinTeamParams>({
|
||||
mutationFn: async (params) => {
|
||||
// Note: Team join functionality would need to be added to teamService
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useMutation, UseMutationOptions } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
interface LeaveTeamParams {
|
||||
@@ -9,8 +7,6 @@ interface LeaveTeamParams {
|
||||
}
|
||||
|
||||
export function useLeaveTeam(options?: Omit<UseMutationOptions<void, ApiError, LeaveTeamParams>, 'mutationFn'>) {
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, LeaveTeamParams>({
|
||||
mutationFn: async (params) => {
|
||||
// Note: Leave team functionality would need to be added to teamService
|
||||
|
||||
@@ -7,7 +7,13 @@ export function useRejectJoinRequest(options?: Omit<UseMutationOptions<void, Api
|
||||
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
|
||||
|
||||
return useMutation<void, ApiError, void>({
|
||||
mutationFn: () => teamJoinService.rejectJoinRequest(),
|
||||
mutationFn: async () => {
|
||||
const result = await teamJoinService.rejectJoinRequest();
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
},
|
||||
...options,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,23 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { TEAM_JOIN_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import { ApiError } from '@/lib/api/base/ApiError';
|
||||
|
||||
export function useTeamJoinRequests(teamId: string, currentUserId: string, isOwner: boolean) {
|
||||
const teamJoinService = useInject(TEAM_JOIN_SERVICE_TOKEN);
|
||||
|
||||
const queryResult = useQuery({
|
||||
queryKey: ['teamJoinRequests', teamId, currentUserId, isOwner],
|
||||
queryFn: () => teamJoinService.getJoinRequests(teamId, currentUserId, isOwner),
|
||||
queryFn: async () => {
|
||||
const result = await teamJoinService.getJoinRequests(teamId, currentUserId, isOwner);
|
||||
if (result.isErr()) {
|
||||
const error = result.getError();
|
||||
throw new ApiError(error.message, 'SERVER_ERROR', { timestamp: new Date().toISOString() });
|
||||
}
|
||||
return result.unwrap();
|
||||
},
|
||||
enabled: !!teamId && !!currentUserId,
|
||||
});
|
||||
|
||||
return enhanceQueryResult(queryResult);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { useInject } from '@/lib/di/hooks/useInject';
|
||||
import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens';
|
||||
import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError';
|
||||
import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO';
|
||||
|
||||
export function useTeamMembership(teamId: string, driverId: string) {
|
||||
const teamService = useInject(TEAM_SERVICE_TOKEN);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user