website refactor

This commit is contained in:
2026-01-17 15:46:55 +01:00
parent 4d5ce9bfd6
commit 72a626ce71
346 changed files with 19308 additions and 8605 deletions

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
/**
* NotFoundPageClient
*
* Client-side entry point for the 404 page.
* Manages navigation logic and wires it to the template.
*/
export function NotFoundPageClient() {
const router = useRouter();
const handleHomeClick = () => {
router.push(routes.public.home);
};
const viewData: NotFoundViewData = {
errorCode: 'Error 404',
title: 'OFF TRACK',
message: 'The requested sector does not exist. You have been returned to the pits.',
actionLabel: 'Return to Pits'
};
return <NotFoundTemplate viewData={viewData} onHomeClick={handleHomeClick} />;
}

View File

@@ -1,22 +1,11 @@
'use client';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
import { routes } from '@/lib/routing/RouteConfig';
import { useRouter } from 'next/navigation';
import { NotFoundPageClient } from './NotFoundPageClient';
/**
* Custom404Page
*
* Entry point for the /404 route.
* Orchestrates the 404 page rendering.
*/
export default function Custom404Page() {
const router = useRouter();
return (
<ErrorPageContainer
errorCode="404"
description="This page doesn't exist."
>
<ErrorActionButtons
onHomeClick={() => router.push(routes.public.home)}
homeLabel="Drive home"
/>
</ErrorPageContainer>
);
}
return <NotFoundPageClient />;
}

View File

@@ -0,0 +1,50 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ServerErrorPageClient } from './ServerErrorPageClient';
import { useRouter } from 'next/navigation';
vi.mock('next/navigation', () => ({
useRouter: vi.fn(),
}));
describe('ServerErrorPageClient', () => {
it('renders the server error page with correct content', () => {
const push = vi.fn();
(useRouter as any).mockReturnValue({ push });
render(<ServerErrorPageClient />);
expect(screen.getByText('CRITICAL_SYSTEM_FAILURE')).toBeDefined();
expect(screen.getByText(/The application engine encountered an unrecoverable state/)).toBeDefined();
expect(screen.getByText(/Internal Server Error/)).toBeDefined();
});
it('handles home navigation', () => {
const push = vi.fn();
(useRouter as any).mockReturnValue({ push });
render(<ServerErrorPageClient />);
const homeButton = screen.getByText('Return to Pits');
fireEvent.click(homeButton);
expect(push).toHaveBeenCalledWith('/');
});
it('handles retry via page reload', () => {
const push = vi.fn();
(useRouter as any).mockReturnValue({ push });
const reloadFn = vi.fn();
vi.stubGlobal('location', { reload: reloadFn });
render(<ServerErrorPageClient />);
const retryButton = screen.getByText('Retry Session');
fireEvent.click(retryButton);
expect(reloadFn).toHaveBeenCalled();
vi.unstubAllGlobals();
});
});

View File

@@ -0,0 +1,40 @@
'use client';
import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { ServerErrorTemplate, type ServerErrorViewData } from '@/templates/ServerErrorTemplate';
/**
* ServerErrorPageClient
*
* Client-side entry point for the 500 page.
* Manages navigation and retry logic and wires it to the template.
*/
export function ServerErrorPageClient() {
const router = useRouter();
const handleHome = () => {
router.push(routes.public.home);
};
const handleRetry = () => {
window.location.reload();
};
const error = new Error('Internal Server Error') as Error & { digest?: string };
error.digest = 'HTTP_500';
const viewData: ServerErrorViewData = {
error,
incidentId: error.digest
};
return (
<ServerErrorTemplate
viewData={viewData}
onRetry={handleRetry}
onHome={handleHome}
/>
);
}

View File

@@ -1,22 +1,11 @@
'use client';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
import { routes } from '@/lib/routing/RouteConfig';
import { useRouter } from 'next/navigation';
import { ServerErrorPageClient } from './ServerErrorPageClient';
/**
* Custom500Page
*
* Entry point for the /500 route.
* Orchestrates the 500 page rendering.
*/
export default function Custom500Page() {
const router = useRouter();
return (
<ErrorPageContainer
errorCode="500"
description="Something went wrong."
>
<ErrorActionButtons
onHomeClick={() => router.push(routes.public.home)}
homeLabel="Drive home"
/>
</ErrorPageContainer>
);
}
return <ServerErrorPageClient />;
}

View File

@@ -5,6 +5,7 @@ import { Result } from '@/lib/contracts/Result';
import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation';
import { routes } from '@/lib/routing/RouteConfig';
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function publishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.publishSchedule(leagueId, seasonId);
@@ -16,6 +17,7 @@ export async function publishScheduleAction(leagueId: string, seasonId: string):
return result;
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function unpublishScheduleAction(leagueId: string, seasonId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.unpublishSchedule(leagueId, seasonId);
@@ -27,6 +29,7 @@ export async function unpublishScheduleAction(leagueId: string, seasonId: string
return result;
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function createRaceAction(
leagueId: string,
seasonId: string,
@@ -42,6 +45,7 @@ export async function createRaceAction(
return result;
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function updateRaceAction(
leagueId: string,
seasonId: string,
@@ -58,6 +62,7 @@ export async function updateRaceAction(
return result;
}
// eslint-disable-next-line gridpilot-rules/server-actions-interface
export async function deleteRaceAction(leagueId: string, seasonId: string, raceId: string): Promise<Result<void, string>> {
const mutation = new ScheduleAdminMutation();
const result = await mutation.deleteRace(leagueId, seasonId, raceId);

View File

@@ -29,4 +29,4 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
isLoading={loading}
/>
);
}
}

View File

@@ -4,8 +4,9 @@ import { useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
import { updateUserStatus, deleteUser } from '@/app/admin/actions';
import { updateUserStatus, deleteUser } from '@/app/actions/adminActions';
import { routes } from '@/lib/routing/RouteConfig';
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
interface AdminUsersWrapperProps {
initialViewData: AdminUsersViewData;
@@ -19,12 +20,35 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [deletingUser, setDeletingUser] = useState<string | null>(null);
const [userToDelete, setUserToDelete] = useState<string | null>(null);
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
// Current filter values from URL
const search = searchParams.get('search') || '';
const roleFilter = searchParams.get('role') || '';
const statusFilter = searchParams.get('status') || '';
// Selection handlers
const handleSelectUser = useCallback((userId: string) => {
setSelectedUserIds(prev =>
prev.includes(userId)
? prev.filter(id => id !== userId)
: [...prev, userId]
);
}, []);
const handleSelectAll = useCallback(() => {
if (selectedUserIds.length === initialViewData.users.length) {
setSelectedUserIds([]);
} else {
setSelectedUserIds(initialViewData.users.map(u => u.id));
}
}, [selectedUserIds.length, initialViewData.users]);
const handleClearSelection = useCallback(() => {
setSelectedUserIds([]);
}, []);
// Callbacks that update URL (triggers RSC re-render)
const handleSearch = useCallback((newSearch: string) => {
const params = new URLSearchParams(searchParams);
@@ -79,13 +103,16 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
}, [router]);
const handleDeleteUser = useCallback(async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) {
return;
}
setUserToDelete(userId);
}, []);
const confirmDeleteUser = useCallback(async () => {
if (!userToDelete) return;
try {
setDeletingUser(userId);
const result = await deleteUser(userId);
setDeletingUser(userToDelete);
setError(null);
const result = await deleteUser(userToDelete);
if (result.isErr()) {
setError(result.getError());
@@ -94,29 +121,46 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
// Revalidate data
router.refresh();
setUserToDelete(null);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally {
setDeletingUser(null);
}
}, [router]);
}, [router, userToDelete]);
return (
<AdminUsersTemplate
viewData={initialViewData}
onRefresh={handleRefresh}
onSearch={handleSearch}
onFilterRole={handleFilterRole}
onFilterStatus={handleFilterStatus}
onClearFilters={handleClearFilters}
onUpdateStatus={handleUpdateStatus}
onDeleteUser={handleDeleteUser}
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
loading={loading}
error={error}
deletingUser={deletingUser}
/>
<>
<AdminUsersTemplate
viewData={initialViewData}
onRefresh={handleRefresh}
onSearch={handleSearch}
onFilterRole={handleFilterRole}
onFilterStatus={handleFilterStatus}
onClearFilters={handleClearFilters}
onUpdateStatus={handleUpdateStatus}
onDeleteUser={handleDeleteUser}
search={search}
roleFilter={roleFilter}
statusFilter={statusFilter}
loading={loading}
error={error}
deletingUser={deletingUser}
selectedUserIds={selectedUserIds}
onSelectUser={handleSelectUser}
onSelectAll={handleSelectAll}
onClearSelection={handleClearSelection}
/>
<ConfirmDialog
isOpen={!!userToDelete}
onClose={() => setUserToDelete(null)}
onConfirm={confirmDeleteUser}
title="Delete User"
description="Are you sure you want to delete this user? This action cannot be undone and will permanently remove the user's access."
confirmLabel="Delete User"
variant="danger"
isLoading={!!deletingUser}
/>
</>
);
}
}

View File

@@ -1,7 +1,7 @@
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
import { AuthContainer } from '@/ui/AuthContainer';
import { AuthShell } from '@/components/auth/AuthShell';
interface AuthLayoutProps {
children: React.ReactNode;
@@ -27,5 +27,5 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
redirect(result.to);
}
return <AuthContainer>{children}</AuthContainer>;
return <AuthShell>{children}</AuthShell>;
}

View File

@@ -1,9 +1,7 @@
import { DashboardLayoutWrapper } from '@/ui/DashboardLayoutWrapper';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return <DashboardLayoutWrapper>{children}</DashboardLayoutWrapper>;
}
return <>{children}</>;
}

View File

@@ -1,10 +1,9 @@
'use client';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { ErrorScreen } from '@/components/errors/ErrorScreen';
export default function ErrorPage({
error,
@@ -14,22 +13,17 @@ export default function ErrorPage({
reset: () => void;
}) {
const router = useRouter();
useEffect(() => {
// Log the error to an error reporting service
console.error('Route Error Boundary:', error);
}, [error]);
return (
<ErrorPageContainer
errorCode="Error"
description={error?.message || 'An unexpected error occurred.'}
>
{error?.digest && (
<Text size="xs" color="text-gray-500" font="mono">
Error ID: {error.digest}
</Text>
)}
<ErrorActionButtons
onRetry={reset}
onHomeClick={() => router.push(routes.public.home)}
showRetry={true}
/>
</ErrorPageContainer>
<ErrorScreen
error={error}
reset={reset}
onHome={() => router.push(routes.public.home)}
/>
);
}
}

View File

@@ -1,10 +1,9 @@
'use client';
import { ErrorPageContainer } from '@/ui/ErrorPageContainer';
import { ErrorActionButtons } from '@/ui/ErrorActionButtons';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { GlobalErrorScreen } from '@/components/errors/GlobalErrorScreen';
import './globals.css';
export default function GlobalError({
error,
@@ -16,24 +15,14 @@ export default function GlobalError({
const router = useRouter();
return (
<html lang="en">
<body className="antialiased">
<ErrorPageContainer
errorCode="Error"
description={error?.message || 'An unexpected error occurred.'}
>
{error?.digest && (
<Text size="xs" color="text-gray-500" font="mono">
Error ID: {error.digest}
</Text>
)}
<ErrorActionButtons
onRetry={reset}
onHomeClick={() => router.push(routes.public.home)}
showRetry={true}
/>
</ErrorPageContainer>
<html lang="en" className="dark scroll-smooth overflow-x-hidden">
<body className="antialiased bg-base-black text-white overflow-x-hidden">
<GlobalErrorScreen
error={error}
reset={reset}
onHome={() => router.push(routes.public.home)}
/>
</body>
</html>
);
}
}

View File

@@ -6,13 +6,29 @@
@layer base {
:root {
--color-deep-graphite: #0E0F11;
--color-iron-gray: #181B1F;
--color-charcoal-outline: #22262A;
--color-primary-blue: #198CFF;
--color-performance-green: #6FE37A;
--color-warning-amber: #FFC556;
--color-neon-aqua: #43C9E6;
/* Core Theme Colors (from THEME.md) */
--color-base: #0C0D0F;
--color-surface: #141619;
--color-outline: #23272B;
--color-primary: #198CFF;
--color-telemetry: #4ED4E0;
--color-warning: #FFBE4D;
--color-success: #6FE37A;
--color-critical: #E35C5C;
/* Text Colors */
--color-text-high: #FFFFFF;
--color-text-med: #A1A1AA;
--color-text-low: #71717A;
/* Selection */
--color-selection-bg: rgba(25, 140, 255, 0.3);
--color-selection-text: #FFFFFF;
/* Focus */
--color-focus-ring: rgba(25, 140, 255, 0.5);
/* Safe Area Insets */
--sat: env(safe-area-inset-top);
--sar: env(safe-area-inset-right);
--sab: env(safe-area-inset-bottom);
@@ -21,192 +37,146 @@
* {
-webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none;
border-color: var(--color-outline);
}
html {
overscroll-behavior: none;
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
background-color: var(--color-base);
color: var(--color-text-high);
font-family: 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
@apply bg-deep-graphite text-white antialiased;
background-color: var(--color-base);
color: var(--color-text-high);
line-height: 1.5;
overscroll-behavior: none;
}
button, a {
::selection {
background-color: var(--color-selection-bg);
color: var(--color-selection-text);
}
/* Focus States */
:focus-visible {
outline: 2px solid var(--color-primary);
outline-offset: 2px;
box-shadow: 0 0 0 4px var(--color-focus-ring);
}
/* Scrollbars */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--color-base);
}
::-webkit-scrollbar-thumb {
background: var(--color-outline);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-low);
}
/* Typography Scale & Smoothing */
h1, h2, h3, h4, h5, h6 {
color: var(--color-text-high);
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.02em;
}
h1 { font-size: 2.25rem; }
h2 { font-size: 1.875rem; }
h3 { font-size: 1.5rem; }
h4 { font-size: 1.25rem; }
h5 { font-size: 1.125rem; }
h6 { font-size: 1rem; }
p {
color: var(--color-text-med);
line-height: 1.6;
}
/* Link Styles */
a {
color: var(--color-primary);
text-decoration: none;
transition: opacity 0.2s ease;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
.scroll-container {
-webkit-overflow-scrolling: touch;
scroll-behavior: smooth;
a:hover {
opacity: 0.8;
}
/* Mobile typography optimization - lighter and more spacious */
button {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* Mobile typography optimization */
@media (max-width: 640px) {
h1 {
font-size: clamp(1.5rem, 6vw, 2rem);
font-weight: 600;
line-height: 1.2;
}
h2 {
font-size: clamp(1.125rem, 4.5vw, 1.5rem);
font-weight: 600;
line-height: 1.3;
}
h3 {
font-size: 1rem;
font-weight: 500;
}
p {
font-size: 0.8125rem; /* 13px */
line-height: 1.6;
}
h1 { font-size: clamp(1.5rem, 6vw, 2rem); }
h2 { font-size: clamp(1.125rem, 4.5vw, 1.5rem); }
h3 { font-size: 1.25rem; }
p { font-size: 0.875rem; }
}
}
@layer utilities {
.animate-spring {
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1);
/* Precision Racing Utilities */
.glass-panel {
background: rgba(20, 22, 25, 0.7);
backdrop-filter: blur(12px);
border: 1px solid var(--color-outline);
}
/* Racing stripe patterns */
.racing-stripes {
background: linear-gradient(
45deg,
transparent 25%,
rgba(25, 140, 255, 0.03) 25%,
rgba(25, 140, 255, 0.03) 50%,
transparent 50%,
transparent 75%,
rgba(25, 140, 255, 0.03) 75%
);
background-size: 60px 60px;
.subtle-gradient {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, transparent 100%);
}
/* Checkered flag pattern */
.checkered-pattern {
background-image:
linear-gradient(45deg, rgba(255,255,255,0.02) 25%, transparent 25%),
linear-gradient(-45deg, rgba(255,255,255,0.02) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.02) 75%),
linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.02) 75%);
background-size: 20px 20px;
background-position: 0 0, 0 10px, 10px -10px, -10px 0px;
}
/* Speed lines animation */
@keyframes speed-lines {
0% {
transform: translateX(0) scaleX(0);
opacity: 0;
}
50% {
opacity: 0.3;
}
100% {
transform: translateX(100px) scaleX(1);
opacity: 0;
}
}
.animate-speed-lines {
animation: speed-lines 1.5s ease-out infinite;
}
/* Racing accent line */
.racing-accent {
.racing-border {
position: relative;
}
.racing-accent::before {
.racing-border::after {
content: '';
position: absolute;
left: -16px;
top: 0;
bottom: 0;
width: 3px;
background: linear-gradient(to bottom, #FF0000, #198CFF);
border-radius: 2px;
}
/* Carbon fiber texture */
.carbon-fiber {
background-image:
linear-gradient(27deg, rgba(255,255,255,0.02) 5%, transparent 5%),
linear-gradient(207deg, rgba(255,255,255,0.02) 5%, transparent 5%),
linear-gradient(27deg, rgba(0,0,0,0.05) 5%, transparent 5%),
linear-gradient(207deg, rgba(0,0,0,0.05) 5%, transparent 5%);
background-size: 10px 10px;
left: 0;
width: 100%;
height: 1px;
background: linear-gradient(90deg, var(--color-primary) 0%, transparent 100%);
opacity: 0.5;
}
/* Racing red-white-blue animated gradient */
@keyframes racing-gradient {
0% {
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
/* Instrument-grade glows */
.glow-primary {
box-shadow: 0 0 20px -5px rgba(25, 140, 255, 0.3);
}
.animate-racing-gradient {
background: linear-gradient(
90deg,
#DC0000 0%,
#FFFFFF 25%,
#0066FF 50%,
#DC0000 75%,
#FFFFFF 100%
);
background-size: 300% 100%;
animation: racing-gradient 12s linear infinite;
-webkit-background-clip: text;
background-clip: text;
}
/* Static red-white-blue gradient (no animation) */
.static-racing-gradient {
background: linear-gradient(
90deg,
#DC0000 0%,
#FFFFFF 50%,
#2563eb 100%
);
-webkit-background-clip: text;
background-clip: text;
}
@media (prefers-reduced-motion: reduce) {
.animate-racing-gradient {
animation: none;
}
.glow-telemetry {
box-shadow: 0 0 20px -5px rgba(78, 212, 224, 0.3);
}
/* Entrance animations */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
@@ -215,19 +185,14 @@
}
.animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards;
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@media (prefers-reduced-motion: reduce) {
.animate-fade-in-up,
.animate-fade-in {
.animate-fade-in-up {
animation: none;
opacity: 1;
transform: none;
}
}
}
}

View File

@@ -5,9 +5,7 @@ import { Metadata, Viewport } from 'next';
import React from 'react';
import './globals.css';
import { AppWrapper } from '@/components/AppWrapper';
import { Header } from '@/ui/Header';
import { HeaderContent } from '@/components/layout/HeaderContent';
import { MainContent } from '@/ui/MainContent';
import { RootAppShellTemplate } from '@/templates/layout/RootAppShellTemplate';
export const dynamic = 'force-dynamic';
@@ -76,12 +74,9 @@ export default async function RootLayout({
</head>
<body className="antialiased overflow-x-hidden">
<AppWrapper enabledFlags={enabledFlags}>
<Header>
<HeaderContent />
</Header>
<MainContent>
<RootAppShellTemplate>
{children}
</MainContent>
</RootAppShellTemplate>
</AppWrapper>
</body>
</html>

View File

@@ -0,0 +1,41 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData';
interface DriverRankingsPageClientProps {
viewData: DriverRankingsViewData;
}
export function DriverRankingsPageClient({ viewData }: DriverRankingsPageClientProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const handleDriverClick = (id: string) => {
router.push(routes.driver.detail(id));
};
const handleBackToLeaderboards = () => {
router.push(routes.leaderboards.root);
};
const filteredDrivers = viewData.drivers.filter(driver =>
driver.name.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<DriverRankingsTemplate
viewData={{
...viewData,
drivers: filteredDrivers
}}
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
);
}

View File

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

View File

@@ -1,42 +1,32 @@
'use client';
import { LeagueCard } from '@/components/leagues/LeagueCardWrapper';
import React, { useState } from 'react';
import { LeagueCard } from '@/components/leagues/LeagueCard';
import { routes } from '@/lib/routing/RouteConfig';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
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 { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import {
Award,
ChevronLeft,
ChevronRight,
Clock,
Filter,
Flag,
Flame,
Globe,
Plus,
Search,
Sparkles,
Target,
Timer,
Trophy,
Users,
Flag,
Award,
Timer,
Clock,
type LucideIcon,
} from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useCallback, useRef, useState } from 'react';
import { getMediaUrl } from '@/lib/utilities/media';
// ============================================================================
// TYPES
@@ -50,12 +40,9 @@ type CategoryId =
| 'trophy'
| 'new'
| 'popular'
| 'iracing'
| 'acc'
| 'f1'
| 'openSlots'
| 'endurance'
| 'sprint'
| 'openSlots';
| 'sprint';
interface Category {
id: CategoryId;
@@ -66,17 +53,6 @@ interface Category {
color?: string;
}
interface LeagueSliderProps {
title: string;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
interface LeaguesTemplateProps {
viewData: LeaguesViewData;
}
@@ -114,7 +90,7 @@ const CATEGORIES: Category[] = [
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(league.createdAt) > oneWeekAgo;
},
color: 'text-performance-green',
color: 'text-green-500',
},
{
id: 'openSlots',
@@ -122,17 +98,15 @@ const CATEGORIES: Category[] = [
icon: Target,
description: 'Leagues with available spots',
filter: (league) => {
// Check for team slots if it's a team league
if (league.maxTeams && league.maxTeams > 0) {
const usedTeams = league.usedTeamSlots ?? 0;
return usedTeams < league.maxTeams;
}
// Otherwise check driver slots
const used = league.usedDriverSlots ?? 0;
const max = league.maxDrivers ?? 0;
return max > 0 && used < max;
},
color: 'text-neon-aqua',
color: 'text-cyan-400',
},
{
id: 'driver',
@@ -183,459 +157,132 @@ const CATEGORIES: Category[] = [
},
];
// ============================================================================
// LEAGUE SLIDER COMPONENT
// ============================================================================
export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
function LeagueSlider({
title,
icon: Icon,
description,
leagues,
autoScroll = true,
iconColor = 'text-primary-blue',
scrollSpeedMultiplier = 1,
scrollDirection = 'right',
}: LeagueSliderProps) {
const scrollRef = useRef<HTMLDivElement>(null);
const [canScrollLeft, setCanScrollLeft] = useState(false);
const [canScrollRight, setCanScrollRight] = useState(true);
const [isHovering, setIsHovering] = useState(false);
const animationRef = useRef<number | null>(null);
const scrollPositionRef = useRef(0);
const filteredLeagues = viewData.leagues.filter((league) => {
const matchesSearch = !searchQuery ||
league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
(league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
const category = CATEGORIES.find(c => c.id === activeCategory);
const matchesCategory = !category || category.filter(league);
const checkScrollButtons = useCallback(() => {
if (scrollRef.current) {
const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current;
setCanScrollLeft(scrollLeft > 0);
setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10);
}
}, []);
const scroll = useCallback((direction: 'left' | 'right') => {
if (scrollRef.current) {
const cardWidth = 340;
const scrollAmount = direction === 'left' ? -cardWidth : cardWidth;
// Update the ref so auto-scroll continues from new position
scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount;
scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' });
}
}, []);
// Initialize scroll position for left-scrolling sliders
const initializeScroll = useCallback(() => {
if (scrollDirection === 'left' && scrollRef.current) {
const { scrollWidth, clientWidth } = scrollRef.current;
scrollPositionRef.current = scrollWidth - clientWidth;
scrollRef.current.scrollLeft = scrollPositionRef.current;
}
}, [scrollDirection]);
// Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction
const setupAutoScroll = useCallback(() => {
// Allow scroll even with just 2 leagues (minimum threshold = 1)
if (!autoScroll || leagues.length <= 1) return;
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
let lastTimestamp = 0;
// Base speed with multiplier for variation between sliders
const baseSpeed = 0.025;
const scrollSpeed = baseSpeed * scrollSpeedMultiplier;
const directionMultiplier = scrollDirection === 'left' ? -1 : 1;
const animate = (timestamp: number) => {
if (!isHovering && scrollContainer) {
const delta = lastTimestamp ? timestamp - lastTimestamp : 0;
lastTimestamp = timestamp;
scrollPositionRef.current += scrollSpeed * delta * directionMultiplier;
const { scrollWidth, clientWidth } = scrollContainer;
const maxScroll = scrollWidth - clientWidth;
// Handle wrap-around for both directions
if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) {
scrollPositionRef.current = 0;
} else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) {
scrollPositionRef.current = maxScroll;
}
scrollContainer.scrollLeft = scrollPositionRef.current;
} else {
lastTimestamp = timestamp;
}
animationRef.current = requestAnimationFrame(animate);
};
animationRef.current = requestAnimationFrame(animate);
return () => {
if (animationRef.current) {
cancelAnimationFrame(animationRef.current);
}
};
}, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]);
// Sync scroll position when user manually scrolls
const setupManualScroll = useCallback(() => {
const scrollContainer = scrollRef.current;
if (!scrollContainer) return;
const handleScroll = () => {
scrollPositionRef.current = scrollContainer.scrollLeft;
checkScrollButtons();
};
scrollContainer.addEventListener('scroll', handleScroll);
return () => scrollContainer.removeEventListener('scroll', handleScroll);
}, [checkScrollButtons]);
// Initialize effects
useState(() => {
initializeScroll();
return matchesSearch && matchesCategory;
});
// Setup auto-scroll effect
useState(() => {
setupAutoScroll();
});
// Setup manual scroll effect
useState(() => {
setupManualScroll();
});
if (leagues.length === 0) return null;
return (
<Box mb={10}>
{/* Section header */}
<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}
</Box>
</Stack>
<Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
<Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
{/* Hero */}
<Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
<Stack gap={4}>
<Box display="flex" alignItems="center" gap={3} color="text-blue-500">
<Trophy size={24} />
<Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
</Box>
<Heading level={1} fontSize="5xl" weight="bold" color="text-white">
Find Your <Text as="span" color="text-blue-500">Grid</Text>
</Heading>
<Text color="text-zinc-400" maxWidth="md" leading="relaxed">
From casual sprints to epic endurance battles discover the perfect league for your racing style.
</Text>
</Stack>
{/* Navigation arrows */}
<Stack direction="row" align="center" gap={2}>
<Button
type="button"
variant="secondary"
onClick={() => scroll('left')}
disabled={!canScrollLeft}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<UIIcon icon={ChevronLeft} size={4} />
</Button>
<Button
type="button"
variant="secondary"
onClick={() => scroll('right')}
disabled={!canScrollRight}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<UIIcon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
<Box display="flex" alignItems="center" gap={4}>
<Box display="flex" flexDirection="col" alignItems="end">
<Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
<Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
</Box>
<Box w="px" h="8" bg="zinc-800" />
<Button
onClick={() => router.push(routes.league.create)}
variant="primary"
size="lg"
>
<Stack direction="row" align="center" gap={2}>
<Plus size={16} />
Create League
</Stack>
</Button>
</Box>
</Box>
{/* Scrollable container with fade edges */}
<Box position="relative">
{/* Left fade gradient */}
<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 */}
<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" />
<Box
ref={scrollRef}
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
display="flex"
gap={4}
overflow="auto"
pb={4}
px={4}
hideScrollbar
>
{leagues.map((league) => {
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<Box key={league.id} flexShrink={0} w="320px" h="full">
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</UILink>
{/* Search & Filters */}
<Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
<Input
type="text"
placeholder="Search leagues by name, description, or game..."
value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
icon={<Search size={20} />}
/>
<Box as="nav" display="flex" flexWrap="wrap" gap={2}>
{CATEGORIES.map((category) => {
const isActive = activeCategory === category.id;
const CategoryIcon = category.icon;
return (
<Button
key={category.id}
onClick={() => setActiveCategory(category.id)}
variant={isActive ? 'primary' : 'secondary'}
size="sm"
>
<Stack direction="row" align="center" gap={2}>
<Box
color={!isActive && category.color ? category.color : undefined}
>
<CategoryIcon size={14} />
</Box>
<Text>{category.label}</Text>
</Stack>
</Button>
);
})}
</Box>
</Box>
{/* Grid */}
<Box as="main">
{filteredLeagues.length > 0 ? (
<Box display="grid" responsiveGridCols={{ base: 1, md: 2, lg: 3 }} gap={6}>
{filteredLeagues.map((league) => (
<LeagueCard
key={league.id}
id={league.id}
name={league.name}
description={league.description || undefined}
coverUrl={getMediaUrl('league-cover', league.id)}
logoUrl={league.logoUrl || undefined}
gameName={league.scoring?.gameName}
memberCount={league.usedDriverSlots || 0}
maxMembers={league.maxDrivers}
championshipType={(league.scoring?.primaryChampionshipType as 'driver' | 'team' | 'nations' | 'trophy') || 'driver'}
onClick={() => router.push(routes.league.detail(league.id))}
/>
))}
</Box>
) : (
<Box display="flex" flexDirection="col" alignItems="center" justifyContent="center" py={24} border borderStyle="dashed" borderColor="zinc-800" bg="zinc-900/20">
<Box color="text-zinc-800" mb={4}>
<Search size={48} />
</Box>
);
})}
<Heading level={3} fontSize="xl" weight="bold" color="text-zinc-500">No Leagues Found</Heading>
<Text color="text-zinc-600" size="sm" mt={2}>Try adjusting your search or filters</Text>
<Button
variant="ghost"
mt={6}
onClick={() => { setSearchQuery(''); setActiveCategory('all'); }}
>
Clear All Filters
</Button>
</Box>
)}
</Box>
</Box>
</Box>
);
}
// ============================================================================
// MAIN TEMPLATE COMPONENT
// ============================================================================
export function LeaguesPageClient({
viewData,
}: LeaguesTemplateProps) {
const router = useRouter();
const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
const [showFilters, setShowFilters] = useState(false);
// Filter by search query
const searchFilteredLeagues = viewData.leagues.filter((league) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
league.name.toLowerCase().includes(query) ||
(league.description ?? '').toLowerCase().includes(query) ||
(league.scoring?.gameName ?? '').toLowerCase().includes(query)
);
});
// Get leagues for active category
const activeCategoryData = CATEGORIES.find((c) => c.id === activeCategory);
const categoryFilteredLeagues = activeCategoryData
? searchFilteredLeagues.filter(activeCategoryData.filter)
: searchFilteredLeagues;
// Group leagues by category for slider view
const leaguesByCategory = CATEGORIES.reduce(
(acc, category) => {
// First try to use the dedicated category field, fall back to scoring-based filtering
acc[category.id] = searchFilteredLeagues.filter((league) => {
// If league has a category field, use it directly
if (league.category) {
return league.category === category.id;
}
// Otherwise fall back to the existing scoring-based filter
return category.filter(league);
});
return acc;
},
{} as Record<CategoryId, LeaguesViewData['leagues']>,
);
// Featured categories to show as sliders with different scroll speeds and alternating directions
const featuredCategoriesWithSpeed: { id: CategoryId; speed: number; direction: 'left' | 'right' }[] = [
{ id: 'popular', speed: 1.0, direction: 'right' },
{ id: 'new', speed: 1.3, direction: 'left' },
{ id: 'driver', speed: 0.8, direction: 'right' },
{ id: 'team', speed: 1.1, direction: 'left' },
{ id: 'nations', speed: 0.9, direction: 'right' },
{ id: 'endurance', speed: 0.7, direction: 'left' },
{ id: 'sprint', speed: 1.2, direction: 'right' },
];
return (
<Container size="lg" pb={12}>
{/* Hero Section */}
<PageHero
title="Find Your Grid"
description="From casual sprints to epic endurance battles — discover the perfect league for your racing style."
icon={Trophy}
stats={[
{ value: viewData.leagues.length, label: 'active leagues', color: 'bg-performance-green', animate: true },
{ value: leaguesByCategory.new.length, label: 'new this week', color: 'bg-primary-blue' },
{ value: leaguesByCategory.openSlots.length, label: 'with open slots', color: 'bg-neon-aqua' },
]}
actions={[
{
label: 'Create League',
onClick: () => { router.push(routes.league.create); },
icon: Plus,
description: 'Set up your own racing series'
}
]}
/>
{/* Search and Filter Bar */}
<Box mb={6}>
<Stack direction="row" gap={4} wrap>
{/* Search */}
<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)}
icon={<UIIcon icon={Search} size={5} color="text-gray-500" />}
/>
</Box>
{/* Filter toggle (mobile) */}
<Box display={{ base: 'block', lg: 'none' }}>
<Button
type="button"
variant="secondary"
onClick={() => setShowFilters(!showFilters)}
>
<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' : { base: 'none', lg: 'block' }}>
<Stack direction="row" gap={2} wrap>
{CATEGORIES.map((category) => {
const count = leaguesByCategory[category.id].length;
const isActive = activeCategory === category.id;
return (
<Button
key={category.id}
type="button"
variant={isActive ? 'primary' : 'secondary'}
onClick={() => setActiveCategory(category.id)}
size="sm"
rounded="full"
>
<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>
</Box>
</Box>
{/* Content */}
{viewData.leagues.length === 0 ? (
/* Empty State */
<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>
</Box>
</Card>
) : activeCategory === 'all' && !searchQuery ? (
/* Slider View - Show featured categories with sliders at different speeds and directions */
<Box>
{featuredCategoriesWithSpeed
.map(({ id, speed, direction }) => {
const category = CATEGORIES.find((c) => c.id === id)!;
return { category, speed, direction };
})
.filter(({ category }) => leaguesByCategory[category.id].length > 0)
.map(({ category, speed, direction }) => (
<LeagueSlider
key={category.id}
title={category.label}
icon={category.icon}
description={category.description}
leagues={leaguesByCategory[category.id]}
autoScroll={true}
iconColor={category.color || 'text-primary-blue'}
scrollSpeedMultiplier={speed}
scrollDirection={direction}
/>
))}
</Box>
) : (
/* Grid View - Filtered by category or search */
<Box>
{categoryFilteredLeagues.length > 0 ? (
<>
<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 && (
<Box as="span">
{' '}
for &quot;<Text color="text-primary-blue">{searchQuery}</Text>&quot;
</Box>
)}
</Text>
</Box>
<Grid cols={1} mdCols={2} lgCols={3} gap={6}>
{categoryFilteredLeagues.map((league) => {
const viewModel = LeagueSummaryViewModelBuilder.build(league);
return (
<GridItem key={league.id}>
<UILink href={routes.league.detail(league.id)} block h="full">
<LeagueCard league={viewModel} />
</UILink>
</GridItem>
);
})}
</Grid>
</>
) : (
<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>
)}
</Container>
);
}

View File

@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { ErrorBanner } from '@/ui/ErrorBanner';
@@ -49,8 +49,6 @@ export default async function Page({ params }: Props) {
});
return (
<LeagueDetailTemplate viewData={viewData} tabs={[]}>
{null}
</LeagueDetailTemplate>
<LeagueOverviewTemplate viewData={viewData} />
);
}

View File

@@ -16,13 +16,14 @@ import {
createRaceAction,
updateRaceAction,
deleteRaceAction
} from './actions';
} from '@/app/actions/leagueScheduleActions';
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
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';
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
export function LeagueAdminSchedulePageClient() {
const params = useParams();
@@ -39,6 +40,8 @@ export function LeagueAdminSchedulePageClient() {
const [isPublishing, setIsPublishing] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null);
const [raceToDelete, setRaceToDelete] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
@@ -48,7 +51,7 @@ export function LeagueAdminSchedulePageClient() {
// Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId
? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId || ''
: '');
// Load schedule using domain hook
@@ -65,6 +68,7 @@ export function LeagueAdminSchedulePageClient() {
if (!schedule || !selectedSeasonId) return;
setIsPublishing(true);
setError(null);
try {
const result = schedule.published
? await unpublishScheduleAction(leagueId, selectedSeasonId)
@@ -73,7 +77,7 @@ export function LeagueAdminSchedulePageClient() {
if (result.isOk()) {
router.refresh();
} else {
alert(result.getError());
setError(result.getError());
}
} finally {
setIsPublishing(false);
@@ -89,6 +93,7 @@ export function LeagueAdminSchedulePageClient() {
}
setIsSaving(true);
setError(null);
try {
const result = !editingRaceId
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
@@ -100,7 +105,7 @@ export function LeagueAdminSchedulePageClient() {
setEditingRaceId(null);
router.refresh();
} else {
alert(result.getError());
setError(result.getError());
}
} finally {
setIsSaving(false);
@@ -120,18 +125,22 @@ export function LeagueAdminSchedulePageClient() {
}));
};
const handleDelete = async (raceId: string) => {
if (!selectedSeasonId) return;
const confirmed = window.confirm('Delete this race?');
if (!confirmed) return;
const handleDelete = (raceId: string) => {
setRaceToDelete(raceId);
};
const confirmDelete = async () => {
if (!selectedSeasonId || !raceToDelete) return;
setDeletingRaceId(raceId);
setDeletingRaceId(raceToDelete);
setError(null);
try {
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId);
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete);
if (result.isOk()) {
router.refresh();
setRaceToDelete(null);
} else {
alert(result.getError());
setError(result.getError());
}
} finally {
setDeletingRaceId(null);
@@ -186,34 +195,47 @@ export function LeagueAdminSchedulePageClient() {
if (!data) return null;
return (
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={form.track}
car={form.car}
scheduledAtIso={form.scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={deletingRaceId}
setTrack={(val) => {
form.track = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setCar={(val) => {
form.car = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setScheduledAtIso={(val) => {
form.scheduledAtIso = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
/>
<>
<LeagueAdminScheduleTemplate
viewData={data}
onSeasonChange={handleSeasonChange}
onPublishToggle={handlePublishToggle}
onAddOrSave={handleAddOrSave}
onEdit={handleEdit}
onDelete={handleDelete}
onCancelEdit={handleCancelEdit}
track={form.track}
car={form.car}
scheduledAtIso={form.scheduledAtIso}
editingRaceId={editingRaceId}
isPublishing={isPublishing}
isSaving={isSaving}
isDeleting={deletingRaceId}
error={error}
setTrack={(val) => {
form.track = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setCar={(val) => {
form.car = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
setScheduledAtIso={(val) => {
form.scheduledAtIso = val;
setForm(new RaceScheduleCommandModel(form.toCommand()));
}}
/>
<ConfirmDialog
isOpen={!!raceToDelete}
onClose={() => setRaceToDelete(null)}
onConfirm={confirmDelete}
title="Delete Race"
description="Are you sure you want to delete this race? This will remove it from the schedule and cannot be undone."
confirmLabel="Delete Race"
variant="danger"
isLoading={!!deletingRaceId}
/>
</>
);
};

View File

@@ -1,5 +1,6 @@
'use client';
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
@@ -8,12 +9,10 @@ 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';
@@ -26,7 +25,7 @@ interface StewardingTemplateProps {
onRefetch: () => void;
}
export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) {
export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) {
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
@@ -36,19 +35,16 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc
// Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => {
return data.races.flatMap(r => r.pendingProtests.map(p => new ProtestViewModel({
return data.races.flatMap(r => r.pendingProtests.map(p => ({
id: p.id,
protestingDriverId: p.protestingDriverId,
accusedDriverId: p.accusedDriverId,
raceName: r.track || 'Unknown Track',
protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown',
accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown',
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]);
status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
})));
}, [data.races, data.drivers]);
const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({
@@ -131,84 +127,91 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc
});
};
const handleReviewProtest = (id: string) => {
// Find the protest in the data
let foundProtest: ProtestViewModel | null = null;
data.races.forEach(r => {
const p = r.pendingProtests.find(p => p.id === id);
if (p) {
foundProtest = 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);
}
});
if (foundProtest) setSelectedProtest(foundProtest);
};
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>
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{/* Tab navigation */}
<Box borderBottom borderColor="border-charcoal-outline">
<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>
{/* 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
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}
/>
) : (
{/* Content */}
{activeTab === 'pending' ? (
<StewardingQueuePanel
protests={allPendingProtests}
onReview={handleReviewProtest}
/>
) : (
<Card>
<Box p={6}>
<PenaltyHistoryList
protests={allResolvedProtests}
races={racesMap}
drivers={driverMap}
/>
)}
</Box>
</Card>
</Box>
</Card>
)}
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />

View File

@@ -1,26 +1,18 @@
'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 React from 'react';
import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
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
Download
} from 'lucide-react';
import { Button } from '@/ui/Button';
interface WalletTemplateProps {
viewData: LeagueWalletViewData;
@@ -29,29 +21,15 @@ interface WalletTemplateProps {
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;
export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
// Map transactions to the format expected by WalletSummaryPanel
const transactions = viewData.transactions.map(t => ({
id: t.id,
type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit',
amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')),
description: t.description,
date: t.formattedDate,
}));
return (
<Container size="lg" py={8}>
@@ -61,314 +39,29 @@ export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutatio
<Heading level={1}>League Wallet</Heading>
<Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text>
</Box>
<Stack direction="row" align="center" gap={2}>
<Button variant="secondary" onClick={onExport}>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Download} size={4} />
<Text>Export</Text>
</Stack>
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!canWithdraw || !onWithdraw}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
<Button variant="secondary" onClick={onExport}>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={Download} size={4} />
<Text>Export</Text>
</Stack>
</Button>
</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>
)}
<WalletSummaryPanel
balance={viewData.balance}
currency="USD"
transactions={transactions}
onDeposit={() => {}} // Not implemented for leagues yet
onWithdraw={() => {}} // Not implemented for leagues yet
/>
{/* Alpha Notice */}
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}>
<Text size="xs" color="text-gray-400">
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only.
Real payment processing and bank integrations will be available when the payment system is fully implemented.
The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation.
</Text>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { MediaTemplate } from '@/templates/MediaTemplate';
import { MediaAsset } from '@/components/media/MediaGallery';
export interface MediaPageClientProps {
initialAssets: MediaAsset[];
categories: { label: string; value: string }[];
}
export function MediaPageClient({
initialAssets,
categories,
}: MediaPageClientProps) {
return (
<MediaTemplate
assets={initialAssets}
categories={categories}
title="Media Library"
description="Manage and view all racing assets, telemetry captures, and brand identities."
/>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { MediaPageClient } from '../MediaPageClient';
export default async function AvatarsPage() {
const assets = [
{ id: '1', src: '/media/avatar/driver-1', title: 'Driver Avatar 1', category: 'avatars', dimensions: '512x512' },
{ id: '2', src: '/media/avatar/driver-2', title: 'Driver Avatar 2', category: 'avatars', dimensions: '512x512' },
];
const categories = [
{ label: 'Avatars', value: 'avatars' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { MediaPageClient } from '../MediaPageClient';
export default async function LeaguesMediaPage() {
const assets = [
{ id: 'l1', src: '/media/leagues/league-1/logo', title: 'League Logo 1', category: 'leagues', dimensions: '1024x1024' },
{ id: 'l1c', src: '/media/leagues/league-1/cover', title: 'League Cover 1', category: 'leagues', dimensions: '1920x400' },
];
const categories = [
{ label: 'Leagues', value: 'leagues' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -0,0 +1,30 @@
import React from 'react';
import { MediaPageClient } from './MediaPageClient';
export default async function MediaPage() {
// In a real app, we would fetch this data from an API or database
// For now, we'll provide some sample data to demonstrate the redesign
const assets = [
{ id: '1', src: '/media/avatar/driver-1', title: 'Driver Avatar 1', category: 'avatars', dimensions: '512x512' },
{ id: '2', src: '/media/teams/team-1/logo', title: 'Team Logo 1', category: 'teams', dimensions: '1024x1024' },
{ id: '3', src: '/media/leagues/league-1/logo', title: 'League Logo 1', category: 'leagues', dimensions: '1024x1024' },
{ id: '4', src: '/media/tracks/track-1/image', title: 'Track Image 1', category: 'tracks', dimensions: '1920x1080' },
{ id: '5', src: '/media/sponsors/sponsor-1/logo', title: 'Sponsor Logo 1', category: 'sponsors', dimensions: '800x400' },
];
const categories = [
{ label: 'All Assets', value: 'all' },
{ label: 'Avatars', value: 'avatars' },
{ label: 'Teams', value: 'teams' },
{ label: 'Leagues', value: 'leagues' },
{ label: 'Tracks', value: 'tracks' },
{ label: 'Sponsors', value: 'sponsors' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { MediaPageClient } from '../MediaPageClient';
export default async function SponsorsMediaPage() {
const assets = [
{ id: 's1', src: '/media/sponsors/sponsor-1/logo', title: 'Sponsor Logo 1', category: 'sponsors', dimensions: '800x400' },
{ id: 's2', src: '/media/sponsors/sponsor-2/logo', title: 'Sponsor Logo 2', category: 'sponsors', dimensions: '800x400' },
];
const categories = [
{ label: 'Sponsors', value: 'sponsors' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { MediaPageClient } from '../MediaPageClient';
export default async function TeamsMediaPage() {
const assets = [
{ id: 't1', src: '/media/teams/team-1/logo', title: 'Team Logo 1', category: 'teams', dimensions: '1024x1024' },
{ id: 't2', src: '/media/teams/team-2/logo', title: 'Team Logo 2', category: 'teams', dimensions: '1024x1024' },
];
const categories = [
{ label: 'Teams', value: 'teams' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { MediaPageClient } from '../MediaPageClient';
export default async function TracksMediaPage() {
const assets = [
{ id: 'tr1', src: '/media/tracks/track-1/image', title: 'Track Image 1', category: 'tracks', dimensions: '1920x1080' },
{ id: 'tr2', src: '/media/tracks/track-2/image', title: 'Track Image 2', category: 'tracks', dimensions: '1920x1080' },
];
const categories = [
{ label: 'Tracks', value: 'tracks' },
];
return (
<MediaPageClient
initialAssets={assets}
categories={categories}
/>
);
}

View File

@@ -1,35 +1,29 @@
'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';
import React from 'react';
import { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
/**
* NotFound
*
* App-level 404 handler.
* Orchestrates the NotFoundTemplate with appropriate racing-themed copy.
*/
export default function NotFound() {
return (
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" bg="bg-deep-graphite" color="text-white" px={6}>
<Box maxWidth="md" textAlign="center">
<Stack gap={4}>
<Heading level={1} fontSize="3xl" weight="semibold">Page not found</Heading>
<Text size="sm" color="text-gray-400" block>
The page you requested doesn&apos;t exist (or isn&apos;t available in this mode).
</Text>
<Box pt={2}>
<Link
href="/"
variant="primary"
size="sm"
weight="medium"
rounded="md"
px={4}
py={2}
>
Drive home
</Link>
</Box>
</Stack>
</Box>
</Box>
);
}
const router = useRouter();
const handleHomeClick = () => {
router.push(routes.public.home);
};
const viewData: NotFoundViewData = {
errorCode: 'Error 404',
title: 'OFF TRACK',
message: 'The requested sector does not exist. You have been returned to the pits.',
actionLabel: 'Return to Pits'
};
return <NotFoundTemplate viewData={viewData} onHomeClick={handleHomeClick} />;
}

View File

@@ -1,13 +1,50 @@
'use client';
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard';
import { OnboardingTemplate } from '@/templates/onboarding/OnboardingTemplate';
import { routes } from '@/lib/routing/RouteConfig';
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction';
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction';
import { completeOnboardingAction } from '@/app/actions/completeOnboardingAction';
import { generateAvatarsAction } from '@/app/actions/generateAvatarsAction';
import { useAuth } from '@/components/auth/AuthContext';
import { useState } from 'react';
import { PersonalInfo } from '@/components/onboarding/PersonalInfoStep';
import { AvatarInfo } from '@/components/onboarding/AvatarStep';
type OnboardingStep = 1 | 2;
interface FormErrors {
[key: string]: string | undefined;
firstName?: string;
lastName?: string;
displayName?: string;
country?: string;
facePhoto?: string;
avatar?: string;
submit?: string;
}
export function OnboardingWizardClient() {
const { session } = useAuth();
const [isProcessing, setIsProcessing] = useState(false);
const [step, setStep] = useState<OnboardingStep>(1);
const [errors, setErrors] = useState<FormErrors>({});
// Form state
const [personalInfo, setPersonalInfo] = useState<PersonalInfo>({
firstName: '',
lastName: '',
displayName: '',
country: '',
timezone: '',
});
const [avatarInfo, setAvatarInfo] = useState<AvatarInfo>({
facePhoto: null,
suitColor: 'blue',
generatedAvatars: [],
selectedAvatarIndex: null,
isGenerating: false,
isValidating: false,
});
const handleCompleteOnboarding = async (input: {
firstName: string;
@@ -16,16 +53,19 @@ export function OnboardingWizardClient() {
country: string;
timezone?: string;
}) => {
setIsProcessing(true);
try {
const result = await completeOnboardingAction(input);
if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() };
}
window.location.href = routes.protected.dashboard;
return { success: true };
} catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to complete onboarding' };
}
};
@@ -38,6 +78,7 @@ export function OnboardingWizardClient() {
return { success: false, error: 'Not authenticated' };
}
setIsProcessing(true);
try {
const result = await generateAvatarsAction({
userId: session.user.userId,
@@ -46,23 +87,37 @@ export function OnboardingWizardClient() {
});
if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() };
}
const data = result.unwrap();
setIsProcessing(false);
return { success: true, data };
} catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to generate avatars' };
}
};
return (
<OnboardingWizard
onCompleted={() => {
window.location.href = routes.protected.dashboard;
<OnboardingTemplate
viewData={{
onCompleted: () => {
window.location.href = routes.protected.dashboard;
},
onCompleteOnboarding: handleCompleteOnboarding,
onGenerateAvatars: handleGenerateAvatars,
isProcessing: isProcessing,
step,
setStep,
errors,
setErrors,
personalInfo,
setPersonalInfo,
avatarInfo,
setAvatarInfo,
}}
onCompleteOnboarding={handleCompleteOnboarding}
onGenerateAvatars={handleGenerateAvatars}
/>
);
}
}

View File

@@ -2,23 +2,21 @@
import { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { ProfileTemplate, type ProfileTab } from '@/templates/ProfileTemplate';
import { ProfileTemplate } from '@/templates/ProfileTemplate';
import { type ProfileTab } from '@/components/profile/ProfileNavTabs';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import type { Result } from '@/lib/contracts/Result';
interface ProfilePageClientProps {
viewData: ProfileViewData;
mode: 'profile-exists' | 'needs-profile';
onSaveSettings: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
}
export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePageClientProps) {
export function ProfilePageClient({ viewData, mode }: ProfilePageClientProps) {
const router = useRouter();
const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null;
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [editMode, setEditMode] = useState(false);
const [friendRequestSent, setFriendRequestSent] = useState(false);
useEffect(() => {
@@ -49,19 +47,8 @@ export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePag
mode={mode}
activeTab={activeTab}
onTabChange={setActiveTab}
editMode={editMode}
onEditModeChange={setEditMode}
friendRequestSent={friendRequestSent}
onFriendRequestSend={() => setFriendRequestSent(true)}
onSaveSettings={async (updates) => {
const result = await onSaveSettings(updates);
if (result.isErr()) {
// In a real app, we'd show a toast or error message.
// For now, we just throw to let the UI handle it if needed,
// or we could add an error state to this client component.
throw new Error(result.getError());
}
}}
/>
);
}

View File

@@ -1,7 +1,7 @@
import type { ReactNode } from 'react';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import { ProfileLayoutShell } from '@/ui/ProfileLayoutShell';
import { ProfileLayoutShellTemplate } from '@/templates/ProfileLayoutShellTemplate';
import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface ProfileLayoutProps {
@@ -18,5 +18,5 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
redirect(result.to);
}
return <ProfileLayoutShell>{children}</ProfileLayoutShell>;
return <ProfileLayoutShellTemplate viewData={{}}>{children}</ProfileLayoutShellTemplate>;
}

View File

@@ -1,13 +1,4 @@
import Link from 'next/link';
import { Button } from '@/ui/Button';
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';
import { ProfileLiveriesTemplate } from '@/templates/ProfileLiveriesTemplate';
export default async function ProfileLiveriesPage() {
const mockLiveries = [
@@ -29,29 +20,5 @@ export default async function ProfileLiveriesPage() {
}
];
return (
<Container size="lg" py={8}>
<Stack direction="row" align="center" justify="between" mb={8}>
<Box>
<Heading level={1}>My Liveries</Heading>
<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>
</Stack>
<Grid cols={3} gap={6}>
{mockLiveries.map((livery) => (
<LiveryCard key={livery.id} livery={livery} />
))}
</Grid>
<Box mt={12}>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</Box>
</Container>
);
return <ProfileLiveriesTemplate viewData={{ liveries: mockLiveries }} />;
}

View File

@@ -0,0 +1,107 @@
'use client';
import React, { useState } from 'react';
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 { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { routes } from '@/lib/routing/RouteConfig';
import { UploadDropzone } from '@/components/media/UploadDropzone';
import { MediaPreviewCard } from '@/ui/MediaPreviewCard';
import { MediaMetaPanel, mapMediaMetadata } from '@/ui/MediaMetaPanel';
export function ProfileLiveryUploadPageClient() {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFilesSelected = (files: File[]) => {
if (files.length > 0) {
const file = files[0];
setSelectedFile(file);
const url = URL.createObjectURL(file);
setPreviewUrl(url);
} else {
setSelectedFile(null);
setPreviewUrl(null);
}
};
const handleUpload = async () => {
if (!selectedFile) return;
setIsUploading(true);
// Mock upload delay
await new Promise(resolve => setTimeout(resolve, 2000));
setIsUploading(false);
alert('Livery uploaded successfully! (Mock)');
};
return (
<Container size="md">
<Box mb={6}>
<Heading level={1}>Upload livery</Heading>
<Text color="text-gray-500">
Upload your custom car livery. Supported formats: .png, .jpg, .tga
</Text>
</Box>
<Box display="grid" responsiveGridCols={{ base: 1, md: 2 }} gap={6}>
<Box>
<Card>
<UploadDropzone
onFilesSelected={handleFilesSelected}
accept=".png,.jpg,.jpeg,.tga"
maxSize={10 * 1024 * 1024} // 10MB
isLoading={isUploading}
/>
<Box mt={6} display="flex" justifyContent="end" gap={3}>
<Link href={routes.protected.profileLiveries}>
<Button variant="ghost">Cancel</Button>
</Link>
<Button
variant="primary"
disabled={!selectedFile || isUploading}
onClick={handleUpload}
isLoading={isUploading}
>
Upload Livery
</Button>
</Box>
</Card>
</Box>
<Box>
{previewUrl ? (
<Box display="flex" flexDirection="col" gap={6}>
<MediaPreviewCard
src={previewUrl}
title={selectedFile?.name}
subtitle="Preview"
aspectRatio="16/9"
/>
<MediaMetaPanel
items={mapMediaMetadata({
filename: selectedFile?.name,
size: selectedFile?.size,
contentType: selectedFile?.type || 'image/tga',
createdAt: new Date(),
})}
/>
</Box>
) : (
<Card center p={12}>
<Text color="text-gray-500" align="center">
Select a file to see preview and details
</Text>
</Card>
)}
</Box>
</Box>
</Container>
);
}

View File

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

View File

@@ -1,6 +1,5 @@
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
import { notFound } from 'next/navigation';
import { updateProfileAction } from './actions';
import { ProfilePageClient } from './ProfilePageClient';
export default async function ProfilePage() {
@@ -18,7 +17,6 @@ export default async function ProfilePage() {
<ProfilePageClient
viewData={viewData}
mode={mode}
onSaveSettings={updateProfileAction}
/>
);
}

View File

@@ -0,0 +1,64 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { ProfileSettingsTemplate } from '@/templates/ProfileSettingsTemplate';
import type { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import type { Result } from '@/lib/contracts/Result';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import { Box } from '@/ui/Box';
interface ProfileSettingsPageClientProps {
viewData: ProfileViewData;
onSave: (updates: { bio?: string; country?: string }) => Promise<Result<void, string>>;
}
export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsPageClientProps) {
const router = useRouter();
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [bio, setBio] = useState(viewData.driver.bio || '');
const [country, setCountry] = useState(viewData.driver.countryCode);
const handleSave = async () => {
setIsSaving(true);
setError(null);
try {
const result = await onSave({ bio, country });
if (result.isErr()) {
setError(result.getError());
} else {
router.refresh();
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save settings');
} finally {
setIsSaving(false);
}
};
return (
<>
<ProgressLine isLoading={isSaving} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Update Failed"
message={error}
/>
</Box>
)}
<ProfileSettingsTemplate
viewData={viewData}
bio={bio}
country={country}
onBioChange={setBio}
onCountryChange={setCountry}
onSave={handleSave}
/>
</>
);
}

View File

@@ -1,21 +1,22 @@
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 { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery';
import { notFound } from 'next/navigation';
import { updateProfileAction } from '@/app/actions/profileActions';
import { ProfileSettingsPageClient } from './ProfileSettingsPageClient';
export default async function ProfileSettingsPage() {
const query = new ProfilePageQuery();
const result = await query.execute();
if (result.isErr()) {
notFound();
}
const viewData = result.unwrap();
return (
<Container size="md">
<Heading level={1}>Settings</Heading>
<Card>
<Text block mb={4}>Settings are currently unavailable.</Text>
<Link href={routes.protected.profile}>
<Button variant="secondary">Back to profile</Button>
</Link>
</Card>
</Container>
<ProfileSettingsPageClient
viewData={viewData}
onSave={updateProfileAction}
/>
);
}

View File

@@ -1,8 +1,13 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate';
import { InlineNotice } from '@/components/shared/ux/InlineNotice';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
import { Box } from '@/ui/Box';
interface SponsorshipRequestsClientProps {
viewData: SponsorshipRequestsViewData;
@@ -11,25 +16,54 @@ interface SponsorshipRequestsClientProps {
}
export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: SponsorshipRequestsClientProps) {
const router = useRouter();
const [isProcessing, setIsProcessing] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const handleAccept = async (requestId: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onAccept(requestId);
if (result.isErr()) {
console.error('Failed to accept request:', result.getError());
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
const handleReject = async (requestId: string, reason?: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onReject(requestId, reason);
if (result.isErr()) {
console.error('Failed to reject request:', result.getError());
setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
}
};
return (
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={handleAccept}
onReject={handleReject}
/>
<>
<ProgressLine isLoading={!!isProcessing} />
{error && (
<Box position="fixed" top={4} right={4} zIndex={50} maxWidth="md">
<InlineNotice
variant="error"
title="Action Failed"
message={error}
/>
</Box>
)}
<SponsorshipRequestsTemplate
viewData={viewData}
onAccept={handleAccept}
onReject={handleReject}
processingId={isProcessing}
/>
</>
);
}

View File

@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from '@/app/actions/sponsorshipActions';
export default async function SponsorshipRequestsPage() {
// Execute PageQuery

View File

@@ -8,7 +8,7 @@ interface Props {
data: RaceDetailViewData;
}
export default function RaceDetailPageClient({ data: viewData }: Props) {
export function RaceDetailPageClient({ data: viewData }: Props) {
const router = useRouter();
const [animatedRatingChange] = useState(0);

View File

@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery';
import RaceDetailPageClient from './RaceDetailPageClient';
import { RaceDetailPageClient } from './RaceDetailPageClient';
interface RaceDetailPageProps {
params: Promise<{
@@ -29,8 +29,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) {
return (
<PageWrapper
data={undefined}
Template={RaceDetailPageClient as any}
error={new Error('Failed to load race details')}
Template={RaceDetailPageClient}
error={new globalThis.Error('Failed to load race details')}
/>
);
}

View File

@@ -9,7 +9,7 @@ interface Props {
data: RaceResultsViewData;
}
export default function RaceResultsPageClient({ data: viewData }: Props) {
export function RaceResultsPageClient({ data: viewData }: Props) {
const router = useRouter();
const [importing, setImporting] = useState(false);
const [importSuccess, setImportSuccess] = useState(false);

View File

@@ -1,7 +1,7 @@
import { notFound } from 'next/navigation';
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery';
import RaceResultsPageClient from './RaceResultsPageClient';
import { RaceResultsPageClient } from './RaceResultsPageClient';
interface RaceResultsPageProps {
params: Promise<{
@@ -29,8 +29,8 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps)
return (
<PageWrapper
data={undefined}
Template={RaceResultsPageClient as any}
error={new Error('Failed to load race results')}
Template={RaceResultsPageClient}
error={new globalThis.Error('Failed to load race results')}
/>
);
}

View File

@@ -1,251 +1,13 @@
'use client';
import { useState } from 'react';
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 { InfoBanner } from '@/ui/InfoBanner';
import { PageHeader } from '@/ui/PageHeader';
import { Icon } from '@/ui/Icon';
import { siteConfig } from '@/lib/siteConfig';
import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
import {
CreditCard,
DollarSign,
Calendar,
Download,
Plus,
Check,
AlertTriangle,
FileText,
TrendingUp,
Receipt,
Building2,
Wallet,
Clock,
ChevronRight,
Info,
ExternalLink,
Percent,
Loader2
} from 'lucide-react';
import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO';
// ============================================================================
// Components
// ============================================================================
function PaymentMethodCardComponent({
method,
onSetDefault,
onRemove
}: {
method: PaymentMethodDTO;
onSetDefault: () => void;
onRemove: () => void;
}) {
const shouldReduceMotion = useReducedMotion();
const getIcon = () => {
if (method.type === 'sepa') return Building2;
return CreditCard;
};
const MethodIcon = getIcon();
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 (
<Box
as={motion.div}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
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
>
<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 && (
<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>
)}
</Box>
{expiryDisplay && (
<Text size="sm" color="text-gray-500" block>
Expires {expiryDisplay}
</Text>
)}
{method.type === 'sepa' && (
<Text size="sm" color="text-gray-500" block>SEPA Direct Debit</Text>
)}
</Box>
</Box>
<Box display="flex" alignItems="center" gap={2}>
{!method.isDefault && (
<Button variant="secondary" onClick={onSetDefault} size="sm">
Set Default
</Button>
)}
<Button variant="secondary" onClick={onRemove} size="sm" color="text-gray-400" hoverTextColor="text-racing-red">
Remove
</Button>
</Box>
</Box>
</Box>
);
}
function InvoiceRowComponent({ invoice, index }: { invoice: InvoiceDTO; index: number }) {
const shouldReduceMotion = useReducedMotion();
const statusConfig = {
paid: {
icon: Check,
label: 'Paid',
color: 'text-performance-green',
bg: 'bg-performance-green/10',
border: 'border-performance-green/30'
},
pending: {
icon: Clock,
label: 'Pending',
color: 'text-warning-amber',
bg: 'bg-warning-amber/10',
border: 'border-warning-amber/30'
},
overdue: {
icon: AlertTriangle,
label: 'Overdue',
color: 'text-racing-red',
bg: 'bg-racing-red/10',
border: 'border-racing-red/30'
},
failed: {
icon: AlertTriangle,
label: 'Failed',
color: 'text-racing-red',
bg: 'bg-racing-red/10',
border: 'border-racing-red/30'
},
};
const typeLabels = {
league: 'League',
team: 'Team',
driver: 'Driver',
race: 'Race',
platform: 'Platform',
};
const status = statusConfig[invoice.status as keyof typeof statusConfig];
const StatusIcon = status.icon;
return (
<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 }}
display="flex"
alignItems="center"
justifyContent="between"
p={4}
borderBottom
borderColor="border-charcoal-outline/50"
hoverBg="bg-iron-gray/20"
transition-colors
group
>
<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>
<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>
<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" size="sm" opacity={0} groupHoverTextColor="opacity-100" transition-opacity icon={<Icon icon={Download} size={3} />}>
PDF
</Button>
</Box>
</Box>
);
}
// ============================================================================
// Main Component
// ============================================================================
import { SponsorBillingTemplate } from "@/templates/SponsorBillingTemplate";
import { Box } from "@/ui/Box";
import { Text } from "@/ui/Text";
import { Button } from "@/ui/Button";
import { DollarSign, AlertTriangle, Calendar, TrendingUp } from "lucide-react";
export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion();
const [showAllInvoices, setShowAllInvoices] = useState(false);
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
if (isLoading) {
@@ -274,228 +36,68 @@ export default function SponsorBillingPage() {
);
}
const data = billingData;
const handleSetDefault = (methodId: string) => {
// In a real app, this would call an API
console.log('Setting default payment method:', methodId);
};
const handleRemoveMethod = (methodId: string) => {
if (window.confirm('Remove this payment method?')) {
// In a real app, this would call an API
console.log('Removing payment method:', methodId);
}
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: shouldReduceMotion ? 0 : 0.1,
},
},
const handleDownloadInvoice = (invoiceId: string) => {
console.log('Downloading invoice:', invoiceId);
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
};
const billingStats = [
{
label: 'Total Spent',
value: `$${billingData.stats.totalSpent.toFixed(2)}`,
subValue: 'All time',
icon: DollarSign,
variant: 'success' as const,
},
{
label: 'Pending Payments',
value: `$${billingData.stats.pendingAmount.toFixed(2)}`,
subValue: `${billingData.invoices.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`,
icon: AlertTriangle,
variant: (billingData.stats.pendingAmount > 0 ? 'warning' : 'default') as 'warning' | 'default',
},
{
label: 'Next Payment',
value: new Date(billingData.stats.nextPaymentDate).toLocaleDateString(),
subValue: `$${billingData.stats.nextPaymentAmount.toFixed(2)}`,
icon: Calendar,
variant: 'info' as const,
},
{
label: 'Monthly Average',
value: `$${billingData.stats.averageMonthlySpend.toFixed(2)}`,
subValue: 'Last 6 months',
icon: TrendingUp,
variant: 'default' as const,
},
];
const transactions = billingData.invoices.map(inv => ({
id: inv.id,
date: inv.date,
description: inv.description,
amount: inv.totalAmount,
status: inv.status as any,
invoiceNumber: inv.invoiceNumber,
type: inv.sponsorshipType,
}));
return (
<Box
maxWidth="5xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<Box as={motion.div} variants={itemVariants}>
<PageHeader
icon={Wallet}
title="Billing & Payments"
description="Manage payment methods, view invoices, and track your sponsorship spending"
iconGradient="from-warning-amber/20 to-warning-amber/5"
iconBorder="border-warning-amber/30"
/>
</Box>
{/* Stats Grid */}
<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.totalSpent.toFixed(2)}`}
subValue="All time"
variant="green"
/>
<StatCard
icon={AlertTriangle}
label="Pending Payments"
value={`$${data.stats.pendingAmount.toFixed(2)}`}
subValue={`${data.invoices.filter((i: { status: string }) => i.status === 'pending' || i.status === 'overdue').length} invoices`}
variant="orange"
/>
<StatCard
icon={Calendar}
label="Next Payment"
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.averageMonthlySpend.toFixed(2)}`}
subValue="Last 6 months"
variant="blue"
/>
</Box>
{/* Payment Methods */}
<Box as={motion.div} variants={itemVariants}>
<Card mb={8} overflow="hidden">
<SectionHeader
icon={CreditCard}
title="Payment Methods"
action={
<Button variant="secondary" size="sm" icon={<Icon icon={Plus} size={4} />}>
Add Payment Method
</Button>
}
/>
<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)}
/>
))}
</Box>
<Box px={5} pb={5}>
<InfoBanner type="info">
<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>
</Box>
</Card>
</Box>
{/* Billing History */}
<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" size="sm" icon={<Icon icon={Download} size={4} />}>
Export All
</Button>
}
/>
<Box>
{data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: InvoiceDTO, index: number) => (
<InvoiceRowComponent key={invoice.id} invoice={invoice} index={index} />
))}
</Box>
{data.invoices.length > 4 && (
<Box p={4} borderTop borderColor="border-charcoal-outline">
<Button
variant="secondary"
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`}
</Button>
</Box>
)}
</Card>
</Box>
{/* Platform Fee & VAT Information */}
<Box as={motion.div} variants={itemVariants} display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
{/* Platform Fee */}
<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
</Heading>
</Box>
<Box p={5}>
<Text size="3xl" weight="bold" color="text-white" block mb={2}>
{siteConfig.fees.platformFeePercent}%
</Text>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.fees.description}
</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 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
</Heading>
</Box>
<Box p={5}>
<Text size="sm" color="text-gray-400" block mb={4}>
{siteConfig.vat.notice}
</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.
</Text>
</Box>
</Card>
</Box>
{/* Billing Support */}
<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.
</Text>
</Box>
</Stack>
<Button variant="secondary" icon={<Icon icon={ExternalLink} size={4} />}>
Contact Support
</Button>
</Box>
</Card>
</Box>
</Box>
<SponsorBillingTemplate
viewData={billingData}
billingStats={billingStats}
transactions={transactions}
onSetDefaultPaymentMethod={handleSetDefault}
onDownloadInvoice={handleDownloadInvoice}
/>
);
}

View File

@@ -1,619 +1,74 @@
'use client';
import { useState } from 'react';
import { useSearchParams } from 'next/navigation';
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 "@/hooks/sponsor/useSponsorSponsorships";
import {
Megaphone,
Trophy,
Users,
Eye,
Calendar,
ExternalLink,
Plus,
ChevronRight,
Check,
Clock,
XCircle,
Car,
Flag,
Search,
TrendingUp,
BarChart3,
ArrowUpRight,
ArrowDownRight,
Send,
ThumbsUp,
ThumbsDown,
RefreshCw,
} from 'lucide-react';
// ============================================================================
// Types
// ============================================================================
type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform';
type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired';
// ============================================================================
// Configuration
// ============================================================================
const TYPE_CONFIG = {
leagues: { icon: Trophy, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', label: 'League' },
teams: { icon: Users, color: 'text-purple-400', bgColor: 'bg-purple-400/10', label: 'Team' },
drivers: { icon: Car, color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Driver' },
races: { icon: Flag, color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Race' },
platform: { icon: Megaphone, color: 'text-racing-red', bgColor: 'bg-racing-red/10', label: 'Platform' },
all: { icon: BarChart3, color: 'text-gray-400', bgColor: 'bg-gray-400/10', label: 'All' },
};
const STATUS_CONFIG = {
active: {
icon: Check,
color: 'text-performance-green',
bgColor: 'bg-performance-green/10',
borderColor: 'border-performance-green/30',
label: 'Active'
},
pending_approval: {
icon: Clock,
color: 'text-warning-amber',
bgColor: 'bg-warning-amber/10',
borderColor: 'border-warning-amber/30',
label: 'Awaiting Approval'
},
approved: {
icon: ThumbsUp,
color: 'text-primary-blue',
bgColor: 'bg-primary-blue/10',
borderColor: 'border-primary-blue/30',
label: 'Approved'
},
rejected: {
icon: ThumbsDown,
color: 'text-racing-red',
bgColor: 'bg-racing-red/10',
borderColor: 'border-racing-red/30',
label: 'Declined'
},
expired: {
icon: XCircle,
color: 'text-gray-400',
bgColor: 'bg-gray-400/10',
borderColor: 'border-gray-400/30',
label: 'Expired'
},
};
// ============================================================================
// Components
// ============================================================================
function SponsorshipCard({ sponsorship }: { sponsorship: unknown }) {
const shouldReduceMotion = useReducedMotion();
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((s.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24));
const isExpiringSoon = daysRemaining > 0 && daysRemaining <= 30;
const isPending = s.status === 'pending_approval';
const isRejected = s.status === 'rejected';
const isApproved = s.status === 'approved';
const getEntityLink = () => {
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 '#';
}
};
return (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
whileHover={{ y: -2 }}
transition={{ duration: 0.2 }}
>
<Card className={`hover:border-primary-blue/30 transition-all duration-300 ${
isPending ? 'border-warning-amber/30' :
isRejected ? 'border-racing-red/20 opacity-75' :
isApproved ? 'border-primary-blue/30' : ''
}`}>
{/* Header */}
<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}`} />
</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}
</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>
)}
</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" />
<Text size="xs" weight="medium">{statusConfig.label}</Text>
</Box>
</Stack>
{/* Entity Name */}
<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 && (
<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" />
<Text size="sm" weight="medium">Application Pending</Text>
</Stack>
<Text size="xs" color="text-gray-400" block mb={2}>
Sent to <Text color="text-gray-300">{s.entityOwner}</Text> on{' '}
{s.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
</Text>
{s.applicationMessage && (
<Text size="xs" color="text-gray-500" italic block>&quot;{s.applicationMessage}&quot;</Text>
)}
</Box>
)}
{isApproved && (
<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" />
<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 && (
<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" />
<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>
</Box>
)}
{/* Metrics Grid - Only show for active sponsorships */}
{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" />
<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>
)}
</Stack>
</Box>
{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" />
<Text size="xs">Engagement</Text>
</Box>
<Text color="text-white" weight="semibold">{s.engagement}%</Text>
</Box>
)}
<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" />
<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>
<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" />
<Text size="xs">Investment</Text>
</Box>
<Text color="text-white" weight="semibold">{s.formattedPrice}</Text>
</Box>
</Box>
)}
{/* Basic info for non-active */}
{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" />
<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" />
<Text size="sm">{s.formattedPrice}</Text>
</Box>
</Stack>
)}
{/* Footer */}
<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'}
</Text>
)}
{isPending && (
<Text size="xs" color="text-gray-500">
Waiting for response...
</Text>
)}
</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" />
View
</Button>
</Link>
)}
{isPending && (
<Button variant="secondary" className="text-xs text-racing-red hover:bg-racing-red/10">
Cancel Application
</Button>
)}
{s.status === 'active' && (
<Button variant="secondary" className="text-xs">
Details
<ChevronRight className="w-3 h-3 ml-1" />
</Button>
)}
</Stack>
</Box>
</Card>
</motion.div>
);
}
// ============================================================================
// Main Component
// ============================================================================
import { SponsorCampaignsTemplate, SponsorshipType } from "@/templates/SponsorCampaignsTemplate";
import { Box } from "@/ui/Box";
import { Text } from "@/ui/Text";
import { Button } from "@/ui/Button";
export default function SponsorCampaignsPage() {
const searchParams = useSearchParams();
const shouldReduceMotion = useReducedMotion();
const initialType = (searchParams.get('type') as SponsorshipType) || 'all';
const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType);
const [statusFilter, setStatusFilter] = useState<SponsorshipStatus>('all');
const [typeFilter, setTypeFilter] = useState<SponsorshipType>('all');
const [searchQuery, setSearchQuery] = useState('');
const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1');
if (isLoading) {
return (
<div className="max-w-7xl 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 sponsorships...</p>
</div>
</div>
<Box maxWidth="7xl" 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 sponsorships...</Text>
</Box>
</Box>
);
}
if (error || !sponsorshipsData) {
return (
<div className="max-w-7xl 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 sponsorships data available'}</p>
<Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<Box textAlign="center">
<Text color="text-gray-400">{error?.getUserMessage() || 'No sponsorships 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>
);
}
const data = sponsorshipsData;
// Calculate stats
const stats = {
total: sponsorshipsData.sponsorships.length,
active: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').length,
pending: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'pending_approval').length,
approved: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'approved').length,
rejected: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'rejected').length,
totalInvestment: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0),
totalImpressions: sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0),
};
// Filter sponsorships
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;
const viewData = {
sponsorships: sponsorshipsData.sponsorships as any,
stats,
};
const filteredSponsorships = sponsorshipsData.sponsorships.filter((s: any) => {
// For now, we only have leagues in the DTO
if (typeFilter !== 'all' && typeFilter !== 'leagues') return false;
if (searchQuery && !s.leagueName.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 (
<Box maxWidth="7xl" mx="auto" py={8} px={4}>
{/* Header */}
<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
</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>
</Box>
</Stack>
{/* Info Banner about how sponsorships work */}
{stats.pending > 0 && (
<motion.div
initial={shouldReduceMotion ? {} : { opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="mb-6"
>
<InfoBanner type="info" title="Sponsorship Applications">
<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.
</Text>
</InfoBanner>
</motion.div>
)}
{/* Quick Stats */}
<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">
<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
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.05 }}
>
<Card className="p-4">
<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
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 }}
>
<Card className={`p-4 ${stats.pending > 0 ? 'border-warning-amber/30' : ''}`}>
<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
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
>
<Card className="p-4">
<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
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
>
<Card className="p-4">
<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
initial={shouldReduceMotion ? {} : { opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.25 }}
>
<Card className="p-4">
<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>
</Box>
{/* Filters */}
<Stack direction={{ base: 'col', lg: 'row' }} gap={4} mb={6}>
{/* Search */}
<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"
placeholder="Search sponsorships..."
value={searchQuery}
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"
/>
</Box>
{/* Type Filter */}
<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;
const count = type === 'all' ? stats.total : statsByType[type];
return (
<button
key={type}
onClick={() => setTypeFilter(type)}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${
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}
<Text size="xs" px={1.5} py={0.5} rounded="sm" bg={typeFilter === type ? 'bg-white/20' : 'bg-charcoal-outline'}>
{count}
</Text>
</button>
);
})}
</Box>
{/* Status Filter */}
<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' }
: STATUS_CONFIG[status];
const count = status === 'all'
? stats.total
: data.sponsorships.filter((s: any) => s.status === status).length;
return (
<button
key={status}
onClick={() => setStatusFilter(status)}
className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${
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' && (
<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}
</Text>
)}
</button>
);
})}
</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" />
<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.'}
</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" />
Browse Leagues
</Button>
</Link>
<Link href="/teams">
<Button variant="secondary">
<Users className="w-4 h-4 mr-2" />
Browse Teams
</Button>
</Link>
<Link href="/drivers">
<Button variant="secondary">
<Car className="w-4 h-4 mr-2" />
Browse Drivers
</Button>
</Link>
</Stack>
</Card>
) : (
<Box display="grid" gridCols={{ base: 1, lg: 2 }} gap={4}>
{filteredSponsorships.map((sponsorship: any) => (
<SponsorshipCard key={sponsorship.id} sponsorship={sponsorship} />
))}
</Box>
)}
</Box>
<SponsorCampaignsTemplate
viewData={viewData}
filteredSponsorships={filteredSponsorships as any}
typeFilter={typeFilter}
setTypeFilter={setTypeFilter}
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
/>
);
}

View File

@@ -1,92 +1,16 @@
'use client';
import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion';
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,
Mail,
Globe,
Upload,
Save,
Bell,
Shield,
Eye,
Trash2,
CheckCircle,
User,
Phone,
MapPin,
FileText,
Link as LinkIcon,
Image as ImageIcon,
Lock,
Key,
Smartphone,
AlertCircle
} from 'lucide-react';
import { SponsorSettingsTemplate } from '@/templates/SponsorSettingsTemplate';
import { logoutAction } from '@/app/actions/logoutAction';
// ============================================================================
// Types
// ============================================================================
interface SponsorProfile {
companyName: string;
contactName: string;
contactEmail: string;
contactPhone: string;
website: string;
description: string;
logoUrl: string | null;
industry: string;
address: {
street: string;
city: string;
country: string;
postalCode: string;
};
taxId: string;
socialLinks: {
twitter: string;
linkedin: string;
instagram: string;
};
}
interface NotificationSettings {
emailNewSponsorships: boolean;
emailWeeklyReport: boolean;
emailRaceAlerts: boolean;
emailPaymentAlerts: boolean;
emailNewOpportunities: boolean;
emailContractExpiry: boolean;
}
interface PrivacySettings {
publicProfile: boolean;
showStats: boolean;
showActiveSponsorships: boolean;
allowDirectContact: boolean;
}
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
import { useRouter } from 'next/navigation';
// ============================================================================
// Mock Data
// ============================================================================
const MOCK_PROFILE: SponsorProfile = {
const MOCK_PROFILE = {
companyName: 'Acme Racing Co.',
contactName: 'John Smith',
contactEmail: 'sponsor@acme-racing.com',
@@ -109,7 +33,7 @@ const MOCK_PROFILE: SponsorProfile = {
},
};
const MOCK_NOTIFICATIONS: NotificationSettings = {
const MOCK_NOTIFICATIONS = {
emailNewSponsorships: true,
emailWeeklyReport: true,
emailRaceAlerts: false,
@@ -118,581 +42,71 @@ const MOCK_NOTIFICATIONS: NotificationSettings = {
emailContractExpiry: true,
};
const MOCK_PRIVACY: PrivacySettings = {
const MOCK_PRIVACY = {
publicProfile: true,
showStats: false,
showActiveSponsorships: true,
allowDirectContact: true,
};
const INDUSTRY_OPTIONS = [
'Racing Equipment',
'Automotive',
'Technology',
'Gaming & Esports',
'Energy Drinks',
'Apparel',
'Financial Services',
'Other',
];
// ============================================================================
// Components
// ============================================================================
function SavedIndicator({ visible }: { visible: boolean }) {
const shouldReduceMotion = useReducedMotion();
return (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: visible ? 1 : 0, x: visible ? 0 : 20 }}
transition={{ duration: shouldReduceMotion ? 0 : 0.2 }}
className="flex items-center gap-2 text-performance-green"
>
<CheckCircle className="w-4 h-4" />
<span className="text-sm font-medium">Changes saved</span>
</motion.div>
);
}
// ============================================================================
// Main Component
// ============================================================================
export default function SponsorSettingsPage() {
const shouldReduceMotion = useReducedMotion();
const router = useRouter();
const [profile, setProfile] = useState(MOCK_PROFILE);
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS);
const [privacy, setPrivacy] = useState(MOCK_PRIVACY);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const handleSaveProfile = async () => {
setSaving(true);
await new Promise(resolve => setTimeout(resolve, 800));
console.log('Profile saved:', profile);
setSaving(false);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
};
const handleDeleteAccount = async () => {
if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) {
// Call the logout action and handle result
const result = await logoutAction();
if (result.isErr()) {
console.error('Logout failed:', result.getError());
// Could show error toast here
return;
}
// Redirect to login after successful logout
window.location.href = '/auth/login';
setIsDeleting(true);
const result = await logoutAction();
if (result.isErr()) {
console.error('Logout failed:', result.getError());
setIsDeleting(false);
return;
}
router.push('/auth/login');
};
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: shouldReduceMotion ? 0 : 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
const viewData = {
profile: MOCK_PROFILE,
notifications: MOCK_NOTIFICATIONS,
privacy: MOCK_PRIVACY,
};
return (
<Box
maxWidth="4xl"
mx="auto"
py={8}
px={4}
as={motion.div}
// @ts-ignore
variants={containerVariants}
initial="hidden"
animate="visible"
>
{/* Header */}
<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} />}
/>
</Box>
{/* Company Profile */}
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Building2}
title="Company Profile"
description="Your public-facing company information"
/>
<Box p={6} className="space-y-6">
{/* Company Basic Info */}
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Company Name" icon={Building2} required>
<Input
type="text"
value={profile.companyName}
onChange={(e) => setProfile({ ...profile, companyName: e.target.value })}
placeholder="Your company name"
/>
</FormField>
<FormField label="Industry">
<Box as="select"
value={profile.industry}
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>
))}
</Box>
</FormField>
</Box>
{/* Contact Information */}
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Contact Information
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 2 }} gap={6}>
<FormField label="Contact Name" icon={User} required>
<Input
type="text"
value={profile.contactName}
onChange={(e) => setProfile({ ...profile, contactName: e.target.value })}
placeholder="Full name"
/>
</FormField>
<FormField label="Contact Email" icon={Mail} required>
<Input
type="email"
value={profile.contactEmail}
onChange={(e) => setProfile({ ...profile, contactEmail: e.target.value })}
placeholder="sponsor@company.com"
/>
</FormField>
<FormField label="Phone Number" icon={Phone}>
<Input
type="tel"
value={profile.contactPhone}
onChange={(e) => setProfile({ ...profile, contactPhone: e.target.value })}
placeholder="+1 (555) 123-4567"
/>
</FormField>
<FormField label="Website" icon={Globe}>
<Input
type="url"
value={profile.website}
onChange={(e) => setProfile({ ...profile, website: e.target.value })}
placeholder="https://company.com"
/>
</FormField>
</Box>
</Box>
{/* Address */}
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Business Address
</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"
value={profile.address.street}
onChange={(e) => setProfile({
...profile,
address: { ...profile.address, street: e.target.value }
})}
placeholder="123 Main Street"
/>
</FormField>
</Box>
<FormField label="City">
<Input
type="text"
value={profile.address.city}
onChange={(e) => setProfile({
...profile,
address: { ...profile.address, city: e.target.value }
})}
placeholder="City"
/>
</FormField>
<FormField label="Postal Code">
<Input
type="text"
value={profile.address.postalCode}
onChange={(e) => setProfile({
...profile,
address: { ...profile.address, postalCode: e.target.value }
})}
placeholder="12345"
/>
</FormField>
<FormField label="Country">
<Input
type="text"
value={profile.address.country}
onChange={(e) => setProfile({
...profile,
address: { ...profile.address, country: e.target.value }
})}
placeholder="Country"
/>
</FormField>
<FormField label="Tax ID / VAT Number" icon={FileText}>
<Input
type="text"
value={profile.taxId}
onChange={(e) => setProfile({ ...profile, taxId: e.target.value })}
placeholder="XX12-3456789"
/>
</FormField>
</Box>
</Box>
{/* Description */}
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<FormField label="Company Description">
<Box as="textarea"
value={profile.description}
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}
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"
/>
<Text size="xs" color="text-gray-500" block mt={1}>
This description appears on your public sponsor profile.
</Text>
</FormField>
</Box>
{/* Social Links */}
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<Heading level={3} fontSize="sm" weight="semibold" color="text-gray-400" mb={4}>
Social Media
</Heading>
<Box display="grid" gridCols={{ base: 1, md: 3 }} gap={6}>
<FormField label="Twitter / X" icon={LinkIcon}>
<Input
type="text"
value={profile.socialLinks.twitter}
onChange={(e) => setProfile({
...profile,
socialLinks: { ...profile.socialLinks, twitter: e.target.value }
})}
placeholder="@username"
/>
</FormField>
<FormField label="LinkedIn" icon={LinkIcon}>
<Input
type="text"
value={profile.socialLinks.linkedin}
onChange={(e) => setProfile({
...profile,
socialLinks: { ...profile.socialLinks, linkedin: e.target.value }
})}
placeholder="company-name"
/>
</FormField>
<FormField label="Instagram" icon={LinkIcon}>
<Input
type="text"
value={profile.socialLinks.instagram}
onChange={(e) => setProfile({
...profile,
socialLinks: { ...profile.socialLinks, instagram: e.target.value }
})}
placeholder="@username"
/>
</FormField>
</Box>
</Box>
{/* Logo Upload */}
<Box pt={4} borderTop borderColor="border-charcoal-outline/50">
<FormField label="Company Logo" icon={ImageIcon}>
<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 ? (
<Image src={profile.logoUrl} alt="Company logo" width={96} height={96} objectFit="cover" />
) : (
<Building2 className="w-10 h-10 text-gray-600" />
)}
</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"
/>
<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" />
<Text>Upload Logo</Text>
</Box>
</Text>
{profile.logoUrl && (
<Button variant="secondary" className="text-sm text-gray-400">
Remove
</Button>
)}
</Stack>
<Text size="xs" color="text-gray-500" block mt={2}>
PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px.
</Text>
</Box>
</Stack>
</FormField>
</Box>
{/* Save Button */}
<Box pt={6} borderTop borderColor="border-charcoal-outline" display="flex" alignItems="center" justifyContent="end" gap={4}>
<Button
variant="primary"
onClick={handleSaveProfile}
disabled={saving}
className="min-w-[160px]"
>
{saving ? (
<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>
) : (
<Stack direction="row" align="center" gap={2}>
<Save className="w-4 h-4" />
<Text>Save Profile</Text>
</Stack>
)}
</Button>
</Box>
</Box>
</Card>
</Box>
{/* Notification Preferences */}
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Bell}
title="Email Notifications"
description="Control which emails you receive from GridPilot"
color="text-warning-amber"
/>
<Box p={6}>
<Box className="space-y-1">
<Toggle
checked={notifications.emailNewSponsorships}
onChange={(checked) => setNotifications({ ...notifications, emailNewSponsorships: checked })}
label="Sponsorship Approvals"
description="Receive confirmation when your sponsorship requests are approved"
/>
<Toggle
checked={notifications.emailWeeklyReport}
onChange={(checked) => setNotifications({ ...notifications, emailWeeklyReport: checked })}
label="Weekly Analytics Report"
description="Get a weekly summary of your sponsorship performance"
/>
<Toggle
checked={notifications.emailRaceAlerts}
onChange={(checked) => setNotifications({ ...notifications, emailRaceAlerts: checked })}
label="Race Day Alerts"
description="Be notified when sponsored leagues have upcoming races"
/>
<Toggle
checked={notifications.emailPaymentAlerts}
onChange={(checked) => setNotifications({ ...notifications, emailPaymentAlerts: checked })}
label="Payment & Invoice Notifications"
description="Receive invoices and payment confirmations"
/>
<Toggle
checked={notifications.emailNewOpportunities}
onChange={(checked) => setNotifications({ ...notifications, emailNewOpportunities: checked })}
label="New Sponsorship Opportunities"
description="Get notified about new leagues and drivers seeking sponsors"
/>
<Toggle
checked={notifications.emailContractExpiry}
onChange={(checked) => setNotifications({ ...notifications, emailContractExpiry: checked })}
label="Contract Expiry Reminders"
description="Receive reminders before your sponsorship contracts expire"
/>
</Box>
</Box>
</Card>
</Box>
{/* Privacy & Visibility */}
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Eye}
title="Privacy & Visibility"
description="Control how your profile appears to others"
color="text-performance-green"
/>
<Box p={6}>
<Box className="space-y-1">
<Toggle
checked={privacy.publicProfile}
onChange={(checked) => setPrivacy({ ...privacy, publicProfile: checked })}
label="Public Profile"
description="Allow leagues, teams, and drivers to view your sponsor profile"
/>
<Toggle
checked={privacy.showStats}
onChange={(checked) => setPrivacy({ ...privacy, showStats: checked })}
label="Show Sponsorship Statistics"
description="Display your total sponsorships and investment amounts"
/>
<Toggle
checked={privacy.showActiveSponsorships}
onChange={(checked) => setPrivacy({ ...privacy, showActiveSponsorships: checked })}
label="Show Active Sponsorships"
description="Let others see which leagues and teams you currently sponsor"
/>
<Toggle
checked={privacy.allowDirectContact}
onChange={(checked) => setPrivacy({ ...privacy, allowDirectContact: checked })}
label="Allow Direct Contact"
description="Enable leagues and teams to send you sponsorship proposals"
/>
</Box>
</Box>
</Card>
</Box>
{/* Security */}
<Box as={motion.div} variants={itemVariants}>
<Card className="mb-6 overflow-hidden">
<SectionHeader
icon={Shield}
title="Account Security"
description="Protect your sponsor account"
color="text-primary-blue"
/>
<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" />
</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>
</Box>
<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" />
</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>
</Box>
<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" />
</Box>
<Box>
<Text color="text-gray-200" weight="medium" block>Active Sessions</Text>
<Text size="sm" color="text-gray-500" block>Manage devices where you&apos;re logged in</Text>
</Box>
</Stack>
<Button variant="secondary">
View Sessions
</Button>
</Box>
</Box>
</Card>
</Box>
{/* Danger Zone */}
<Box as={motion.div} variants={itemVariants}>
<Card className="border-racing-red/30 overflow-hidden">
<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
</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" />
</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.
</Text>
</Box>
</Stack>
<Button
variant="secondary"
onClick={handleDeleteAccount}
className="text-racing-red border-racing-red/30 hover:bg-racing-red/10"
>
Delete Account
</Button>
</Box>
</Box>
</Card>
</Box>
</Box>
<>
<SponsorSettingsTemplate
viewData={viewData}
profile={profile}
setProfile={setProfile as any}
notifications={notifications}
setNotifications={setNotifications as any}
onSaveProfile={handleSaveProfile}
onDeleteAccount={() => setIsDeleteDialogOpen(true)}
saving={saving}
saved={saved}
/>
<ConfirmDialog
isOpen={isDeleteDialogOpen}
onClose={() => setIsDeleteDialogOpen(false)}
onConfirm={handleDeleteAccount}
title="Delete Sponsor Account"
description="Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data, contracts, and history will be permanently removed."
confirmLabel="Delete Account"
variant="danger"
isLoading={isDeleting}
/>
</>
);
}

View File

@@ -36,13 +36,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
alert('Remove member functionality would be implemented here');
};
const handleChangeRole = (driverId: string, newRole: 'owner' | 'admin' | 'member') => {
// This would call an API to change the role
console.log('Change role:', driverId, newRole);
// In a real implementation, you'd have a mutation hook here
alert('Change role functionality would be implemented here');
};
const handleGoBack = () => {
router.back();
};
@@ -55,7 +48,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
onTabChange={handleTabChange}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
);

View File

@@ -1,56 +1,15 @@
import { PageWrapper } from '@/components/shared/state/PageWrapper';
import { TeamService } from '@/lib/services/teams/TeamService';
import { Trophy } from 'lucide-react';
import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { notFound } from 'next/navigation';
import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery';
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default async function TeamLeaderboardPage() {
// Manual wiring: create dependencies
const service = new TeamService();
const query = new TeamLeaderboardPageQuery();
const result = await query.execute();
// Fetch data through service
const result = await service.getAllTeams();
// Handle result
let data = null;
let error = null;
if (result.isOk()) {
data = result.unwrap().map(t => new TeamSummaryViewModel(t));
} else {
const domainError = result.getError();
error = new Error(domainError.message);
if (result.isErr()) {
notFound();
}
const hasData = (data?.length ?? 0) > 0;
// Handle loading state (should be fast since we're using async/await)
const isLoading = false;
const retry = () => {
// In server components, we can't retry without a reload
redirect(routes.team.detail('leaderboard'));
};
return (
<PageWrapper
data={hasData ? data : null}
isLoading={isLoading}
error={error}
retry={retry}
Template={TeamLeaderboardPageWrapper}
loading={{ variant: 'full-screen', message: 'Loading team leaderboard...' }}
errorConfig={{ variant: 'full-screen' }}
empty={{
icon: Trophy,
title: 'No teams found',
description: 'There are no teams in the system yet.',
}}
/>
);
const data = result.unwrap();
return <TeamLeaderboardPageWrapper data={data.teams} />;
}