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

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

View File

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

View File

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

View File

@@ -4,8 +4,9 @@ import { useState, useCallback } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; import { useRouter, useSearchParams } from 'next/navigation';
import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate'; import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; 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 { routes } from '@/lib/routing/RouteConfig';
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
interface AdminUsersWrapperProps { interface AdminUsersWrapperProps {
initialViewData: AdminUsersViewData; initialViewData: AdminUsersViewData;
@@ -19,12 +20,35 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [deletingUser, setDeletingUser] = 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 // Current filter values from URL
const search = searchParams.get('search') || ''; const search = searchParams.get('search') || '';
const roleFilter = searchParams.get('role') || ''; const roleFilter = searchParams.get('role') || '';
const statusFilter = searchParams.get('status') || ''; 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) // Callbacks that update URL (triggers RSC re-render)
const handleSearch = useCallback((newSearch: string) => { const handleSearch = useCallback((newSearch: string) => {
const params = new URLSearchParams(searchParams); const params = new URLSearchParams(searchParams);
@@ -79,13 +103,16 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
}, [router]); }, [router]);
const handleDeleteUser = useCallback(async (userId: string) => { const handleDeleteUser = useCallback(async (userId: string) => {
if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { setUserToDelete(userId);
return; }, []);
}
const confirmDeleteUser = useCallback(async () => {
if (!userToDelete) return;
try { try {
setDeletingUser(userId); setDeletingUser(userToDelete);
const result = await deleteUser(userId); setError(null);
const result = await deleteUser(userToDelete);
if (result.isErr()) { if (result.isErr()) {
setError(result.getError()); setError(result.getError());
@@ -94,29 +121,46 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) {
// Revalidate data // Revalidate data
router.refresh(); router.refresh();
setUserToDelete(null);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete user'); setError(err instanceof Error ? err.message : 'Failed to delete user');
} finally { } finally {
setDeletingUser(null); setDeletingUser(null);
} }
}, [router]); }, [router, userToDelete]);
return ( return (
<AdminUsersTemplate <>
viewData={initialViewData} <AdminUsersTemplate
onRefresh={handleRefresh} viewData={initialViewData}
onSearch={handleSearch} onRefresh={handleRefresh}
onFilterRole={handleFilterRole} onSearch={handleSearch}
onFilterStatus={handleFilterStatus} onFilterRole={handleFilterRole}
onClearFilters={handleClearFilters} onFilterStatus={handleFilterStatus}
onUpdateStatus={handleUpdateStatus} onClearFilters={handleClearFilters}
onDeleteUser={handleDeleteUser} onUpdateStatus={handleUpdateStatus}
search={search} onDeleteUser={handleDeleteUser}
roleFilter={roleFilter} search={search}
statusFilter={statusFilter} roleFilter={roleFilter}
loading={loading} statusFilter={statusFilter}
error={error} loading={loading}
deletingUser={deletingUser} 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 { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { createRouteGuard } from '@/lib/auth/createRouteGuard'; import { createRouteGuard } from '@/lib/auth/createRouteGuard';
import { AuthContainer } from '@/ui/AuthContainer'; import { AuthShell } from '@/components/auth/AuthShell';
interface AuthLayoutProps { interface AuthLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@@ -27,5 +27,5 @@ export default async function AuthLayout({ children }: AuthLayoutProps) {
redirect(result.to); 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({ export default function DashboardLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return <DashboardLayoutWrapper>{children}</DashboardLayoutWrapper>; return <>{children}</>;
} }

View File

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

View File

@@ -1,10 +1,9 @@
'use client'; '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 { useRouter } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { GlobalErrorScreen } from '@/components/errors/GlobalErrorScreen';
import './globals.css';
export default function GlobalError({ export default function GlobalError({
error, error,
@@ -16,24 +15,14 @@ export default function GlobalError({
const router = useRouter(); const router = useRouter();
return ( return (
<html lang="en"> <html lang="en" className="dark scroll-smooth overflow-x-hidden">
<body className="antialiased"> <body className="antialiased bg-base-black text-white overflow-x-hidden">
<ErrorPageContainer <GlobalErrorScreen
errorCode="Error" error={error}
description={error?.message || 'An unexpected error occurred.'} reset={reset}
> onHome={() => router.push(routes.public.home)}
{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>
</body> </body>
</html> </html>
); );
} }

View File

@@ -6,13 +6,29 @@
@layer base { @layer base {
:root { :root {
--color-deep-graphite: #0E0F11; /* Core Theme Colors (from THEME.md) */
--color-iron-gray: #181B1F; --color-base: #0C0D0F;
--color-charcoal-outline: #22262A; --color-surface: #141619;
--color-primary-blue: #198CFF; --color-outline: #23272B;
--color-performance-green: #6FE37A; --color-primary: #198CFF;
--color-warning-amber: #FFC556; --color-telemetry: #4ED4E0;
--color-neon-aqua: #43C9E6; --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); --sat: env(safe-area-inset-top);
--sar: env(safe-area-inset-right); --sar: env(safe-area-inset-right);
--sab: env(safe-area-inset-bottom); --sab: env(safe-area-inset-bottom);
@@ -21,192 +37,146 @@
* { * {
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-touch-callout: none; border-color: var(--color-outline);
} }
html { html {
overscroll-behavior: none; overscroll-behavior: none;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
scroll-behavior: smooth; 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 { 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; 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; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; touch-action: manipulation;
} }
.scroll-container { a:hover {
-webkit-overflow-scrolling: touch; opacity: 0.8;
scroll-behavior: smooth;
} }
/* Mobile typography optimization - lighter and more spacious */ button {
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
}
/* Mobile typography optimization */
@media (max-width: 640px) { @media (max-width: 640px) {
h1 { h1 { font-size: clamp(1.5rem, 6vw, 2rem); }
font-size: clamp(1.5rem, 6vw, 2rem); h2 { font-size: clamp(1.125rem, 4.5vw, 1.5rem); }
font-weight: 600; h3 { font-size: 1.25rem; }
line-height: 1.2; p { font-size: 0.875rem; }
}
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;
}
} }
} }
@layer utilities { @layer utilities {
.animate-spring { /* Precision Racing Utilities */
transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); .glass-panel {
background: rgba(20, 22, 25, 0.7);
backdrop-filter: blur(12px);
border: 1px solid var(--color-outline);
} }
/* Racing stripe patterns */ .subtle-gradient {
.racing-stripes { background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, transparent 100%);
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;
} }
/* Checkered flag pattern */ .racing-border {
.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 {
position: relative; position: relative;
} }
.racing-accent::before { .racing-border::after {
content: ''; content: '';
position: absolute; position: absolute;
left: -16px;
top: 0;
bottom: 0; bottom: 0;
width: 3px; left: 0;
background: linear-gradient(to bottom, #FF0000, #198CFF); width: 100%;
border-radius: 2px; height: 1px;
} background: linear-gradient(90deg, var(--color-primary) 0%, transparent 100%);
opacity: 0.5;
/* 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;
} }
/* Racing red-white-blue animated gradient */ /* Instrument-grade glows */
@keyframes racing-gradient { .glow-primary {
0% { box-shadow: 0 0 20px -5px rgba(25, 140, 255, 0.3);
background-position: 0% 50%;
}
50% {
background-position: 100% 50%;
}
100% {
background-position: 0% 50%;
}
} }
.animate-racing-gradient { .glow-telemetry {
background: linear-gradient( box-shadow: 0 0 20px -5px rgba(78, 212, 224, 0.3);
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;
}
} }
/* Entrance animations */ /* Entrance animations */
@keyframes fade-in-up { @keyframes fade-in-up {
from { from {
opacity: 0; opacity: 0;
transform: translateY(20px); transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fade-in {
from {
opacity: 0;
} }
to { to {
opacity: 1; opacity: 1;
@@ -215,19 +185,14 @@
} }
.animate-fade-in-up { .animate-fade-in-up {
animation: fade-in-up 0.6s ease-out forwards; animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-fade-in {
animation: fade-in 0.4s ease-out forwards;
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.animate-fade-in-up, .animate-fade-in-up {
.animate-fade-in {
animation: none; animation: none;
opacity: 1; opacity: 1;
transform: none; transform: none;
} }
} }
} }

View File

@@ -5,9 +5,7 @@ import { Metadata, Viewport } from 'next';
import React from 'react'; import React from 'react';
import './globals.css'; import './globals.css';
import { AppWrapper } from '@/components/AppWrapper'; import { AppWrapper } from '@/components/AppWrapper';
import { Header } from '@/ui/Header'; import { RootAppShellTemplate } from '@/templates/layout/RootAppShellTemplate';
import { HeaderContent } from '@/components/layout/HeaderContent';
import { MainContent } from '@/ui/MainContent';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -76,12 +74,9 @@ export default async function RootLayout({
</head> </head>
<body className="antialiased overflow-x-hidden"> <body className="antialiased overflow-x-hidden">
<AppWrapper enabledFlags={enabledFlags}> <AppWrapper enabledFlags={enabledFlags}>
<Header> <RootAppShellTemplate>
<HeaderContent />
</Header>
<MainContent>
{children} {children}
</MainContent> </RootAppShellTemplate>
</AppWrapper> </AppWrapper>
</body> </body>
</html> </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 { notFound, redirect } from 'next/navigation';
import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery'; import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery';
import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate'; import { DriverRankingsPageClient } from './DriverRankingsPageClient';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
export default async function DriverLeaderboardPage() { export default async function DriverLeaderboardPage() {
@@ -23,5 +23,5 @@ export default async function DriverLeaderboardPage() {
// Success // Success
const viewData = result.unwrap(); const viewData = result.unwrap();
return <DriverRankingsTemplate viewData={viewData} />; return <DriverRankingsPageClient viewData={viewData} />;
} }

View File

@@ -1,42 +1,32 @@
'use client'; '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 { routes } from '@/lib/routing/RouteConfig';
import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder';
import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData';
import { Box } from '@/ui/Box'; 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 { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import { Button } from '@/ui/Button';
import { Input } from '@/ui/Input';
import { import {
Award,
ChevronLeft,
ChevronRight,
Clock,
Filter,
Flag,
Flame, Flame,
Globe, Globe,
Plus, Plus,
Search, Search,
Sparkles, Sparkles,
Target, Target,
Timer,
Trophy, Trophy,
Users, Users,
Flag,
Award,
Timer,
Clock,
type LucideIcon, type LucideIcon,
} from 'lucide-react'; } from 'lucide-react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useCallback, useRef, useState } from 'react'; import { getMediaUrl } from '@/lib/utilities/media';
// ============================================================================ // ============================================================================
// TYPES // TYPES
@@ -50,12 +40,9 @@ type CategoryId =
| 'trophy' | 'trophy'
| 'new' | 'new'
| 'popular' | 'popular'
| 'iracing' | 'openSlots'
| 'acc'
| 'f1'
| 'endurance' | 'endurance'
| 'sprint' | 'sprint';
| 'openSlots';
interface Category { interface Category {
id: CategoryId; id: CategoryId;
@@ -66,17 +53,6 @@ interface Category {
color?: string; color?: string;
} }
interface LeagueSliderProps {
title: string;
icon: LucideIcon;
description: string;
leagues: LeaguesViewData['leagues'];
autoScroll?: boolean;
iconColor?: string;
scrollSpeedMultiplier?: number;
scrollDirection?: 'left' | 'right';
}
interface LeaguesTemplateProps { interface LeaguesTemplateProps {
viewData: LeaguesViewData; viewData: LeaguesViewData;
} }
@@ -114,7 +90,7 @@ const CATEGORIES: Category[] = [
oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
return new Date(league.createdAt) > oneWeekAgo; return new Date(league.createdAt) > oneWeekAgo;
}, },
color: 'text-performance-green', color: 'text-green-500',
}, },
{ {
id: 'openSlots', id: 'openSlots',
@@ -122,17 +98,15 @@ const CATEGORIES: Category[] = [
icon: Target, icon: Target,
description: 'Leagues with available spots', description: 'Leagues with available spots',
filter: (league) => { filter: (league) => {
// Check for team slots if it's a team league
if (league.maxTeams && league.maxTeams > 0) { if (league.maxTeams && league.maxTeams > 0) {
const usedTeams = league.usedTeamSlots ?? 0; const usedTeams = league.usedTeamSlots ?? 0;
return usedTeams < league.maxTeams; return usedTeams < league.maxTeams;
} }
// Otherwise check driver slots
const used = league.usedDriverSlots ?? 0; const used = league.usedDriverSlots ?? 0;
const max = league.maxDrivers ?? 0; const max = league.maxDrivers ?? 0;
return max > 0 && used < max; return max > 0 && used < max;
}, },
color: 'text-neon-aqua', color: 'text-cyan-400',
}, },
{ {
id: 'driver', id: 'driver',
@@ -183,459 +157,132 @@ const CATEGORIES: Category[] = [
}, },
]; ];
// ============================================================================ export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) {
// LEAGUE SLIDER COMPONENT const router = useRouter();
// ============================================================================ const [searchQuery, setSearchQuery] = useState('');
const [activeCategory, setActiveCategory] = useState<CategoryId>('all');
function LeagueSlider({ const filteredLeagues = viewData.leagues.filter((league) => {
title, const matchesSearch = !searchQuery ||
icon: Icon, league.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
description, (league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase());
leagues,
autoScroll = true, const category = CATEGORIES.find(c => c.id === activeCategory);
iconColor = 'text-primary-blue', const matchesCategory = !category || category.filter(league);
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 checkScrollButtons = useCallback(() => { return matchesSearch && matchesCategory;
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();
}); });
// Setup auto-scroll effect
useState(() => {
setupAutoScroll();
});
// Setup manual scroll effect
useState(() => {
setupManualScroll();
});
if (leagues.length === 0) return null;
return ( return (
<Box mb={10}> <Box minHeight="screen" bg="zinc-950" color="text-zinc-200">
{/* Section header */} <Box maxWidth="7xl" mx="auto" px={{ base: 4, sm: 6, lg: 8 }} py={12}>
<Box display="flex" alignItems="center" justifyContent="between" mb={4}> {/* Hero */}
<Stack direction="row" align="center" gap={3}> <Box as="header" display="flex" flexDirection={{ base: 'col', md: 'row' }} alignItems={{ base: 'start', md: 'end' }} justifyContent="between" gap={8} mb={16}>
<Box display="flex" h={10} w={10} alignItems="center" justifyContent="center" rounded="xl" bg="bg-iron-gray" border borderColor="border-charcoal-outline"> <Stack gap={4}>
<UIIcon icon={Icon} size={5} color={iconColor} /> <Box display="flex" alignItems="center" gap={3} color="text-blue-500">
</Box> <Trophy size={24} />
<Box> <Text fontSize="xs" weight="bold" uppercase letterSpacing="widest">Competition Hub</Text>
<Heading level={2}>{title}</Heading> </Box>
<Text size="xs" color="text-gray-500">{description}</Text> <Heading level={1} fontSize="5xl" weight="bold" color="text-white">
</Box> Find Your <Text as="span" color="text-blue-500">Grid</Text>
<Box as="span" ml={2} px={2} py={0.5} rounded="full" fontSize="0.75rem" bg="bg-charcoal-outline/50" color="text-gray-400"> </Heading>
{leagues.length} <Text color="text-zinc-400" maxWidth="md" leading="relaxed">
</Box> From casual sprints to epic endurance battles discover the perfect league for your racing style.
</Stack> </Text>
</Stack>
{/* Navigation arrows */} <Box display="flex" alignItems="center" gap={4}>
<Stack direction="row" align="center" gap={2}> <Box display="flex" flexDirection="col" alignItems="end">
<Button <Text fontSize="2xl" weight="bold" color="text-white" font="mono">{viewData.leagues.length}</Text>
type="button" <Text weight="bold" color="text-zinc-500" uppercase letterSpacing="widest" fontSize="10px">Active Leagues</Text>
variant="secondary" </Box>
onClick={() => scroll('left')} <Box w="px" h="8" bg="zinc-800" />
disabled={!canScrollLeft} <Button
size="sm" onClick={() => router.push(routes.league.create)}
w="2rem" variant="primary"
h="2rem" size="lg"
p={0} >
> <Stack direction="row" align="center" gap={2}>
<UIIcon icon={ChevronLeft} size={4} /> <Plus size={16} />
</Button> Create League
<Button </Stack>
type="button" </Button>
variant="secondary" </Box>
onClick={() => scroll('right')} </Box>
disabled={!canScrollRight}
size="sm"
w="2rem"
h="2rem"
p={0}
>
<UIIcon icon={ChevronRight} size={4} />
</Button>
</Stack>
</Box>
{/* Scrollable container with fade edges */} {/* Search & Filters */}
<Box position="relative"> <Box as="section" display="flex" flexDirection="col" gap={8} mb={12}>
{/* Left fade gradient */} <Input
<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" /> type="text"
{/* Right fade gradient */} placeholder="Search leagues by name, description, or game..."
<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" /> value={searchQuery}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
<Box icon={<Search size={20} />}
ref={scrollRef} />
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)} <Box as="nav" display="flex" flexWrap="wrap" gap={2}>
display="flex" {CATEGORIES.map((category) => {
gap={4} const isActive = activeCategory === category.id;
overflow="auto" const CategoryIcon = category.icon;
pb={4} return (
px={4} <Button
hideScrollbar key={category.id}
> onClick={() => setActiveCategory(category.id)}
{leagues.map((league) => { variant={isActive ? 'primary' : 'secondary'}
const viewModel = LeagueSummaryViewModelBuilder.build(league); size="sm"
>
return ( <Stack direction="row" align="center" gap={2}>
<Box key={league.id} flexShrink={0} w="320px" h="full"> <Box
<UILink href={routes.league.detail(league.id)} block h="full"> color={!isActive && category.color ? category.color : undefined}
<LeagueCard league={viewModel} /> >
</UILink> <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> </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> </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 { notFound } from 'next/navigation';
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate';
import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery';
import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder';
import { ErrorBanner } from '@/ui/ErrorBanner'; import { ErrorBanner } from '@/ui/ErrorBanner';
@@ -49,8 +49,6 @@ export default async function Page({ params }: Props) {
}); });
return ( return (
<LeagueDetailTemplate viewData={viewData} tabs={[]}> <LeagueOverviewTemplate viewData={viewData} />
{null}
</LeagueDetailTemplate>
); );
} }

View File

@@ -16,13 +16,14 @@ import {
createRaceAction, createRaceAction,
updateRaceAction, updateRaceAction,
deleteRaceAction deleteRaceAction
} from './actions'; } from '@/app/actions/leagueScheduleActions';
import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel'; import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
export function LeagueAdminSchedulePageClient() { export function LeagueAdminSchedulePageClient() {
const params = useParams(); const params = useParams();
@@ -39,6 +40,8 @@ export function LeagueAdminSchedulePageClient() {
const [isPublishing, setIsPublishing] = useState(false); const [isPublishing, setIsPublishing] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [deletingRaceId, setDeletingRaceId] = useState<string | null>(null); 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 // Check admin status using domain hook
const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId); const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId);
@@ -48,7 +51,7 @@ export function LeagueAdminSchedulePageClient() {
// Auto-select season // Auto-select season
const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0 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 // Load schedule using domain hook
@@ -65,6 +68,7 @@ export function LeagueAdminSchedulePageClient() {
if (!schedule || !selectedSeasonId) return; if (!schedule || !selectedSeasonId) return;
setIsPublishing(true); setIsPublishing(true);
setError(null);
try { try {
const result = schedule.published const result = schedule.published
? await unpublishScheduleAction(leagueId, selectedSeasonId) ? await unpublishScheduleAction(leagueId, selectedSeasonId)
@@ -73,7 +77,7 @@ export function LeagueAdminSchedulePageClient() {
if (result.isOk()) { if (result.isOk()) {
router.refresh(); router.refresh();
} else { } else {
alert(result.getError()); setError(result.getError());
} }
} finally { } finally {
setIsPublishing(false); setIsPublishing(false);
@@ -89,6 +93,7 @@ export function LeagueAdminSchedulePageClient() {
} }
setIsSaving(true); setIsSaving(true);
setError(null);
try { try {
const result = !editingRaceId const result = !editingRaceId
? await createRaceAction(leagueId, selectedSeasonId, form.toCommand()) ? await createRaceAction(leagueId, selectedSeasonId, form.toCommand())
@@ -100,7 +105,7 @@ export function LeagueAdminSchedulePageClient() {
setEditingRaceId(null); setEditingRaceId(null);
router.refresh(); router.refresh();
} else { } else {
alert(result.getError()); setError(result.getError());
} }
} finally { } finally {
setIsSaving(false); setIsSaving(false);
@@ -120,18 +125,22 @@ export function LeagueAdminSchedulePageClient() {
})); }));
}; };
const handleDelete = async (raceId: string) => { const handleDelete = (raceId: string) => {
if (!selectedSeasonId) return; setRaceToDelete(raceId);
const confirmed = window.confirm('Delete this race?'); };
if (!confirmed) return;
const confirmDelete = async () => {
if (!selectedSeasonId || !raceToDelete) return;
setDeletingRaceId(raceId); setDeletingRaceId(raceToDelete);
setError(null);
try { try {
const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId); const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete);
if (result.isOk()) { if (result.isOk()) {
router.refresh(); router.refresh();
setRaceToDelete(null);
} else { } else {
alert(result.getError()); setError(result.getError());
} }
} finally { } finally {
setDeletingRaceId(null); setDeletingRaceId(null);
@@ -186,34 +195,47 @@ export function LeagueAdminSchedulePageClient() {
if (!data) return null; if (!data) return null;
return ( return (
<LeagueAdminScheduleTemplate <>
viewData={data} <LeagueAdminScheduleTemplate
onSeasonChange={handleSeasonChange} viewData={data}
onPublishToggle={handlePublishToggle} onSeasonChange={handleSeasonChange}
onAddOrSave={handleAddOrSave} onPublishToggle={handlePublishToggle}
onEdit={handleEdit} onAddOrSave={handleAddOrSave}
onDelete={handleDelete} onEdit={handleEdit}
onCancelEdit={handleCancelEdit} onDelete={handleDelete}
track={form.track} onCancelEdit={handleCancelEdit}
car={form.car} track={form.track}
scheduledAtIso={form.scheduledAtIso} car={form.car}
editingRaceId={editingRaceId} scheduledAtIso={form.scheduledAtIso}
isPublishing={isPublishing} editingRaceId={editingRaceId}
isSaving={isSaving} isPublishing={isPublishing}
isDeleting={deletingRaceId} isSaving={isSaving}
setTrack={(val) => { isDeleting={deletingRaceId}
form.track = val; error={error}
setForm(new RaceScheduleCommandModel(form.toCommand())); setTrack={(val) => {
}} form.track = val;
setCar={(val) => { setForm(new RaceScheduleCommandModel(form.toCommand()));
form.car = val; }}
setForm(new RaceScheduleCommandModel(form.toCommand())); setCar={(val) => {
}} form.car = val;
setScheduledAtIso={(val) => { setForm(new RaceScheduleCommandModel(form.toCommand()));
form.scheduledAtIso = 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'; 'use client';
import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel';
import { PenaltyFAB } from '@/ui/PenaltyFAB'; import { PenaltyFAB } from '@/ui/PenaltyFAB';
import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal'; import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal';
import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal';
@@ -8,12 +9,10 @@ import { Button } from '@/ui/Button';
import { Card } from '@/ui/Card'; import { Card } from '@/ui/Card';
import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations"; import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations";
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { PendingProtestsList } from '@/components/leagues/PendingProtestsList';
import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList'; import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading';
import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData';
import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel';
import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; import { RaceViewModel } from '@/lib/view-models/RaceViewModel';
@@ -26,7 +25,7 @@ interface StewardingTemplateProps {
onRefetch: () => void; 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 [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null); const [selectedProtest, setSelectedProtest] = useState<ProtestViewModel | null>(null);
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
@@ -36,19 +35,16 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc
// Flatten protests for the specialized list components // Flatten protests for the specialized list components
const allPendingProtests = useMemo(() => { 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, id: p.id,
protestingDriverId: p.protestingDriverId, raceName: r.track || 'Unknown Track',
accusedDriverId: p.accusedDriverId, 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, description: p.incident.description,
submittedAt: p.filedAt, submittedAt: p.filedAt,
status: p.status, status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected',
raceId: r.id, })));
incident: p.incident, }, [data.races, data.drivers]);
proofVideoUrl: p.proofVideoUrl,
decisionNotes: p.decisionNotes,
} as never)));
}, [data.races]);
const allResolvedProtests = useMemo(() => { const allResolvedProtests = useMemo(() => {
return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({ 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 ( return (
<Stack gap={6}> <Stack gap={6}>
<Card> <StewardingStats
<Box p={6}> totalPending={data.totalPending}
<Box display="flex" alignItems="center" justifyContent="between" mb={6}> totalResolved={data.totalResolved}
<Box> totalPenalties={data.totalPenalties}
<Heading level={2}>Stewarding</Heading> />
<Box mt={1}>
<Text size="sm" color="text-gray-400"> {/* Tab navigation */}
Quick overview of protests and penalties across all races <Box borderBottom borderColor="border-charcoal-outline">
</Text> <Stack direction="row" gap={4}>
</Box> <Box
</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>
<Box
{/* Stats summary */} borderBottom={activeTab === 'history'}
<StewardingStats borderColor={activeTab === 'history' ? 'border-primary-blue' : undefined}
totalPending={data.totalPending} >
totalResolved={data.totalResolved} <Button
totalPenalties={data.totalPenalties} variant="ghost"
/> onClick={() => setActiveTab('history')}
rounded="none"
{/* Tab navigation */} >
<Box borderBottom borderColor="border-charcoal-outline" mb={6}> <Text weight="medium" color={activeTab === 'history' ? 'text-primary-blue' : undefined}>History</Text>
<Stack direction="row" gap={4}> </Button>
<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> </Box>
</Stack>
</Box>
{/* Content */} {/* Content */}
{activeTab === 'pending' ? ( {activeTab === 'pending' ? (
<PendingProtestsList <StewardingQueuePanel
protests={allPendingProtests} protests={allPendingProtests}
races={racesMap} onReview={handleReviewProtest}
drivers={driverMap} />
leagueId={leagueId} ) : (
onReviewProtest={setSelectedProtest} <Card>
onProtestReviewed={onRefetch} <Box p={6}>
/>
) : (
<PenaltyHistoryList <PenaltyHistoryList
protests={allResolvedProtests} protests={allResolvedProtests}
races={racesMap} races={racesMap}
drivers={driverMap} drivers={driverMap}
/> />
)} </Box>
</Box> </Card>
</Card> )}
{activeTab === 'history' && ( {activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} /> <PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />

View File

@@ -1,26 +1,18 @@
'use client'; 'use client';
import React, { useState, useMemo } from 'react'; import React from 'react';
import { Card } from '@/ui/Card'; import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel';
import { Button } from '@/ui/Button';
import { TransactionRow } from '@/components/leagues/TransactionRow';
import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData'; import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData';
import { Box } from '@/ui/Box'; import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Heading } from '@/ui/Heading'; import { Heading } from '@/ui/Heading';
import { Container } from '@/ui/Container'; import { Container } from '@/ui/Container';
import { Grid } from '@/ui/Grid';
import { Icon as UIIcon } from '@/ui/Icon'; import { Icon as UIIcon } from '@/ui/Icon';
import { import {
Wallet, Download
DollarSign,
ArrowUpRight,
Clock,
AlertTriangle,
Download,
TrendingUp
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/ui/Button';
interface WalletTemplateProps { interface WalletTemplateProps {
viewData: LeagueWalletViewData; viewData: LeagueWalletViewData;
@@ -29,29 +21,15 @@ interface WalletTemplateProps {
mutationLoading?: boolean; mutationLoading?: boolean;
} }
export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) { export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) {
const [withdrawAmount, setWithdrawAmount] = useState(''); // Map transactions to the format expected by WalletSummaryPanel
const [showWithdrawModal, setShowWithdrawModal] = useState(false); const transactions = viewData.transactions.map(t => ({
const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all'); id: t.id,
type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit',
const filteredTransactions = useMemo(() => { amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')),
if (filterType === 'all') return viewData.transactions; description: t.description,
return viewData.transactions.filter(t => t.type === filterType); date: t.formattedDate,
}, [viewData.transactions, filterType]); }));
const handleWithdrawClick = () => {
const amount = parseFloat(withdrawAmount);
if (!amount || amount <= 0) return;
if (onWithdraw) {
onWithdraw(amount);
setShowWithdrawModal(false);
setWithdrawAmount('');
}
};
const canWithdraw = viewData.balance > 0;
const withdrawalBlockReason = !canWithdraw ? 'Balance is zero' : undefined;
return ( return (
<Container size="lg" py={8}> <Container size="lg" py={8}>
@@ -61,314 +39,29 @@ export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutatio
<Heading level={1}>League Wallet</Heading> <Heading level={1}>League Wallet</Heading>
<Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text> <Text color="text-gray-400">Manage your league&apos;s finances and payouts</Text>
</Box> </Box>
<Stack direction="row" align="center" gap={2}> <Button variant="secondary" onClick={onExport}>
<Button variant="secondary" onClick={onExport}> <Stack direction="row" align="center" gap={2}>
<Stack direction="row" align="center" gap={2}> <UIIcon icon={Download} size={4} />
<UIIcon icon={Download} size={4} /> <Text>Export</Text>
<Text>Export</Text> </Stack>
</Stack> </Button>
</Button>
<Button
variant="primary"
onClick={() => setShowWithdrawModal(true)}
disabled={!canWithdraw || !onWithdraw}
>
<Stack direction="row" align="center" gap={2}>
<UIIcon icon={ArrowUpRight} size={4} />
<Text>Withdraw</Text>
</Stack>
</Button>
</Stack>
</Box> </Box>
{/* Withdrawal Warning */} <WalletSummaryPanel
{!canWithdraw && withdrawalBlockReason && ( balance={viewData.balance}
<Box mb={6} p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30"> currency="USD"
<Stack direction="row" align="start" gap={3}> transactions={transactions}
<UIIcon icon={AlertTriangle} size={5} color="text-warning-amber" flexShrink={0} mt={0.5} /> onDeposit={() => {}} // Not implemented for leagues yet
<Box> onWithdraw={() => {}} // Not implemented for leagues yet
<Text weight="medium" color="text-warning-amber" block>Withdrawals Temporarily Unavailable</Text> />
<Text size="sm" color="text-gray-400" block mt={1}>{withdrawalBlockReason}</Text>
</Box>
</Stack>
</Box>
)}
{/* Stats Grid */}
<Grid cols={1} mdCols={2} lgCols={4} gap={4} mb={8}>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-performance-green/10">
<UIIcon icon={Wallet} size={6} color="text-performance-green" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedBalance}</Text>
<Text size="sm" color="text-gray-400" block>Available Balance</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10">
<UIIcon icon={TrendingUp} size={6} color="text-primary-blue" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalRevenue}</Text>
<Text size="sm" color="text-gray-400" block>Total Revenue</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-warning-amber/10">
<UIIcon icon={DollarSign} size={6} color="text-warning-amber" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedTotalFees}</Text>
<Text size="sm" color="text-gray-400" block>Platform Fees (10%)</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h={12} w={12} alignItems="center" justifyContent="center" rounded="xl" bg="bg-purple-500/10">
<UIIcon icon={Clock} size={6} color="text-purple-400" />
</Box>
<Box>
<Text size="2xl" weight="bold" color="text-white" block>{viewData.formattedPendingPayouts}</Text>
<Text size="sm" color="text-gray-400" block>Pending Payouts</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Transactions */}
<Card>
<Box display="flex" alignItems="center" justifyContent="between" p={4} borderBottom borderColor="border-charcoal-outline">
<Heading level={2}>Transaction History</Heading>
<Box as="select"
value={filterType}
onChange={(e: React.ChangeEvent<HTMLSelectElement>) => setFilterType(e.target.value as typeof filterType)}
p={1.5}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
fontSize="sm"
>
<Box as="option" value="all">All Transactions</Box>
<Box as="option" value="sponsorship">Sponsorships</Box>
<Box as="option" value="membership">Memberships</Box>
<Box as="option" value="withdrawal">Withdrawals</Box>
<Box as="option" value="prize">Prizes</Box>
</Box>
</Box>
{filteredTransactions.length === 0 ? (
<Box py={12} textAlign="center">
<Box display="flex" justifyContent="center" mb={4}>
<UIIcon icon={Wallet} size={12} color="text-gray-500" />
</Box>
<Heading level={3}>No Transactions</Heading>
<Box mt={2}>
<Text color="text-gray-400">
{filterType === 'all'
? 'Revenue from sponsorships and fees will appear here.'
: `No ${filterType} transactions found.`}
</Text>
</Box>
</Box>
) : (
<Box>
{filteredTransactions.map((transaction) => (
<TransactionRow
key={transaction.id}
transaction={{
id: transaction.id,
type: transaction.type,
description: transaction.description,
formattedDate: transaction.formattedDate,
formattedAmount: transaction.formattedAmount,
typeColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
status: transaction.status,
statusColor: transaction.status === 'completed' ? 'text-performance-green' : 'text-warning-amber',
amountColor: transaction.type === 'withdrawal' ? 'text-red-400' : 'text-performance-green',
}}
/>
))}
</Box>
)}
</Card>
{/* Revenue Breakdown */}
<Grid cols={1} lgCols={2} gap={6} mt={6}>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Revenue Breakdown</Heading>
<Stack gap={3}>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-primary-blue" />
<Text color="text-gray-400">Sponsorships</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Stack direction="row" align="center" gap={2}>
<Box w={3} h={3} rounded="full" bg="bg-performance-green" />
<Text color="text-gray-400">Membership Fees</Text>
</Stack>
<Text weight="medium" color="text-white">$1,600.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-gray-300">Total Gross Revenue</Text>
<Text weight="bold" color="text-white">$3,200.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between">
<Text size="sm" color="text-warning-amber">Platform Fee (10%)</Text>
<Text size="sm" color="text-warning-amber">-$320.00</Text>
</Box>
<Box display="flex" alignItems="center" justifyContent="between" pt={2} borderTop borderColor="border-charcoal-outline">
<Text weight="medium" color="text-performance-green">Net Revenue</Text>
<Text weight="bold" color="text-performance-green">$2,880.00</Text>
</Box>
</Stack>
</Box>
</Card>
<Card>
<Box p={4}>
<Heading level={3} mb={4}>Payout Schedule</Heading>
<Stack gap={3}>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Season 2 Prize Pool</Text>
<Text size="sm" weight="medium" color="text-warning-amber">Pending</Text>
</Box>
<Text size="xs" color="text-gray-500">
Distributed after season completion to top 3 drivers
</Text>
</Box>
<Box p={3} rounded="lg" bg="bg-iron-gray/50" border borderColor="border-charcoal-outline">
<Box display="flex" alignItems="center" justifyContent="between" mb={1}>
<Text size="sm" weight="medium" color="text-white">Available for Withdrawal</Text>
<Text size="sm" weight="medium" color="text-performance-green">{viewData.formattedBalance}</Text>
</Box>
<Text size="xs" color="text-gray-500">
Available after Season 2 ends (estimated: Jan 15, 2026)
</Text>
</Box>
</Stack>
</Box>
</Card>
</Grid>
{/* Withdraw Modal */}
{showWithdrawModal && onWithdraw && (
<Box position="fixed" inset="0" bg="bg-black/50" display="flex" alignItems="center" justifyContent="center" zIndex={50}>
<Card>
<Box p={6} w="full" maxWidth="28rem">
<Heading level={2} mb={4}>Withdraw Funds</Heading>
{!canWithdraw ? (
<Box p={4} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" mb={4}>
<Text size="sm" color="text-warning-amber">{withdrawalBlockReason}</Text>
</Box>
) : (
<Stack gap={4}>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Amount to Withdraw
</Text>
<Box position="relative">
<Box position="absolute" left={3} top="50%" transform="translateY(-50%)">
<Text color="text-gray-500">$</Text>
</Box>
<Box as="input"
type="number"
value={withdrawAmount}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setWithdrawAmount(e.target.value)}
max={viewData.balance}
w="full"
pl={8}
pr={4}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
placeholder="0.00"
/>
</Box>
<Text size="xs" color="text-gray-500" block mt={1}>
Available: {viewData.formattedBalance}
</Text>
</Box>
<Box>
<Text as="label" size="sm" weight="medium" color="text-gray-300" block mb={2}>
Destination
</Text>
<Box as="select"
w="full"
px={3}
py={2}
rounded="lg"
border
borderColor="border-charcoal-outline"
bg="bg-iron-gray"
color="text-white"
>
<Box as="option">Bank Account ***1234</Box>
</Box>
</Box>
</Stack>
)}
<Stack direction="row" gap={3} mt={6}>
<Button
variant="secondary"
onClick={() => setShowWithdrawModal(false)}
fullWidth
>
Cancel
</Button>
<Button
variant="primary"
onClick={handleWithdrawClick}
disabled={!canWithdraw || mutationLoading || !withdrawAmount}
fullWidth
>
{mutationLoading ? 'Processing...' : 'Withdraw'}
</Button>
</Stack>
</Box>
</Card>
</Box>
)}
{/* Alpha Notice */} {/* Alpha Notice */}
<Box mt={6} rounded="lg" bg="bg-warning-amber/10" border borderColor="border-warning-amber/30" p={4}> <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 size="xs" color="text-gray-400">
<Text weight="bold" color="text-warning-amber">Alpha Note:</Text> Wallet management is demonstration-only. <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. 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> </Text>
</Box> </Box>
</Container> </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'; 'use client';
import { Link } from '@/ui/Link'; import React from 'react';
import { Box } from '@/ui/Box'; import { useRouter } from 'next/navigation';
import { Text } from '@/ui/Text'; import { routes } from '@/lib/routing/RouteConfig';
import { Heading } from '@/ui/Heading'; import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate';
import { Stack } from '@/ui/Stack';
/**
* NotFound
*
* App-level 404 handler.
* Orchestrates the NotFoundTemplate with appropriate racing-themed copy.
*/
export default function NotFound() { export default function NotFound() {
return ( const router = useRouter();
<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"> const handleHomeClick = () => {
<Stack gap={4}> router.push(routes.public.home);
<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). const viewData: NotFoundViewData = {
</Text> errorCode: 'Error 404',
<Box pt={2}> title: 'OFF TRACK',
<Link message: 'The requested sector does not exist. You have been returned to the pits.',
href="/" actionLabel: 'Return to Pits'
variant="primary" };
size="sm"
weight="medium" return <NotFoundTemplate viewData={viewData} onHomeClick={handleHomeClick} />;
rounded="md" }
px={4}
py={2}
>
Drive home
</Link>
</Box>
</Stack>
</Box>
</Box>
);
}

View File

@@ -1,13 +1,50 @@
'use client'; 'use client';
import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard'; import { OnboardingTemplate } from '@/templates/onboarding/OnboardingTemplate';
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction'; import { completeOnboardingAction } from '@/app/actions/completeOnboardingAction';
import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction'; import { generateAvatarsAction } from '@/app/actions/generateAvatarsAction';
import { useAuth } from '@/components/auth/AuthContext'; 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() { export function OnboardingWizardClient() {
const { session } = useAuth(); 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: { const handleCompleteOnboarding = async (input: {
firstName: string; firstName: string;
@@ -16,16 +53,19 @@ export function OnboardingWizardClient() {
country: string; country: string;
timezone?: string; timezone?: string;
}) => { }) => {
setIsProcessing(true);
try { try {
const result = await completeOnboardingAction(input); const result = await completeOnboardingAction(input);
if (result.isErr()) { if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() }; return { success: false, error: result.getError() };
} }
window.location.href = routes.protected.dashboard; window.location.href = routes.protected.dashboard;
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to complete onboarding' }; return { success: false, error: 'Failed to complete onboarding' };
} }
}; };
@@ -38,6 +78,7 @@ export function OnboardingWizardClient() {
return { success: false, error: 'Not authenticated' }; return { success: false, error: 'Not authenticated' };
} }
setIsProcessing(true);
try { try {
const result = await generateAvatarsAction({ const result = await generateAvatarsAction({
userId: session.user.userId, userId: session.user.userId,
@@ -46,23 +87,37 @@ export function OnboardingWizardClient() {
}); });
if (result.isErr()) { if (result.isErr()) {
setIsProcessing(false);
return { success: false, error: result.getError() }; return { success: false, error: result.getError() };
} }
const data = result.unwrap(); const data = result.unwrap();
setIsProcessing(false);
return { success: true, data }; return { success: true, data };
} catch (error) { } catch (error) {
setIsProcessing(false);
return { success: false, error: 'Failed to generate avatars' }; return { success: false, error: 'Failed to generate avatars' };
} }
}; };
return ( return (
<OnboardingWizard <OnboardingTemplate
onCompleted={() => { viewData={{
window.location.href = routes.protected.dashboard; 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 { useEffect, useState } from 'react';
import { useRouter, useSearchParams } from 'next/navigation'; 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 { ProfileViewData } from '@/lib/view-data/ProfileViewData';
import type { Result } from '@/lib/contracts/Result';
interface ProfilePageClientProps { interface ProfilePageClientProps {
viewData: ProfileViewData; viewData: ProfileViewData;
mode: 'profile-exists' | 'needs-profile'; 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 router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const tabParam = searchParams.get('tab') as ProfileTab | null; const tabParam = searchParams.get('tab') as ProfileTab | null;
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview'); const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [editMode, setEditMode] = useState(false);
const [friendRequestSent, setFriendRequestSent] = useState(false); const [friendRequestSent, setFriendRequestSent] = useState(false);
useEffect(() => { useEffect(() => {
@@ -49,19 +47,8 @@ export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePag
mode={mode} mode={mode}
activeTab={activeTab} activeTab={activeTab}
onTabChange={setActiveTab} onTabChange={setActiveTab}
editMode={editMode}
onEditModeChange={setEditMode}
friendRequestSent={friendRequestSent} friendRequestSent={friendRequestSent}
onFriendRequestSend={() => setFriendRequestSent(true)} 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 type { ReactNode } from 'react';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { redirect } from 'next/navigation'; import { redirect } from 'next/navigation';
import { ProfileLayoutShell } from '@/ui/ProfileLayoutShell'; import { ProfileLayoutShellTemplate } from '@/templates/ProfileLayoutShellTemplate';
import { createRouteGuard } from '@/lib/auth/createRouteGuard'; import { createRouteGuard } from '@/lib/auth/createRouteGuard';
interface ProfileLayoutProps { interface ProfileLayoutProps {
@@ -18,5 +18,5 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) {
redirect(result.to); redirect(result.to);
} }
return <ProfileLayoutShell>{children}</ProfileLayoutShell>; return <ProfileLayoutShellTemplate viewData={{}}>{children}</ProfileLayoutShellTemplate>;
} }

View File

@@ -1,13 +1,4 @@
import Link from 'next/link'; import { ProfileLiveriesTemplate } from '@/templates/ProfileLiveriesTemplate';
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';
export default async function ProfileLiveriesPage() { export default async function ProfileLiveriesPage() {
const mockLiveries = [ const mockLiveries = [
@@ -29,29 +20,5 @@ export default async function ProfileLiveriesPage() {
} }
]; ];
return ( return <ProfileLiveriesTemplate viewData={{ liveries: mockLiveries }} />;
<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>
);
} }

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 React from 'react';
import { Button } from '@/ui/Button'; import { ProfileLiveryUploadPageClient } from './ProfileLiveryUploadPageClient';
import { Card } from '@/ui/Card';
import { Container } from '@/ui/Container';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { routes } from '@/lib/routing/RouteConfig';
export default async function ProfileLiveryUploadPage() { export default async function ProfileLiveryUploadPage() {
return ( return <ProfileLiveryUploadPageClient />;
<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>
);
} }

View File

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

View File

@@ -1,8 +1,13 @@
'use client'; 'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { Result } from '@/lib/contracts/Result'; import type { Result } from '@/lib/contracts/Result';
import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData'; import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData';
import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate'; 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 { interface SponsorshipRequestsClientProps {
viewData: SponsorshipRequestsViewData; viewData: SponsorshipRequestsViewData;
@@ -11,25 +16,54 @@ interface SponsorshipRequestsClientProps {
} }
export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: 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) => { const handleAccept = async (requestId: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onAccept(requestId); const result = await onAccept(requestId);
if (result.isErr()) { 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) => { const handleReject = async (requestId: string, reason?: string) => {
setIsProcessing(requestId);
setError(null);
const result = await onReject(requestId, reason); const result = await onReject(requestId, reason);
if (result.isErr()) { if (result.isErr()) {
console.error('Failed to reject request:', result.getError()); setError(result.getError());
setIsProcessing(null);
} else {
router.refresh();
setIsProcessing(null);
} }
}; };
return ( return (
<SponsorshipRequestsTemplate <>
viewData={viewData} <ProgressLine isLoading={!!isProcessing} />
onAccept={handleAccept} {error && (
onReject={handleReject} <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 { notFound } from 'next/navigation';
import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery'; import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery';
import { SponsorshipRequestsClient } from './SponsorshipRequestsClient'; import { SponsorshipRequestsClient } from './SponsorshipRequestsClient';
import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions'; import { acceptSponsorshipRequest, rejectSponsorshipRequest } from '@/app/actions/sponsorshipActions';
export default async function SponsorshipRequestsPage() { export default async function SponsorshipRequestsPage() {
// Execute PageQuery // Execute PageQuery

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,251 +1,13 @@
'use client'; '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 { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling";
import { import { SponsorBillingTemplate } from "@/templates/SponsorBillingTemplate";
CreditCard, import { Box } from "@/ui/Box";
DollarSign, import { Text } from "@/ui/Text";
Calendar, import { Button } from "@/ui/Button";
Download, import { DollarSign, AlertTriangle, Calendar, TrendingUp } from "lucide-react";
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
// ============================================================================
export default function SponsorBillingPage() { export default function SponsorBillingPage() {
const shouldReduceMotion = useReducedMotion();
const [showAllInvoices, setShowAllInvoices] = useState(false);
const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1'); const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1');
if (isLoading) { if (isLoading) {
@@ -274,228 +36,68 @@ export default function SponsorBillingPage() {
); );
} }
const data = billingData;
const handleSetDefault = (methodId: string) => { const handleSetDefault = (methodId: string) => {
// In a real app, this would call an API
console.log('Setting default payment method:', methodId); console.log('Setting default payment method:', methodId);
}; };
const handleRemoveMethod = (methodId: string) => { const handleRemoveMethod = (methodId: string) => {
if (window.confirm('Remove this payment method?')) { if (window.confirm('Remove this payment method?')) {
// In a real app, this would call an API
console.log('Removing payment method:', methodId); console.log('Removing payment method:', methodId);
} }
}; };
const containerVariants = { const handleDownloadInvoice = (invoiceId: string) => {
hidden: { opacity: 0 }, console.log('Downloading invoice:', invoiceId);
visible: {
opacity: 1,
transition: {
staggerChildren: shouldReduceMotion ? 0 : 0.1,
},
},
}; };
const itemVariants = { const billingStats = [
hidden: { opacity: 0, y: 20 }, {
visible: { opacity: 1, y: 0 }, 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 ( return (
<Box <SponsorBillingTemplate
maxWidth="5xl" viewData={billingData}
mx="auto" billingStats={billingStats}
py={8} transactions={transactions}
px={4} onSetDefaultPaymentMethod={handleSetDefault}
as={motion.div} onDownloadInvoice={handleDownloadInvoice}
// @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>
); );
} }

View File

@@ -1,619 +1,74 @@
'use client'; 'use client';
import { useState } from 'react'; 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 { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships";
import { import { SponsorCampaignsTemplate, SponsorshipType } from "@/templates/SponsorCampaignsTemplate";
Megaphone, import { Box } from "@/ui/Box";
Trophy, import { Text } from "@/ui/Text";
Users, import { Button } from "@/ui/Button";
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
// ============================================================================
export default function SponsorCampaignsPage() { export default function SponsorCampaignsPage() {
const searchParams = useSearchParams(); const [typeFilter, setTypeFilter] = useState<SponsorshipType>('all');
const shouldReduceMotion = useReducedMotion();
const initialType = (searchParams.get('type') as SponsorshipType) || 'all';
const [typeFilter, setTypeFilter] = useState<SponsorshipType>(initialType);
const [statusFilter, setStatusFilter] = useState<SponsorshipStatus>('all');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1'); const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1');
if (isLoading) { if (isLoading) {
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]"> <Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<div className="text-center"> <Box textAlign="center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin mx-auto mb-4" /> <Box w="8" h="8" border borderTop={false} borderColor="border-primary-blue" rounded="full" animate="spin" mx="auto" mb={4} />
<p className="text-gray-400">Loading sponsorships...</p> <Text color="text-gray-400">Loading sponsorships...</Text>
</div> </Box>
</div> </Box>
); );
} }
if (error || !sponsorshipsData) { if (error || !sponsorshipsData) {
return ( return (
<div className="max-w-7xl mx-auto py-8 px-4 flex items-center justify-center min-h-[400px]"> <Box maxWidth="7xl" mx="auto" py={8} px={4} display="flex" alignItems="center" justifyContent="center" minHeight="400px">
<div className="text-center"> <Box textAlign="center">
<p className="text-gray-400">{error?.getUserMessage() || 'No sponsorships data available'}</p> <Text color="text-gray-400">{error?.getUserMessage() || 'No sponsorships data available'}</Text>
{error && ( {error && (
<Button variant="secondary" onClick={retry} className="mt-4"> <Button variant="secondary" onClick={retry} mt={4}>
Retry Retry
</Button> </Button>
)} )}
</div> </Box>
</div> </Box>
); );
} }
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 viewData = {
const filteredSponsorships = data.sponsorships.filter((s: unknown) => { sponsorships: sponsorshipsData.sponsorships as any,
// eslint-disable-next-line @typescript-eslint/no-explicit-any stats,
const sponsorship = s as any; };
if (typeFilter !== 'all' && sponsorship.type !== typeFilter) return false;
if (statusFilter !== 'all' && sponsorship.status !== statusFilter) return false; const filteredSponsorships = sponsorshipsData.sponsorships.filter((s: any) => {
if (searchQuery && !sponsorship.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false; // 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; 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 ( return (
<Box maxWidth="7xl" mx="auto" py={8} px={4}> <SponsorCampaignsTemplate
{/* Header */} viewData={viewData}
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={4} mb={8}> filteredSponsorships={filteredSponsorships as any}
<Box> typeFilter={typeFilter}
<Heading level={1} fontSize="2xl" weight="bold" color="text-white" icon={<Megaphone className="w-7 h-7 text-primary-blue" />}> setTypeFilter={setTypeFilter}
My Sponsorships searchQuery={searchQuery}
</Heading> setSearchQuery={setSearchQuery}
<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>
); );
} }

View File

@@ -1,92 +1,16 @@
'use client'; 'use client';
import { useState } from 'react'; import { useState } from 'react';
import { motion, useReducedMotion } from 'framer-motion'; import { SponsorSettingsTemplate } from '@/templates/SponsorSettingsTemplate';
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 { logoutAction } from '@/app/actions/logoutAction'; import { logoutAction } from '@/app/actions/logoutAction';
import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog';
// ============================================================================ import { useRouter } from 'next/navigation';
// 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;
}
// ============================================================================ // ============================================================================
// Mock Data // Mock Data
// ============================================================================ // ============================================================================
const MOCK_PROFILE: SponsorProfile = { const MOCK_PROFILE = {
companyName: 'Acme Racing Co.', companyName: 'Acme Racing Co.',
contactName: 'John Smith', contactName: 'John Smith',
contactEmail: 'sponsor@acme-racing.com', contactEmail: 'sponsor@acme-racing.com',
@@ -109,7 +33,7 @@ const MOCK_PROFILE: SponsorProfile = {
}, },
}; };
const MOCK_NOTIFICATIONS: NotificationSettings = { const MOCK_NOTIFICATIONS = {
emailNewSponsorships: true, emailNewSponsorships: true,
emailWeeklyReport: true, emailWeeklyReport: true,
emailRaceAlerts: false, emailRaceAlerts: false,
@@ -118,581 +42,71 @@ const MOCK_NOTIFICATIONS: NotificationSettings = {
emailContractExpiry: true, emailContractExpiry: true,
}; };
const MOCK_PRIVACY: PrivacySettings = { const MOCK_PRIVACY = {
publicProfile: true, publicProfile: true,
showStats: false, showStats: false,
showActiveSponsorships: true, showActiveSponsorships: true,
allowDirectContact: 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() { export default function SponsorSettingsPage() {
const shouldReduceMotion = useReducedMotion(); const router = useRouter();
const [profile, setProfile] = useState(MOCK_PROFILE); const [profile, setProfile] = useState(MOCK_PROFILE);
const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS); 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 [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const handleSaveProfile = async () => { const handleSaveProfile = async () => {
setSaving(true); setSaving(true);
await new Promise(resolve => setTimeout(resolve, 800)); await new Promise(resolve => setTimeout(resolve, 800));
console.log('Profile saved:', profile);
setSaving(false); setSaving(false);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 3000); setTimeout(() => setSaved(false), 3000);
}; };
const handleDeleteAccount = async () => { 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.')) { setIsDeleting(true);
// Call the logout action and handle result const result = await logoutAction();
const result = await logoutAction(); if (result.isErr()) {
if (result.isErr()) { console.error('Logout failed:', result.getError());
console.error('Logout failed:', result.getError()); setIsDeleting(false);
// Could show error toast here return;
return;
}
// Redirect to login after successful logout
window.location.href = '/auth/login';
} }
router.push('/auth/login');
}; };
const containerVariants = { const viewData = {
hidden: { opacity: 0 }, profile: MOCK_PROFILE,
visible: { notifications: MOCK_NOTIFICATIONS,
opacity: 1, privacy: MOCK_PRIVACY,
transition: {
staggerChildren: shouldReduceMotion ? 0 : 0.1,
},
},
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0 },
}; };
return ( return (
<Box <>
maxWidth="4xl" <SponsorSettingsTemplate
mx="auto" viewData={viewData}
py={8} profile={profile}
px={4} setProfile={setProfile as any}
as={motion.div} notifications={notifications}
// @ts-ignore setNotifications={setNotifications as any}
variants={containerVariants} onSaveProfile={handleSaveProfile}
initial="hidden" onDeleteAccount={() => setIsDeleteDialogOpen(true)}
animate="visible" saving={saving}
> saved={saved}
{/* Header */} />
<Box as={motion.div} variants={itemVariants}> <ConfirmDialog
<PageHeader isOpen={isDeleteDialogOpen}
icon={Settings} onClose={() => setIsDeleteDialogOpen(false)}
title="Sponsor Settings" onConfirm={handleDeleteAccount}
description="Manage your company profile, notifications, and security preferences" title="Delete Sponsor Account"
action={<SavedIndicator visible={saved} />} 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"
</Box> variant="danger"
isLoading={isDeleting}
{/* 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>
); );
} }

View File

@@ -36,13 +36,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
alert('Remove member functionality would be implemented here'); 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 = () => { const handleGoBack = () => {
router.back(); router.back();
}; };
@@ -55,7 +48,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) {
onTabChange={handleTabChange} onTabChange={handleTabChange}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember} onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack} onGoBack={handleGoBack}
/> />
); );

View File

@@ -1,56 +1,15 @@
import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { notFound } from 'next/navigation';
import { TeamService } from '@/lib/services/teams/TeamService'; import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery';
import { Trophy } from 'lucide-react';
import { redirect } from 'next/navigation';
import { routes } from '@/lib/routing/RouteConfig';
import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper'; import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper';
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
// ============================================================================
// MAIN PAGE COMPONENT
// ============================================================================
export default async function TeamLeaderboardPage() { export default async function TeamLeaderboardPage() {
// Manual wiring: create dependencies const query = new TeamLeaderboardPageQuery();
const service = new TeamService(); const result = await query.execute();
// Fetch data through service if (result.isErr()) {
const result = await service.getAllTeams(); notFound();
// 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);
} }
const hasData = (data?.length ?? 0) > 0; const data = result.unwrap();
return <TeamLeaderboardPageWrapper data={data.teams} />;
// 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.',
}}
/>
);
} }

View File

@@ -0,0 +1,57 @@
'use client';
import { useState } from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Select } from '@/ui/Select';
import { Input } from '@/ui/Input';
export function ActionFiltersBar() {
const [filter, setFilter] = useState('all');
return (
<Box
h="12"
borderBottom
borderColor="border-[#23272B]"
display="flex"
alignItems="center"
px={6}
bg="bg-[#0C0D0F]"
gap={6}
>
<Box display="flex" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Filter:</Text>
<Select
options={[
{ label: 'All Types', value: 'all' },
{ label: 'User Update', value: 'user' },
{ label: 'Onboarding', value: 'onboarding' }
]}
value={filter}
onChange={(e) => setFilter(e.target.value)}
fullWidth={false}
/>
</Box>
<Box display="flex" alignItems="center" gap={2}>
<Text size="xs" color="text-gray-500" weight="bold" uppercase>Status:</Text>
<Select
options={[
{ label: 'All Status', value: 'all' },
{ label: 'Completed', value: 'completed' },
{ label: 'Pending', value: 'pending' },
{ label: 'Failed', value: 'failed' }
]}
value="all"
onChange={() => {}}
fullWidth={false}
/>
</Box>
<Box ml="auto">
<Input
placeholder="SEARCH_ID..."
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import { ActionItem } from '@/lib/queries/ActionsPageQuery';
import { ActionStatusBadge } from './ActionStatusBadge';
import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table';
import { Text } from '@/ui/Text';
interface ActionListProps {
actions: ActionItem[];
}
export function ActionList({ actions }: ActionListProps) {
return (
<Table>
<TableHead>
<TableRow>
<TableHeader>Timestamp</TableHeader>
<TableHeader>Type</TableHeader>
<TableHeader>Initiator</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Details</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{actions.map((action) => (
<TableRow
key={action.id}
clickable
>
<TableCell>
<Text font="mono" size="xs" color="text-gray-400">{action.timestamp}</Text>
</TableCell>
<TableCell>
<Text size="xs" weight="medium" color="text-gray-200">{action.type}</Text>
</TableCell>
<TableCell>
<Text size="xs" color="text-gray-400">{action.initiator}</Text>
</TableCell>
<TableCell>
<ActionStatusBadge status={action.status} />
</TableCell>
<TableCell>
<Text size="xs" color="text-gray-400">
{action.details}
</Text>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,43 @@
'use client';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface ActionStatusBadgeProps {
status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS';
}
export function ActionStatusBadge({ status }: ActionStatusBadgeProps) {
const styles = {
PENDING: { bg: 'bg-amber-500/10', text: 'text-[#FFBE4D]', border: 'border-amber-500/20' },
COMPLETED: { bg: 'bg-emerald-500/10', text: 'text-emerald-400', border: 'border-emerald-500/20' },
FAILED: { bg: 'bg-red-500/10', text: 'text-red-400', border: 'border-red-500/30' },
IN_PROGRESS: { bg: 'bg-blue-500/10', text: 'text-[#198CFF]', border: 'border-blue-500/20' },
};
const config = styles[status];
return (
<Box
as="span"
px={2}
py={0.5}
rounded="sm"
bg={config.bg}
border
borderColor={config.border}
display="inline-block"
>
<Text
size="xs"
weight="bold"
color={config.text}
uppercase
letterSpacing="tight"
fontSize="10px"
>
{status.replace('_', ' ')}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,41 @@
'use client';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Activity } from 'lucide-react';
import { StatusIndicator } from '@/ui/StatusIndicator';
interface ActionsHeaderProps {
title: string;
}
export function ActionsHeader({ title }: ActionsHeaderProps) {
return (
<Box
as="header"
h="16"
borderBottom
borderColor="border-[#23272B]"
display="flex"
alignItems="center"
px={6}
bg="bg-[#141619]"
>
<Box display="flex" alignItems="center" gap={4}>
<Box
w="2"
h="6"
bg="bg-[#198CFF]"
rounded="sm"
shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]"
/>
<Text as="h1" size="xl" weight="medium" letterSpacing="tight" uppercase>
{title}
</Text>
</Box>
<Box ml="auto" display="flex" alignItems="center" gap={4}>
<StatusIndicator icon={Activity} variant="info" label="SYSTEM_READY" />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface AdminDangerZonePanelProps {
title: string;
description: string;
children: React.ReactNode;
}
/**
* AdminDangerZonePanel
*
* Semantic panel for destructive or dangerous admin actions.
* Restrained but clear warning styling.
*/
export function AdminDangerZonePanel({
title,
description,
children
}: AdminDangerZonePanelProps) {
return (
<Card borderColor="border-error-red/30" bg="bg-error-red/5">
<Stack direction={{ base: 'col', md: 'row' }} align="center" justify="between" gap={6}>
<Box>
<Heading level={4} weight="bold" color="text-error-red">
{title}
</Heading>
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
</Box>
<Box>
{children}
</Box>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Box } from '@/ui/Box';
interface AdminDataTableProps {
children: React.ReactNode;
maxHeight?: string | number;
}
/**
* AdminDataTable
*
* Semantic wrapper for high-density admin tables.
* Provides a consistent container with "Precision Racing Minimal" styling.
*/
export function AdminDataTable({
children,
maxHeight
}: AdminDataTableProps) {
return (
<Card p={0} overflow="hidden">
<Box
overflow="auto"
maxHeight={maxHeight}
>
{children}
</Box>
</Card>
);
}

View File

@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Icon } from '@/ui/Icon';
import { LucideIcon } from 'lucide-react';
interface AdminEmptyStateProps {
icon: LucideIcon;
title: string;
description?: string;
action?: React.ReactNode;
}
/**
* AdminEmptyState
*
* Semantic empty state for admin lists and tables.
* Follows "Precision Racing Minimal" theme.
*/
export function AdminEmptyState({
icon,
title,
description,
action
}: AdminEmptyStateProps) {
return (
<Stack center py={20} gap={4}>
<Icon icon={icon} size={12} color="#23272B" />
<Box textAlign="center">
<Text size="lg" weight="bold" color="text-white" block>
{title}
</Text>
{description && (
<Text size="sm" color="text-gray-500" block mt={1}>
{description}
</Text>
)}
</Box>
{action && (
<Box mt={2}>
{action}
</Box>
)}
</Stack>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { ProgressLine } from '@/components/shared/ux/ProgressLine';
interface AdminHeaderPanelProps {
title: string;
description?: string;
actions?: React.ReactNode;
isLoading?: boolean;
}
/**
* AdminHeaderPanel
*
* Semantic header for admin pages.
* Includes title, description, actions, and a progress line for loading states.
*/
export function AdminHeaderPanel({
title,
description,
actions,
isLoading = false
}: AdminHeaderPanelProps) {
return (
<Box position="relative" pb={4} borderBottom borderColor="border-charcoal-outline">
<Stack direction="row" align="center" justify="between">
<Box>
<Heading level={1} weight="bold" color="text-white">
{title}
</Heading>
{description && (
<Text size="sm" color="text-gray-400" block mt={1}>
{description}
</Text>
)}
</Box>
{actions && (
<Stack direction="row" align="center" gap={3}>
{actions}
</Stack>
)}
</Stack>
<Box position="absolute" bottom="0" left="0" w="full">
<ProgressLine isLoading={isLoading} />
</Box>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface AdminSectionHeaderProps {
title: string;
description?: string;
actions?: React.ReactNode;
}
/**
* AdminSectionHeader
*
* Semantic header for sections within admin pages.
* Follows "Precision Racing Minimal" theme: dense, clear hierarchy.
*/
export function AdminSectionHeader({
title,
description,
actions
}: AdminSectionHeaderProps) {
return (
<Stack direction="row" align="center" justify="between" mb={4}>
<Box>
<Heading level={3} weight="bold" color="text-white">
{title}
</Heading>
{description && (
<Text size="xs" color="text-gray-500" block mt={0.5}>
{description}
</Text>
)}
</Box>
{actions && (
<Stack direction="row" align="center" gap={2}>
{actions}
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Grid } from '@/ui/Grid';
import { StatCard } from '@/ui/StatCard';
import { LucideIcon } from 'lucide-react';
interface AdminStat {
label: string;
value: string | number;
icon: LucideIcon;
variant?: 'blue' | 'purple' | 'green' | 'orange';
trend?: {
value: number;
isPositive: boolean;
};
}
interface AdminStatsPanelProps {
stats: AdminStat[];
}
/**
* AdminStatsPanel
*
* Semantic container for admin statistics.
* Renders a grid of StatCards.
*/
export function AdminStatsPanel({ stats }: AdminStatsPanelProps) {
return (
<Grid cols={1} mdCols={2} lgCols={4} gap={4}>
{stats.map((stat, index) => (
<StatCard
key={stat.label}
label={stat.label}
value={stat.value}
icon={stat.icon}
variant={stat.variant}
trend={stat.trend}
delay={index * 0.05}
/>
))}
</Grid>
);
}

View File

@@ -0,0 +1,37 @@
'use client';
import React from 'react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
interface AdminToolbarProps {
children: React.ReactNode;
leftContent?: React.ReactNode;
}
/**
* AdminToolbar
*
* Semantic toolbar for admin pages.
* Used for filters, search, and secondary actions.
*/
export function AdminToolbar({
children,
leftContent
}: AdminToolbarProps) {
return (
<Card p={3} bg="bg-charcoal/50" borderColor="border-charcoal-outline">
<Stack direction="row" align="center" justify="between" gap={4} wrap>
{leftContent && (
<Box flexGrow={1}>
{leftContent}
</Box>
)}
<Stack direction="row" align="center" gap={3} flexGrow={leftContent ? 0 : 1} wrap>
{children}
</Stack>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,170 @@
'use client';
import React from 'react';
import {
Table,
TableHead,
TableBody,
TableRow,
TableHeader,
TableCell
} from '@/ui/Table';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { SimpleCheckbox } from '@/ui/SimpleCheckbox';
import { UserStatusTag } from './UserStatusTag';
import { DateDisplay } from '@/lib/display-objects/DateDisplay';
import { Shield, Trash2, MoreVertical } from 'lucide-react';
import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData';
interface AdminUsersTableProps {
users: AdminUsersViewData['users'];
selectedUserIds: string[];
onSelectUser: (userId: string) => void;
onSelectAll: () => void;
onUpdateStatus: (userId: string, status: string) => void;
onDeleteUser: (userId: string) => void;
deletingUserId?: string | null;
}
/**
* AdminUsersTable
*
* Semantic table for managing users.
* High-density, instrument-grade UI.
*/
export function AdminUsersTable({
users,
selectedUserIds,
onSelectUser,
onSelectAll,
onUpdateStatus,
onDeleteUser,
deletingUserId
}: AdminUsersTableProps) {
const allSelected = users.length > 0 && selectedUserIds.length === users.length;
return (
<Table>
<TableHead>
<TableRow>
<TableHeader width="10">
<SimpleCheckbox
checked={allSelected}
onChange={onSelectAll}
aria-label="Select all users"
/>
</TableHeader>
<TableHeader>User</TableHeader>
<TableHeader>Roles</TableHeader>
<TableHeader>Status</TableHeader>
<TableHeader>Last Login</TableHeader>
<TableHeader textAlign="right">Actions</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{users.map((user) => (
<TableRow key={user.id} variant={selectedUserIds.includes(user.id) ? 'highlight' : 'default'}>
<TableCell>
<SimpleCheckbox
checked={selectedUserIds.includes(user.id)}
onChange={() => onSelectUser(user.id)}
aria-label={`Select user ${user.displayName}`}
/>
</TableCell>
<TableCell>
<Stack direction="row" align="center" gap={3}>
<Box
bg="bg-primary-blue/10"
rounded="full"
p={2}
border
borderColor="border-primary-blue/20"
>
<Icon icon={Shield} size={4} color="#198CFF" />
</Box>
<Box>
<Text weight="semibold" color="text-white" block>
{user.displayName}
</Text>
<Text size="xs" color="text-gray-500" block>
{user.email}
</Text>
</Box>
</Stack>
</TableCell>
<TableCell>
<Stack direction="row" gap={1.5} wrap>
{user.roles.map((role) => (
<Box
key={role}
px={2}
py={0.5}
rounded="full"
bg="bg-charcoal-outline/30"
border
borderColor="border-charcoal-outline"
>
<Text size="xs" weight="medium" color="text-gray-300">
{role}
</Text>
</Box>
))}
</Stack>
</TableCell>
<TableCell>
<UserStatusTag status={user.status} />
</TableCell>
<TableCell>
<Text size="sm" color="text-gray-400">
{user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'}
</Text>
</TableCell>
<TableCell>
<Stack direction="row" align="center" justify="end" gap={2}>
{user.status === 'active' ? (
<Button
size="sm"
variant="secondary"
onClick={() => onUpdateStatus(user.id, 'suspended')}
>
Suspend
</Button>
) : user.status === 'suspended' ? (
<Button
size="sm"
variant="secondary"
onClick={() => onUpdateStatus(user.id, 'active')}
>
Activate
</Button>
) : null}
<Button
size="sm"
variant="secondary"
onClick={() => onDeleteUser(user.id)}
disabled={deletingUserId === user.id}
icon={<Icon icon={Trash2} size={3} />}
>
{deletingUserId === user.id ? '...' : ''}
</Button>
<Button
size="sm"
variant="ghost"
icon={<Icon icon={MoreVertical} size={4} />}
>
{''}
</Button>
</Stack>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import React from 'react';
import { Stack } from '@/ui/Stack';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { motion, AnimatePresence } from 'framer-motion';
interface BulkActionBarProps {
selectedCount: number;
actions: {
label: string;
onClick: () => void;
variant?: 'primary' | 'secondary' | 'danger';
icon?: React.ReactNode;
}[];
onClearSelection: () => void;
}
/**
* BulkActionBar
*
* Floating action bar that appears when items are selected in a table.
*/
export function BulkActionBar({
selectedCount,
actions,
onClearSelection
}: BulkActionBarProps) {
return (
<AnimatePresence>
{selectedCount > 0 && (
<Box
as={motion.div}
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
position="fixed"
bottom="8"
left="1/2"
translateX="-1/2"
zIndex={50}
bg="bg-surface-charcoal"
border
borderColor="border-primary-blue/50"
rounded="xl"
shadow="xl"
px={6}
py={4}
bgOpacity={0.9}
blur="md"
>
<Stack direction="row" align="center" gap={8}>
<Stack direction="row" align="center" gap={3}>
<Box bg="bg-primary-blue" rounded="full" px={2} py={0.5}>
<Text size="xs" weight="bold" color="text-white">
{selectedCount}
</Text>
</Box>
<Text size="sm" weight="medium" color="text-white">
Items Selected
</Text>
</Stack>
<Box w="px" h="6" bg="bg-charcoal-outline" />
<Stack direction="row" align="center" gap={3}>
{actions.map((action) => (
<Button
key={action.label}
size="sm"
variant={action.variant === 'danger' ? 'secondary' : (action.variant || 'primary')}
onClick={action.onClick}
icon={action.icon}
>
{action.label}
</Button>
))}
<Button
size="sm"
variant="ghost"
onClick={onClearSelection}
>
Cancel
</Button>
</Stack>
</Stack>
</Box>
)}
</AnimatePresence>
);
}

View File

@@ -2,14 +2,13 @@
import React from 'react'; import React from 'react';
import { Filter, Search } from 'lucide-react'; import { Filter, Search } from 'lucide-react';
import { Card } from '@/ui/Card';
import { Stack } from '@/ui/Stack'; import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text'; import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { Grid } from '@/ui/Grid';
import { Icon } from '@/ui/Icon'; import { Icon } from '@/ui/Icon';
import { Input } from '@/ui/Input'; import { Input } from '@/ui/Input';
import { Select } from '@/ui/Select'; import { Select } from '@/ui/Select';
import { AdminToolbar } from './AdminToolbar';
interface UserFiltersProps { interface UserFiltersProps {
search: string; search: string;
@@ -31,13 +30,11 @@ export function UserFilters({
onClearFilters, onClearFilters,
}: UserFiltersProps) { }: UserFiltersProps) {
return ( return (
<Card> <AdminToolbar
<Stack gap={4}> leftContent={
<Stack direction="row" align="center" justify="between"> <Stack direction="row" align="center" gap={2}>
<Stack direction="row" align="center" gap={2}> <Icon icon={Filter} size={4} color="#9ca3af" />
<Icon icon={Filter} size={4} color="#9ca3af" /> <Text weight="medium" color="text-white">Filters</Text>
<Text weight="medium" color="text-white">Filters</Text>
</Stack>
{(search || roleFilter || statusFilter) && ( {(search || roleFilter || statusFilter) && (
<Button <Button
onClick={onClearFilters} onClick={onClearFilters}
@@ -48,39 +45,38 @@ export function UserFilters({
</Button> </Button>
)} )}
</Stack> </Stack>
}
<Grid cols={3} gap={4}> >
<Input <Input
type="text" type="text"
placeholder="Search by email or name..." placeholder="Search by email or name..."
value={search} value={search}
onChange={(e) => onSearch(e.target.value)} onChange={(e) => onSearch(e.target.value)}
icon={<Icon icon={Search} size={4} color="#9ca3af" />} icon={<Icon icon={Search} size={4} color="#9ca3af" />}
/> width="300px"
/>
<Select <Select
value={roleFilter} value={roleFilter}
onChange={(e) => onFilterRole(e.target.value)} onChange={(e) => onFilterRole(e.target.value)}
options={[ options={[
{ value: '', label: 'All Roles' }, { value: '', label: 'All Roles' },
{ value: 'owner', label: 'Owner' }, { value: 'owner', label: 'Owner' },
{ value: 'admin', label: 'Admin' }, { value: 'admin', label: 'Admin' },
{ value: 'user', label: 'User' }, { value: 'user', label: 'User' },
]} ]}
/> />
<Select <Select
value={statusFilter} value={statusFilter}
onChange={(e) => onFilterStatus(e.target.value)} onChange={(e) => onFilterStatus(e.target.value)}
options={[ options={[
{ value: '', label: 'All Status' }, { value: '', label: 'All Status' },
{ value: 'active', label: 'Active' }, { value: 'active', label: 'Active' },
{ value: 'suspended', label: 'Suspended' }, { value: 'suspended', label: 'Suspended' },
{ value: 'deleted', label: 'Deleted' }, { value: 'deleted', label: 'Deleted' },
]} ]}
/> />
</Grid> </AdminToolbar>
</Stack>
</Card>
); );
} }

View File

@@ -0,0 +1,68 @@
'use client';
import React from 'react';
import { StatusBadge } from '@/ui/StatusBadge';
import {
CheckCircle2,
AlertTriangle,
XCircle,
Clock,
LucideIcon
} from 'lucide-react';
export type UserStatus = 'active' | 'suspended' | 'deleted' | 'pending';
interface UserStatusTagProps {
status: UserStatus | string;
}
interface StatusConfig {
variant: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending';
icon: LucideIcon;
label: string;
}
/**
* UserStatusTag
*
* Semantic status indicator for users.
* Maps status strings to appropriate visual variants and icons.
*/
export function UserStatusTag({ status }: UserStatusTagProps) {
const normalizedStatus = status.toLowerCase() as UserStatus;
const config: Record<UserStatus, StatusConfig> = {
active: {
variant: 'success',
icon: CheckCircle2,
label: 'Active'
},
suspended: {
variant: 'warning',
icon: AlertTriangle,
label: 'Suspended'
},
deleted: {
variant: 'error',
icon: XCircle,
label: 'Deleted'
},
pending: {
variant: 'pending',
icon: Clock,
label: 'Pending'
}
};
const { variant, icon, label } = config[normalizedStatus] || {
variant: 'neutral',
icon: Clock,
label: status
};
return (
<StatusBadge variant={variant} icon={icon}>
{label}
</StatusBadge>
);
}

View File

@@ -0,0 +1,47 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
interface AuthCardProps {
children: React.ReactNode;
title: string;
description?: string;
}
/**
* AuthCard
*
* A matte surface container for auth forms with a subtle accent glow.
*/
export function AuthCard({ children, title, description }: AuthCardProps) {
return (
<Box bg="surface-charcoal" border borderColor="outline-steel" rounded="lg" shadow="card" position="relative" overflow="hidden">
{/* Subtle top accent line */}
<Box
position="absolute"
top="0"
left="0"
w="full"
h="1px"
bg="linear-gradient(to right, transparent, rgba(25, 140, 255, 0.3), transparent)"
/>
<Box p={{ base: 6, md: 8 }}>
<Box as="header" mb={8} textAlign="center">
<Text as="h1" size="xl" weight="semibold" color="text-white" letterSpacing="tight" mb={2} block>
{title}
</Text>
{description && (
<Text size="sm" color="text-med" block>
{description}
</Text>
)}
</Box>
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface AuthFooterLinksProps {
children: React.ReactNode;
}
/**
* AuthFooterLinks
*
* Semantic container for links at the bottom of auth cards.
*/
export function AuthFooterLinks({ children }: AuthFooterLinksProps) {
return (
<Box as="footer" mt={8} pt={6} borderTop borderStyle="solid" borderColor="outline-steel">
<Stack gap={3} align="center" textAlign="center">
{children}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,25 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface AuthFormProps {
children: React.ReactNode;
onSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
}
/**
* AuthForm
*
* Semantic form wrapper for auth flows.
*/
export function AuthForm({ children, onSubmit }: AuthFormProps) {
return (
<Box as="form" onSubmit={onSubmit} noValidate>
<Stack gap={6}>
{children}
</Stack>
</Box>
);
}

View File

@@ -0,0 +1,21 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
interface AuthProviderButtonsProps {
children: React.ReactNode;
}
/**
* AuthProviderButtons
*
* Container for social login buttons (Google, Discord, etc.)
*/
export function AuthProviderButtons({ children }: AuthProviderButtonsProps) {
return (
<Box display="grid" gridCols={1} gap={3} mb={6}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,51 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
interface AuthShellProps {
children: React.ReactNode;
}
/**
* AuthShell
*
* The outermost container for all authentication pages.
* Provides the "calm intensity" background and centered layout.
*/
export function AuthShell({ children }: AuthShellProps) {
return (
<Box as="main" minHeight="100vh" display="flex" alignItems="center" justifyContent="center" p={4} bg="base-black" position="relative" overflow="hidden">
{/* Subtle background glow - top right */}
<Box
position="absolute"
top="-10%"
right="-10%"
w="40%"
h="40%"
rounded="full"
bg="rgba(25, 140, 255, 0.05)"
blur="xl"
pointerEvents="none"
aria-hidden="true"
/>
{/* Subtle background glow - bottom left */}
<Box
position="absolute"
bottom="-10%"
left="-10%"
w="40%"
h="40%"
rounded="full"
bg="rgba(78, 212, 224, 0.05)"
blur="xl"
pointerEvents="none"
aria-hidden="true"
/>
<Box w="full" maxWidth="400px" position="relative" zIndex={10} animate="fade-in">
{children}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { Panel } from '@/ui/Panel';
import { Box } from '@/ui/Box';
import { ActivityFeed } from '../feed/ActivityFeed';
interface FeedItem {
id: string;
type: string;
headline: string;
body?: string;
timestamp: string;
formattedTime: string;
ctaHref?: string;
ctaLabel?: string;
}
interface ActivityFeedPanelProps {
items: FeedItem[];
hasItems: boolean;
}
/**
* ActivityFeedPanel
*
* A semantic wrapper for the activity feed.
*/
export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) {
return (
<Panel title="Activity Feed" padding={0}>
<Box px={6} pb={6}>
<ActivityFeed items={items} hasItems={hasItems} />
</Box>
</Panel>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { Stack } from '@/ui/Stack';
interface DashboardControlBarProps {
title: string;
actions?: React.ReactNode;
}
/**
* DashboardControlBar
*
* The top header bar for page-level controls and context.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardControlBar({ title, actions }: DashboardControlBarProps) {
return (
<Box display="flex" h="full" alignItems="center" justifyContent="between" px={6}>
<Heading level={6} weight="bold">
{title}
</Heading>
<Stack direction="row" align="center" gap={4}>
{actions}
</Stack>
</Box>
);
}

View File

@@ -1,5 +1,3 @@
import { routes } from '@/lib/routing/RouteConfig'; import { routes } from '@/lib/routing/RouteConfig';
import { Button } from '@/ui/Button'; import { Button } from '@/ui/Button';
import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero'; import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero';
@@ -48,10 +46,10 @@ export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHe
} }
stats={ stats={
<> <>
<StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="var(--performance-green)" /> <StatBox icon={Trophy} label="Wins" value={currentDriver.wins} color="#10b981" />
<StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="var(--warning-amber)" /> <StatBox icon={Medal} label="Podiums" value={currentDriver.podiums} color="#FFBE4D" />
<StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="var(--primary-blue)" /> <StatBox icon={Target} label="Consistency" value={currentDriver.consistency} color="#198CFF" />
<StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="var(--neon-purple)" /> <StatBox icon={Users} label="Active Leagues" value={activeLeaguesCount} color="#a855f7" />
</> </>
} }
/> />

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { Grid } from '@/ui/Grid';
interface KpiItem {
label: string;
value: string | number;
color?: string;
}
interface DashboardKpiRowProps {
items: KpiItem[];
}
/**
* DashboardKpiRow
*
* A horizontal row of key performance indicators with telemetry styling.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardKpiRow({ items }: DashboardKpiRowProps) {
return (
<Grid responsiveGridCols={{ base: 2, md: 3, lg: 6 }} gap={4}>
{items.map((item, index) => (
<Box key={index} borderLeft pl={4} borderColor="var(--color-outline)">
<Text
size="xs"
weight="bold"
uppercase
letterSpacing="tighter"
color="var(--color-text-low)"
block
>
{item.label}
</Text>
<Text
size="xl"
font="mono"
weight="bold"
color={item.color || 'var(--color-text-high)'}
>
{item.value}
</Text>
</Box>
))}
</Grid>
);
}

View File

@@ -0,0 +1,20 @@
import React from 'react';
import { Box } from '@/ui/Box';
interface DashboardRailProps {
children: React.ReactNode;
}
/**
* DashboardRail
*
* A thin sidebar rail for high-level navigation and status indicators.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardRail({ children }: DashboardRailProps) {
return (
<Box as="nav" display="flex" h="full" flexDirection="col" alignItems="center" py={4} gap={4}>
{children}
</Box>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { Box } from '@/ui/Box';
interface DashboardShellProps {
children: React.ReactNode;
rail?: React.ReactNode;
controlBar?: React.ReactNode;
}
/**
* DashboardShell
*
* The primary layout container for the Telemetry Workspace.
* Orchestrates the sidebar rail, top control bar, and main content area.
* Uses UI primitives to comply with architectural constraints.
*/
export function DashboardShell({ children, rail, controlBar }: DashboardShellProps) {
return (
<Box display="flex" h="screen" overflow="hidden" bg="base-black" color="white">
{rail && (
<Box as="aside" w="16" flexShrink={0} borderRight bg="surface-charcoal" borderColor="var(--color-outline)">
{rail}
</Box>
)}
<Box display="flex" flexGrow={1} flexDirection="col" overflow="hidden">
{controlBar && (
<Box as="header" h="14" borderBottom bg="surface-charcoal" borderColor="var(--color-outline)">
{controlBar}
</Box>
)}
<Box as="main" flexGrow={1} overflow="auto" p={6}>
<Box maxWidth="7xl" mx="auto" display="flex" flexDirection="col" gap={6}>
{children}
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -1,29 +0,0 @@
import { routes } from '@/lib/routing/RouteConfig';
import { Trophy, Users } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Heading } from '@/ui/Heading';
import { QuickActionItem } from '@/ui/QuickActionItem';
export function QuickActions() {
return (
<Box>
<Heading level={3} mb={4}>Quick Actions</Heading>
<Box display="flex" flexDirection="col" gap={2}>
<QuickActionItem
href={routes.public.leagues}
label="Browse Leagues"
icon={Users}
iconVariant="blue"
/>
<QuickActionItem
href={routes.public.leaderboards}
label="View Leaderboards"
icon={Trophy}
iconVariant="amber"
/>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Box } from '@/ui/Box';
import { Text } from '@/ui/Text';
import { StatusDot } from '@/ui/StatusDot';
export interface ActivityItem {
id: string;
type: string;
description: string;
timestamp: string;
status?: 'success' | 'warning' | 'critical' | 'info';
}
interface RecentActivityTableProps {
items: ActivityItem[];
}
/**
* RecentActivityTable
*
* A high-density table for displaying recent events and telemetry logs.
* Uses UI primitives to comply with architectural constraints.
*/
export function RecentActivityTable({ items }: RecentActivityTableProps) {
const getStatusColor = (status?: string) => {
switch (status) {
case 'success': return 'var(--color-success)';
case 'warning': return 'var(--color-warning)';
case 'critical': return 'var(--color-critical)';
default: return 'var(--color-primary)';
}
};
return (
<Box overflow="auto">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="var(--color-outline)">
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Type</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Description</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Time</Text>
</Box>
<Box as="th" pb={2}>
<Text size="xs" weight="medium" uppercase letterSpacing="wider" color="var(--color-text-low)">Status</Text>
</Box>
</Box>
</Box>
<Box as="tbody">
{items.map((item) => (
<Box key={item.id} as="tr" borderBottom borderColor="rgba(35, 39, 43, 0.5)" hoverBg="rgba(255, 255, 255, 0.05)" transition>
<Box as="td" py={3}>
<Text font="mono" color="var(--color-telemetry)" size="xs">{item.type}</Text>
</Box>
<Box as="td" py={3}>
<Text color="var(--color-text-med)" size="xs">{item.description}</Text>
</Box>
<Box as="td" py={3}>
<Text color="var(--color-text-low)" size="xs">{item.timestamp}</Text>
</Box>
<Box as="td" py={3}>
<StatusDot color={getStatusColor(item.status)} size={1.5} />
</Box>
</Box>
))}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,28 @@
import React from 'react';
import { Surface } from '@/ui/Surface';
import { Heading } from '@/ui/Heading';
import { Box } from '@/ui/Box';
interface TelemetryPanelProps {
title: string;
children: React.ReactNode;
}
/**
* TelemetryPanel
*
* A dense, instrument-grade panel for displaying data and controls.
* Uses UI primitives to comply with architectural constraints.
*/
export function TelemetryPanel({ title, children }: TelemetryPanelProps) {
return (
<Surface variant="dark" border rounded="sm" padding={4} shadow="sm">
<Heading level={6} mb={4} color="var(--color-text-low)">
{title}
</Heading>
<Box fontSize="sm">
{children}
</Box>
</Surface>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface DriverPerformanceOverviewProps {
stats: {
wins: number;
podiums: number;
totalRaces: number;
consistency: number;
dnfs: number;
bestFinish: number;
avgFinish: number;
};
}
export function DriverPerformanceOverview({ stats }: DriverPerformanceOverviewProps) {
const winRate = stats.totalRaces > 0 ? (stats.wins / stats.totalRaces) * 100 : 0;
const podiumRate = stats.totalRaces > 0 ? (stats.podiums / stats.totalRaces) * 100 : 0;
const metrics = [
{ label: 'Win Rate', value: `${winRate.toFixed(1)}%`, color: 'text-performance-green' },
{ label: 'Podium Rate', value: `${podiumRate.toFixed(1)}%`, color: 'text-warning-amber' },
{ label: 'Best Finish', value: `P${stats.bestFinish}`, color: 'text-white' },
{ label: 'Avg Finish', value: `P${stats.avgFinish.toFixed(1)}`, color: 'text-gray-400' },
{ label: 'Consistency', value: `${stats.consistency}%`, color: 'text-neon-aqua' },
{ label: 'DNFs', value: stats.dnfs, color: 'text-red-500' },
];
return (
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
<Heading level={3}>Performance Overview</Heading>
<Box display="grid" gridCols={{ base: 2, md: 3, lg: 6 }} gap={6}>
{metrics.map((metric, index) => (
<Box key={index} display="flex" flexDirection="col" gap={1}>
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{metric.label}
</Text>
<Text size="xl" weight="bold" font="mono" color={metric.color}>
{metric.value}
</Text>
</Box>
))}
</Box>
{/* Visual Progress Bars */}
<Box display="flex" flexDirection="col" gap={4} mt={2}>
<Box display="flex" flexDirection="col" gap={2}>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" weight="bold" color="text-gray-400">Win Rate</Text>
<Text size="xs" weight="bold" font="mono" color="text-performance-green">{winRate.toFixed(1)}%</Text>
</Box>
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
<Box
h="full"
bg="bg-performance-green"
shadow="shadow-[0_0_8px_rgba(34,197,94,0.4)]"
transition
width={`${winRate}%`}
/>
</Box>
</Box>
<Box display="flex" flexDirection="col" gap={2}>
<Box display="flex" justifyContent="between" alignItems="center">
<Text size="xs" weight="bold" color="text-gray-400">Podium Rate</Text>
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">{podiumRate.toFixed(1)}%</Text>
</Box>
<Box h="1.5" w="full" rounded="full" bg="bg-charcoal-outline" overflow="hidden">
<Box
h="full"
bg="bg-warning-amber"
shadow="shadow-[0_0_8px_rgba(255,190,77,0.4)]"
transition
width={`${podiumRate}%`}
/>
</Box>
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React from 'react';
import { Globe, Trophy, UserPlus, Check } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { RatingBadge } from '@/ui/RatingBadge';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
import { SafetyRatingBadge } from './SafetyRatingBadge';
interface DriverProfileHeaderProps {
name: string;
avatarUrl?: string | null;
nationality: string;
rating: number;
safetyRating?: number;
globalRank?: number;
bio?: string | null;
friendRequestSent: boolean;
onAddFriend: () => void;
}
export function DriverProfileHeader({
name,
avatarUrl,
nationality,
rating,
safetyRating = 92,
globalRank,
bio,
friendRequestSent,
onAddFriend,
}: DriverProfileHeaderProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
return (
<Box position="relative" overflow="hidden" rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" p={{ base: 6, lg: 8 }}>
{/* Background Accents */}
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} gap={8}>
{/* Avatar */}
<Box position="relative" h={{ base: '32', lg: '40' }} w={{ base: '32', lg: '40' }} flexShrink={0} overflow="hidden" rounded="2xl" border={true} borderWidth="2px" borderColor="border-charcoal-outline" bg="bg-deep-graphite" shadow="2xl">
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
{/* Info */}
<Box display="flex" flexGrow={1} flexDirection="col" gap={4}>
<Box display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={2}>
<Box>
<Stack direction="row" align="center" gap={3} mb={1}>
<Heading level={1}>{name}</Heading>
{globalRank && (
<Box display="flex" alignItems="center" gap={1} rounded="md" bg="bg-warning-amber/10" px={2} py={0.5} border borderColor="border-warning-amber/20">
<Trophy size={12} color="#FFBE4D" />
<Text size="xs" weight="bold" font="mono" color="text-warning-amber">
#{globalRank}
</Text>
</Box>
)}
</Stack>
<Stack direction="row" align="center" gap={4}>
<Stack direction="row" align="center" gap={1.5}>
<Globe size={14} color="#6B7280" />
<Text size="sm" color="text-gray-400">{nationality}</Text>
</Stack>
<Box w="1" h="1" rounded="full" bg="bg-gray-700" />
<Stack direction="row" align="center" gap={2}>
<RatingBadge rating={rating} size="sm" />
<SafetyRatingBadge rating={safetyRating} size="sm" />
</Stack>
</Stack>
</Box>
<Box mt={{ base: 4, lg: 0 }}>
<Button
variant={friendRequestSent ? 'secondary' : 'primary'}
onClick={onAddFriend}
disabled={friendRequestSent}
icon={friendRequestSent ? <Check size={18} /> : <UserPlus size={18} />}
>
{friendRequestSent ? 'Request Sent' : 'Add Friend'}
</Button>
</Box>
</Box>
{bio && (
<Box maxWidth="3xl">
<Text size="sm" color="text-gray-400" leading="relaxed">
{bio}
</Text>
</Box>
)}
</Box>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import React from 'react';
import { LayoutDashboard, BarChart3, ShieldCheck } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
export type ProfileTab = 'overview' | 'stats' | 'ratings';
interface DriverProfileTabsProps {
activeTab: ProfileTab;
onTabChange: (tab: ProfileTab) => void;
}
export function DriverProfileTabs({ activeTab, onTabChange }: DriverProfileTabsProps) {
const tabs = [
{ id: 'overview', label: 'Overview', icon: LayoutDashboard },
{ id: 'stats', label: 'Career Stats', icon: BarChart3 },
{ id: 'ratings', label: 'Ratings', icon: ShieldCheck },
] as const;
return (
<Box display="flex" alignItems="center" gap={1} borderBottom borderColor="border-charcoal-outline">
{tabs.map((tab) => {
const isActive = activeTab === tab.id;
const Icon = tab.icon;
return (
<Box
as="button"
key={tab.id}
onClick={() => onTabChange(tab.id)}
position="relative"
display="flex"
alignItems="center"
gap={2}
px={6}
py={4}
transition
hoverBg="bg-white/5"
color={isActive ? 'text-primary-blue' : 'text-gray-500'}
hoverTextColor={isActive ? 'text-primary-blue' : 'text-gray-300'}
>
<Icon size={18} />
<Text size="sm" weight={isActive ? 'bold' : 'medium'} color="inherit">
{tab.label}
</Text>
{isActive && (
<Box position="absolute" bottom="0" left="0" h="0.5" w="full" bg="bg-primary-blue" shadow="shadow-[0_0_8px_rgba(25,140,255,0.5)]" />
)}
</Box>
);
})}
</Box>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
import React from 'react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { MapPin, Car, Clock, Users2, MailCheck } from 'lucide-react';
interface DriverRacingProfileProps {
racingStyle?: string | null;
favoriteTrack?: string | null;
favoriteCar?: string | null;
availableHours?: string | null;
lookingForTeam?: boolean;
openToRequests?: boolean;
}
export function DriverRacingProfile({
racingStyle,
favoriteTrack,
favoriteCar,
availableHours,
lookingForTeam,
openToRequests,
}: DriverRacingProfileProps) {
const details = [
{ label: 'Racing Style', value: racingStyle || 'Not specified', icon: Users2 },
{ label: 'Favorite Track', value: favoriteTrack || 'Not specified', icon: MapPin },
{ label: 'Favorite Car', value: favoriteCar || 'Not specified', icon: Car },
{ label: 'Availability', value: availableHours || 'Not specified', icon: Clock },
];
return (
<Box display="flex" flexDirection="col" gap={6} rounded="2xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50" p={6}>
<Box display="flex" alignItems="center" justifyContent="between">
<Heading level={3}>Racing Profile</Heading>
<Stack direction="row" gap={2}>
{lookingForTeam && (
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20" px={3} py={1}>
<Users2 size={12} color="#198CFF" />
<Text size="xs" weight="bold" color="text-primary-blue" uppercase letterSpacing="tight">Looking for Team</Text>
</Box>
)}
{openToRequests && (
<Box display="flex" alignItems="center" gap={1.5} rounded="full" bg="bg-performance-green/10" border borderColor="border-performance-green/20" px={3} py={1}>
<MailCheck size={12} color="#22C55E" />
<Text size="xs" weight="bold" color="text-performance-green" uppercase letterSpacing="tight">Open to Requests</Text>
</Box>
)}
</Stack>
</Box>
<Box display="grid" gridCols={{ base: 1, sm: 2 }} gap={4}>
{details.map((detail, index) => {
const Icon = detail.icon;
return (
<Box key={index} display="flex" alignItems="center" gap={4} rounded="xl" border borderColor="border-charcoal-outline/50" bg="bg-deep-graphite/50" p={4}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="lg" bg="bg-charcoal-outline/50" color="text-gray-400">
<Icon size={20} />
</Box>
<Box display="flex" flexDirection="col">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{detail.label}
</Text>
<Text size="sm" weight="semibold" color="text-white">
{detail.value}
</Text>
</Box>
</Box>
);
})}
</Box>
</Box>
);
}

View File

@@ -0,0 +1,24 @@
'use client';
import React from 'react';
import { Search } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Input } from '@/ui/Input';
interface DriverSearchBarProps {
query: string;
onChange: (query: string) => void;
}
export function DriverSearchBar({ query, onChange }: DriverSearchBarProps) {
return (
<Box position="relative" group>
<Input
value={query}
onChange={(e) => onChange(e.target.value)}
placeholder="Search drivers by name or nationality..."
icon={<Search size={20} />}
/>
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface StatItem {
label: string;
value: string | number;
subValue?: string;
color?: string;
}
interface DriverStatsPanelProps {
stats: StatItem[];
}
export function DriverStatsPanel({ stats }: DriverStatsPanelProps) {
return (
<Box display="grid" gridCols={{ base: 2, sm: 3, lg: 6 }} gap="px" overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-charcoal-outline">
{stats.map((stat, index) => (
<Box key={index} display="flex" flexDirection="col" gap={1} bg="bg-deep-charcoal" p={5} transition hoverBg="bg-deep-charcoal/80">
<Text size="xs" weight="bold" color="text-gray-500" uppercase letterSpacing="wider">
{stat.label}
</Text>
<Box display="flex" alignItems="baseline" gap={1.5}>
<Text
size="2xl"
weight="bold"
font="mono"
color={stat.color || 'text-white'}
>
{stat.value}
</Text>
{stat.subValue && (
<Text size="xs" weight="bold" color="text-gray-600" font="mono">
{stat.subValue}
</Text>
)}
</Box>
</Box>
))}
</Box>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
import React from 'react';
import { TrendingUp } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface DriverTableProps {
children: React.ReactNode;
}
export function DriverTable({ children }: DriverTableProps) {
return (
<Stack gap={4}>
<Stack direction="row" align="center" gap={3}>
<Box display="flex" h="10" w="10" alignItems="center" justifyContent="center" rounded="xl" bg="bg-primary-blue/10" border borderColor="border-primary-blue/20">
<TrendingUp size={20} color="#198CFF" />
</Box>
<Box>
<Heading level={2}>Driver Rankings</Heading>
<Text size="xs" color="text-gray-500">Top performers by skill rating</Text>
</Box>
</Stack>
<Box overflow="hidden" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal/50">
<Box as="table" w="full" textAlign="left">
<Box as="thead">
<Box as="tr" borderBottom borderColor="border-charcoal-outline" bg="bg-deep-charcoal/80">
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="center" width="60px">#</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500">Driver</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" width="150px">Nationality</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="100px">Rating</Box>
<Box as="th" px={6} py={4} fontSize="xs" color="text-gray-500" textAlign="right" width="80px">Wins</Box>
</Box>
</Box>
<Box as="tbody">
{children}
</Box>
</Box>
</Box>
</Stack>
);
}

View File

@@ -0,0 +1,86 @@
'use client';
import React from 'react';
import { RatingBadge } from '@/ui/RatingBadge';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Image } from '@/ui/Image';
interface DriverTableRowProps {
rank: number;
name: string;
avatarUrl?: string | null;
nationality: string;
rating: number;
wins: number;
onClick: () => void;
}
export function DriverTableRow({
rank,
name,
avatarUrl,
nationality,
rating,
wins,
onClick,
}: DriverTableRowProps) {
const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png';
return (
<Box
as="tr"
onClick={onClick}
cursor="pointer"
transition
hoverBg="bg-primary-blue/5"
group
borderBottom
borderColor="border-charcoal-outline/50"
>
<Box as="td" px={6} py={4} textAlign="center">
<Text
size="sm"
weight="bold"
font="mono"
color={rank <= 3 ? 'text-warning-amber' : 'text-gray-500'}
>
{rank}
</Text>
</Box>
<Box as="td" px={6} py={4}>
<Stack direction="row" align="center" gap={3}>
<Box position="relative" h="8" w="8" overflow="hidden" rounded="full" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal">
<Image
src={avatarUrl || defaultAvatar}
alt={name}
fill
objectFit="cover"
/>
</Box>
<Text
size="sm"
weight="semibold"
color="text-white"
groupHoverTextColor="text-primary-blue"
transition
>
{name}
</Text>
</Stack>
</Box>
<Box as="td" px={6} py={4}>
<Text size="xs" color="text-gray-400">{nationality}</Text>
</Box>
<Box as="td" px={6} py={4} textAlign="right">
<RatingBadge rating={rating} size="sm" />
</Box>
<Box as="td" px={6} py={4} textAlign="right">
<Text size="sm" weight="semibold" font="mono" color="text-performance-green">
{wins}
</Text>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,101 @@
'use client';
import React from 'react';
import { Users, Trophy } from 'lucide-react';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
interface DriverStat {
label: string;
value: string | number;
color?: string;
animate?: boolean;
}
interface DriversDirectoryHeaderProps {
totalDrivers: number;
activeDrivers: number;
totalWins: number;
totalRaces: number;
onViewLeaderboard: () => void;
}
export function DriversDirectoryHeader({
totalDrivers,
activeDrivers,
totalWins,
totalRaces,
onViewLeaderboard,
}: DriversDirectoryHeaderProps) {
const stats: DriverStat[] = [
{ label: 'drivers', value: totalDrivers, color: 'text-primary-blue' },
{ label: 'active', value: activeDrivers, color: 'text-performance-green', animate: true },
{ label: 'total wins', value: totalWins.toLocaleString(), color: 'text-warning-amber' },
{ label: 'races', value: totalRaces.toLocaleString(), color: 'text-neon-aqua' },
];
return (
<Box
as="header"
position="relative"
overflow="hidden"
rounded="2xl"
border
borderColor="border-charcoal-outline/50"
bg="bg-gradient-to-br from-iron-gray/80 via-deep-graphite to-iron-gray/60"
p={{ base: 8, lg: 10 }}
>
{/* Background Accents */}
<Box position="absolute" right="-24" top="-24" w="96" h="96" rounded="full" bg="bg-primary-blue/5" blur="3xl" />
<Box position="absolute" bottom="-16" left="-16" w="64" h="64" rounded="full" bg="bg-neon-aqua/5" blur="3xl" />
<Box position="relative" display="flex" flexDirection={{ base: 'col', lg: 'row' }} alignItems={{ lg: 'center' }} justifyContent="between" gap={8}>
<Box maxWidth="2xl">
<Stack direction="row" align="center" gap={3} mb={4}>
<Box display="flex" h="12" w="12" alignItems="center" justifyContent="center" rounded="xl" border borderColor="border-charcoal-outline" bg="bg-deep-charcoal" shadow="lg">
<Users size={24} color="#198CFF" />
</Box>
<Heading level={1}>Drivers</Heading>
</Stack>
<Text size="lg" color="text-gray-400" block leading="relaxed">
Meet the racers who make every lap count. From rookies to champions, track their journey and see who&apos;s dominating the grid.
</Text>
<Box display="flex" flexWrap="wrap" gap={6} mt={6}>
{stats.map((stat, index) => (
<Stack key={index} direction="row" align="center" gap={2}>
<Box
w="2"
h="2"
rounded="full"
bg={stat.color?.replace('text-', 'bg-') || 'bg-primary-blue'}
animate={stat.animate ? 'pulse' : 'none'}
/>
<Text size="sm" color="text-gray-400">
<Text as="span" weight="semibold" color="text-white">{stat.value}</Text> {stat.label}
</Text>
</Stack>
))}
</Box>
</Box>
<Stack gap={2}>
<Button
variant="primary"
onClick={onViewLeaderboard}
icon={<Trophy size={20} />}
>
View Leaderboard
</Button>
<Text size="xs" color="text-gray-500" align="center" block>
See full driver rankings
</Text>
</Stack>
</Box>
</Box>
);
}

View File

@@ -0,0 +1,73 @@
'use client';
import React from 'react';
import { Shield } from 'lucide-react';
import { Text } from '@/ui/Text';
import { Box } from '@/ui/Box';
interface SafetyRatingBadgeProps {
rating: number;
size?: 'sm' | 'md' | 'lg';
}
export function SafetyRatingBadge({ rating, size = 'md' }: SafetyRatingBadgeProps) {
const getColor = (r: number) => {
if (r >= 90) return 'text-performance-green';
if (r >= 70) return 'text-warning-amber';
return 'text-red-500';
};
const getBgColor = (r: number) => {
if (r >= 90) return 'bg-performance-green/10';
if (r >= 70) return 'bg-warning-amber/10';
return 'bg-red-500/10';
};
const getBorderColor = (r: number) => {
if (r >= 90) return 'border-performance-green/20';
if (r >= 70) return 'border-warning-amber/20';
return 'border-red-500/20';
};
const sizeProps = {
sm: { px: 2, py: 0.5, gap: 1 },
md: { px: 3, py: 1, gap: 1.5 },
lg: { px: 4, py: 2, gap: 2 },
};
const iconSizes = {
sm: 12,
md: 14,
lg: 16,
};
const iconColors = {
'text-performance-green': '#22C55E',
'text-warning-amber': '#FFBE4D',
'text-red-500': '#EF4444',
};
const colorClass = getColor(rating);
return (
<Box
display="inline-flex"
alignItems="center"
rounded="full"
border
bg={getBgColor(rating)}
borderColor={getBorderColor(rating)}
{...sizeProps[size]}
>
<Shield size={iconSizes[size]} color={iconColors[colorClass as keyof typeof iconColors]} />
<Text
size={size === 'lg' ? 'sm' : 'xs'}
weight="bold"
font="mono"
color={colorClass}
>
SR {rating.toFixed(0)}
</Text>
</Box>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Icon } from '@/ui/Icon';
import { Heading } from '@/ui/Heading';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
interface AppErrorBoundaryViewProps {
title: string;
description: string;
children?: React.ReactNode;
}
/**
* AppErrorBoundaryView
*
* Semantic container for error boundary content.
* Follows "Precision Racing Minimal" theme.
*/
export function AppErrorBoundaryView({ title, description, children }: AppErrorBoundaryViewProps) {
return (
<Stack gap={6} align="center" fullWidth>
{/* Header Icon */}
<Box
p={4}
rounded="full"
bg="bg-warning-amber"
bgOpacity={0.1}
border
borderColor="border-warning-amber"
>
<Icon icon={AlertTriangle} size={8} color="var(--warning-amber)" />
</Box>
{/* Typography */}
<Stack gap={2} align="center">
<Heading level={1} weight="bold">
<Text uppercase letterSpacing="tighter">
{title}
</Text>
</Heading>
<Text color="text-gray-400" align="center" maxWidth="md" leading="relaxed">
{description}
</Text>
</Stack>
{children}
</Stack>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
import React, { useState } from 'react';
import { ChevronDown, ChevronUp, Copy, Terminal } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Stack } from '@/ui/Stack';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Surface } from '@/ui/Surface';
interface ErrorDetailsProps {
error: Error & { digest?: string };
}
/**
* ErrorDetails
*
* Handles the display of technical error information with a toggle.
* Part of the 500 route redesign.
*/
export function ErrorDetails({ error }: ErrorDetailsProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
const copyError = async () => {
const details = {
message: error.message,
digest: error.digest,
stack: error.stack,
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
timestamp: new Date().toISOString(),
};
try {
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silent fail
}
};
return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white">
<Box
as="button"
onClick={() => setShowDetails(!showDetails)}
display="flex"
alignItems="center"
justifyContent="center"
gap={2}
color="text-gray-500"
hoverTextColor="text-gray-300"
transition
>
<Icon icon={Terminal} size={3} />
<Text
size="xs"
weight="medium"
uppercase
letterSpacing="widest"
color="inherit"
>
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
</Text>
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
</Box>
{showDetails && (
<Stack gap={3}>
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
maxHeight="48"
overflow="auto"
border
borderColor="border-white"
bgOpacity={0.4}
hideScrollbar={false}
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`}
</Text>
</Surface>
<Box display="flex" justifyContent="end">
<Button
variant="secondary"
size="sm"
onClick={copyError}
icon={<Icon icon={Copy} size={3} />}
height="8"
fontSize="10px"
>
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
</Button>
</Box>
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,107 @@
'use client';
import React, { useState } from 'react';
import { Copy, ChevronDown, ChevronUp } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Text } from '@/ui/Text';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
import { Stack } from '@/ui/Stack';
interface ErrorDetailsBlockProps {
error: Error & { digest?: string };
}
/**
* ErrorDetailsBlock
*
* Semantic component for technical error details.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorDetailsBlock({ error }: ErrorDetailsBlockProps) {
const [showDetails, setShowDetails] = useState(false);
const [copied, setCopied] = useState(false);
const copyError = async () => {
const details = {
message: error.message,
digest: error.digest,
stack: error.stack,
url: typeof window !== 'undefined' ? window.location.href : 'unknown',
timestamp: new Date().toISOString(),
};
try {
await navigator.clipboard.writeText(JSON.stringify(details, null, 2));
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
// Silent fail
}
};
return (
<Stack gap={4} fullWidth pt={4} borderTop borderColor="border-white" bgOpacity={0.1}>
<Box
as="button"
onClick={() => setShowDetails(!showDetails)}
display="flex"
alignItems="center"
justifyContent="center"
gap={2}
transition
>
<Text
size="xs"
color="text-gray-500"
hoverTextColor="text-gray-300"
uppercase
letterSpacing="widest"
weight="medium"
display="flex"
alignItems="center"
gap={2}
>
{showDetails ? <Icon icon={ChevronUp} size={3} /> : <Icon icon={ChevronDown} size={3} />}
{showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'}
</Text>
</Box>
{showDetails && (
<Stack gap={3}>
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
maxHeight="48"
overflow="auto"
border
borderColor="border-white"
bgOpacity={0.4}
hideScrollbar={false}
>
<Text font="mono" size="xs" color="text-gray-500" block leading="relaxed">
{error.stack || 'No stack trace available'}
{error.digest && `\n\nDigest: ${error.digest}`}
</Text>
</Surface>
<Box display="flex" justifyContent="end">
<Button
variant="secondary"
size="sm"
onClick={copyError}
icon={<Icon icon={Copy} size={3} />}
height="8"
fontSize="10px"
>
{copied ? 'Copied to Clipboard' : 'Copy Error Details'}
</Button>
</Box>
</Stack>
)}
</Stack>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import React from 'react';
import { RefreshCw, Home } from 'lucide-react';
import { Box } from '@/ui/Box';
import { Button } from '@/ui/Button';
import { Icon } from '@/ui/Icon';
interface ErrorRecoveryActionsProps {
onRetry: () => void;
onHome: () => void;
}
/**
* ErrorRecoveryActions
*
* Semantic component for error recovery buttons.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorRecoveryActions({ onRetry, onHome }: ErrorRecoveryActionsProps) {
return (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
justifyContent="center"
gap={3}
fullWidth
>
<Button
variant="primary"
onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />}
width="160px"
>
Retry Session
</Button>
<Button
variant="secondary"
onClick={onHome}
icon={<Icon icon={Home} size={4} />}
width="160px"
>
Return to Pits
</Button>
</Box>
);
}

View File

@@ -0,0 +1,52 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi } from 'vitest';
import { ErrorScreen } from './ErrorScreen';
describe('ErrorScreen', () => {
const mockError = new Error('Test error message');
(mockError as any).digest = 'test-digest';
(mockError as any).stack = 'test-stack-trace';
const mockReset = vi.fn();
const mockOnHome = vi.fn();
it('renders error message and system malfunction title', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
expect(screen.getByText('System Malfunction')).toBeDefined();
expect(screen.getByText('Test error message')).toBeDefined();
});
it('calls reset when Retry Session is clicked', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Retry Session');
fireEvent.click(button);
expect(mockReset).toHaveBeenCalledTimes(1);
});
it('calls onHome when Return to Pits is clicked', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
const button = screen.getByText('Return to Pits');
fireEvent.click(button);
expect(mockOnHome).toHaveBeenCalledTimes(1);
});
it('toggles technical logs visibility', () => {
render(<ErrorScreen error={mockError} reset={mockReset} onHome={mockOnHome} />);
expect(screen.queryByText('test-stack-trace')).toBeNull();
const toggle = screen.getByText('Show Technical Logs');
fireEvent.click(toggle);
expect(screen.getByText(/test-stack-trace/)).toBeDefined();
expect(screen.getByText(/Digest: test-digest/)).toBeDefined();
fireEvent.click(screen.getByText('Hide Technical Logs'));
expect(screen.queryByText(/test-stack-trace/)).toBeNull();
});
});

View File

@@ -0,0 +1,80 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { Text } from '@/ui/Text';
import { AppErrorBoundaryView } from './AppErrorBoundaryView';
import { ErrorRecoveryActions } from './ErrorRecoveryActions';
import { ErrorDetailsBlock } from './ErrorDetailsBlock';
interface ErrorScreenProps {
error: Error & { digest?: string };
reset: () => void;
onHome: () => void;
}
/**
* ErrorScreen
*
* Semantic component for the root-level error boundary.
* Follows "Precision Racing Minimal" theme.
*/
export function ErrorScreen({ error, reset, onHome }: ErrorScreenProps) {
return (
<Box
as="main"
minHeight="screen"
fullWidth
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-deep-graphite"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents */}
<Glow color="primary" size="xl" position="center" opacity={0.05} />
<Surface
variant="glass"
border
rounded="lg"
padding={8}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
shadow="xl"
borderColor="border-white"
bgOpacity={0.05}
>
<AppErrorBoundaryView
title="System Malfunction"
description="The application encountered an unexpected state. Our telemetry has logged the incident."
>
{/* Error Message Summary */}
<Surface
variant="dark"
rounded="md"
padding={4}
fullWidth
border
borderColor="border-white"
bgOpacity={0.2}
>
<Text font="mono" size="sm" color="text-warning-amber" block>
{error.message || 'Unknown execution error'}
</Text>
</Surface>
<ErrorRecoveryActions onRetry={reset} onHome={onHome} />
<ErrorDetailsBlock error={error} />
</AppErrorBoundaryView>
</Surface>
</Box>
);
}

View File

@@ -0,0 +1,185 @@
'use client';
import React from 'react';
import { Box } from '@/ui/Box';
import { Surface } from '@/ui/Surface';
import { Glow } from '@/ui/Glow';
import { Text } from '@/ui/Text';
import { Stack } from '@/ui/Stack';
import { Heading } from '@/ui/Heading';
import { Icon } from '@/ui/Icon';
import { AlertTriangle, RefreshCw, Home, Terminal } from 'lucide-react';
import { Button } from '@/ui/Button';
interface GlobalErrorScreenProps {
error: Error & { digest?: string };
reset: () => void;
onHome: () => void;
}
/**
* GlobalErrorScreen
*
* A strong, minimal "system fault" view for the root global error boundary.
* Instrument-grade UI following the "Precision Racing Minimal" theme.
*/
export function GlobalErrorScreen({ error, reset, onHome }: GlobalErrorScreenProps) {
return (
<Box
as="main"
minHeight="screen"
fullWidth
display="flex"
alignItems="center"
justifyContent="center"
bg="bg-base-black"
position="relative"
overflow="hidden"
px={6}
>
{/* Background Accents - Subtle telemetry vibe */}
<Glow color="primary" size="xl" position="center" opacity={0.03} />
<Surface
variant="dark"
border
rounded="none"
padding={0}
maxWidth="2xl"
fullWidth
position="relative"
zIndex={10}
borderColor="border-white"
bgOpacity={0.1}
>
{/* System Status Header */}
<Box
borderBottom
borderColor="border-white"
bgOpacity={0.05}
px={6}
py={4}
display="flex"
alignItems="center"
justifyContent="space-between"
>
<Stack direction="row" gap={3} align="center">
<Icon icon={AlertTriangle} size={5} color="var(--warning-amber)" />
<Heading level={2} weight="bold">
<Text uppercase letterSpacing="widest" size="sm">
System Fault Detected
</Text>
</Heading>
</Stack>
<Text font="mono" size="xs" color="text-gray-500" uppercase>
Status: Critical
</Text>
</Box>
<Box p={8}>
<Stack gap={8}>
{/* Fault Description */}
<Stack gap={4}>
<Text color="text-gray-400" size="base" leading="relaxed">
The application kernel encountered an unrecoverable execution error.
Telemetry has been captured for diagnostic review.
</Text>
<SystemStatusPanel error={error} />
</Stack>
{/* Recovery Actions */}
<RecoveryActions onRetry={reset} onHome={onHome} />
</Stack>
</Box>
{/* Footer / Metadata */}
<Box
borderTop
borderColor="border-white"
bgOpacity={0.05}
px={6}
py={3}
display="flex"
justifyContent="end"
>
<Text font="mono" size="xs" color="text-gray-600">
GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'}
</Text>
</Box>
</Surface>
</Box>
);
}
/**
* SystemStatusPanel
*
* Displays technical fault details in an instrument-grade panel.
*/
function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) {
return (
<Surface
variant="dark"
rounded="none"
padding={4}
fullWidth
border
borderColor="border-white"
bgOpacity={0.2}
>
<Stack gap={3}>
<Box display="flex" alignItems="center" gap={2}>
<Icon icon={Terminal} size={3} color="var(--gray-500)" />
<Text font="mono" size="xs" color="text-gray-500" uppercase letterSpacing="wider">
Fault Log
</Text>
</Box>
<Text font="mono" size="sm" color="text-warning-amber" block>
{error.message || 'Unknown execution fault'}
</Text>
{error.digest && (
<Text font="mono" size="xs" color="text-gray-600" block>
Digest: {error.digest}
</Text>
)}
</Stack>
</Surface>
);
}
/**
* RecoveryActions
*
* Clear, instrument-grade recovery options.
*/
function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) {
return (
<Box
display="flex"
flexWrap="wrap"
alignItems="center"
gap={4}
fullWidth
>
<Button
variant="primary"
onClick={onRetry}
icon={<Icon icon={RefreshCw} size={4} />}
rounded="none"
px={8}
>
Reboot Session
</Button>
<Button
variant="secondary"
onClick={onHome}
icon={<Icon icon={Home} size={4} />}
rounded="none"
px={8}
>
Return to Pits
</Button>
</Box>
);
}

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