website refactor
This commit is contained in:
29
apps/website/app/404/NotFoundPageClient.tsx
Normal file
29
apps/website/app/404/NotFoundPageClient.tsx
Normal 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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
50
apps/website/app/500/ServerErrorPageClient.test.tsx
Normal file
50
apps/website/app/500/ServerErrorPageClient.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
40
apps/website/app/500/ServerErrorPageClient.tsx
Normal file
40
apps/website/app/500/ServerErrorPageClient.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -29,4 +29,4 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper
|
|||||||
isLoading={loading}
|
isLoading={loading}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 "<Text color="text-primary-blue">{searchQuery}</Text>"
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)} />
|
||||||
|
|||||||
@@ -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's finances and payouts</Text>
|
<Text color="text-gray-400">Manage your league'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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
24
apps/website/app/media/MediaPageClient.tsx
Normal file
24
apps/website/app/media/MediaPageClient.tsx
Normal 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."
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/app/media/avatar/page.tsx
Normal file
20
apps/website/app/media/avatar/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/app/media/leagues/page.tsx
Normal file
20
apps/website/app/media/leagues/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
apps/website/app/media/page.tsx
Normal file
30
apps/website/app/media/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/app/media/sponsors/page.tsx
Normal file
20
apps/website/app/media/sponsors/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/app/media/teams/page.tsx
Normal file
20
apps/website/app/media/teams/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/app/media/tracks/page.tsx
Normal file
20
apps/website/app/media/tracks/page.tsx
Normal 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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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't exist (or isn'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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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')}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>"{s.applicationMessage}"</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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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'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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.',
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
57
apps/website/components/actions/ActionFiltersBar.tsx
Normal file
57
apps/website/components/actions/ActionFiltersBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/website/components/actions/ActionList.tsx
Normal file
52
apps/website/components/actions/ActionList.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/website/components/actions/ActionStatusBadge.tsx
Normal file
43
apps/website/components/actions/ActionStatusBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
41
apps/website/components/actions/ActionsHeader.tsx
Normal file
41
apps/website/components/actions/ActionsHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
44
apps/website/components/admin/AdminDangerZonePanel.tsx
Normal file
44
apps/website/components/admin/AdminDangerZonePanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/website/components/admin/AdminDataTable.tsx
Normal file
32
apps/website/components/admin/AdminDataTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
apps/website/components/admin/AdminEmptyState.tsx
Normal file
49
apps/website/components/admin/AdminEmptyState.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/website/components/admin/AdminHeaderPanel.tsx
Normal file
53
apps/website/components/admin/AdminHeaderPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/website/components/admin/AdminSectionHeader.tsx
Normal file
45
apps/website/components/admin/AdminSectionHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/website/components/admin/AdminStatsPanel.tsx
Normal file
45
apps/website/components/admin/AdminStatsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
37
apps/website/components/admin/AdminToolbar.tsx
Normal file
37
apps/website/components/admin/AdminToolbar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
170
apps/website/components/admin/AdminUsersTable.tsx
Normal file
170
apps/website/components/admin/AdminUsersTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
93
apps/website/components/admin/BulkActionBar.tsx
Normal file
93
apps/website/components/admin/BulkActionBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
68
apps/website/components/admin/UserStatusTag.tsx
Normal file
68
apps/website/components/admin/UserStatusTag.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
apps/website/components/auth/AuthCard.tsx
Normal file
47
apps/website/components/auth/AuthCard.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/website/components/auth/AuthFooterLinks.tsx
Normal file
24
apps/website/components/auth/AuthFooterLinks.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/website/components/auth/AuthForm.tsx
Normal file
25
apps/website/components/auth/AuthForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
apps/website/components/auth/AuthProviderButtons.tsx
Normal file
21
apps/website/components/auth/AuthProviderButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/website/components/auth/AuthShell.tsx
Normal file
51
apps/website/components/auth/AuthShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
apps/website/components/dashboard/ActivityFeedPanel.tsx
Normal file
35
apps/website/components/dashboard/ActivityFeedPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/website/components/dashboard/DashboardControlBar.tsx
Normal file
28
apps/website/components/dashboard/DashboardControlBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" />
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
49
apps/website/components/dashboard/DashboardKpiRow.tsx
Normal file
49
apps/website/components/dashboard/DashboardKpiRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
20
apps/website/components/dashboard/DashboardRail.tsx
Normal file
20
apps/website/components/dashboard/DashboardRail.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
apps/website/components/dashboard/DashboardShell.tsx
Normal file
39
apps/website/components/dashboard/DashboardShell.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
74
apps/website/components/dashboard/RecentActivityTable.tsx
Normal file
74
apps/website/components/dashboard/RecentActivityTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
apps/website/components/dashboard/TelemetryPanel.tsx
Normal file
28
apps/website/components/dashboard/TelemetryPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/website/components/drivers/DriverProfileHeader.tsx
Normal file
106
apps/website/components/drivers/DriverProfileHeader.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
57
apps/website/components/drivers/DriverProfileTabs.tsx
Normal file
57
apps/website/components/drivers/DriverProfileTabs.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
apps/website/components/drivers/DriverRacingProfile.tsx
Normal file
76
apps/website/components/drivers/DriverRacingProfile.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
apps/website/components/drivers/DriverSearchBar.tsx
Normal file
24
apps/website/components/drivers/DriverSearchBar.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/website/components/drivers/DriverStatsPanel.tsx
Normal file
45
apps/website/components/drivers/DriverStatsPanel.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
45
apps/website/components/drivers/DriverTable.tsx
Normal file
45
apps/website/components/drivers/DriverTable.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
apps/website/components/drivers/DriverTableRow.tsx
Normal file
86
apps/website/components/drivers/DriverTableRow.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
101
apps/website/components/drivers/DriversDirectoryHeader.tsx
Normal file
101
apps/website/components/drivers/DriversDirectoryHeader.tsx
Normal 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
apps/website/components/drivers/SafetyRatingBadge.tsx
Normal file
73
apps/website/components/drivers/SafetyRatingBadge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
apps/website/components/errors/AppErrorBoundaryView.tsx
Normal file
53
apps/website/components/errors/AppErrorBoundaryView.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
106
apps/website/components/errors/ErrorDetails.tsx
Normal file
106
apps/website/components/errors/ErrorDetails.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/website/components/errors/ErrorDetailsBlock.tsx
Normal file
107
apps/website/components/errors/ErrorDetailsBlock.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
48
apps/website/components/errors/ErrorRecoveryActions.tsx
Normal file
48
apps/website/components/errors/ErrorRecoveryActions.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
52
apps/website/components/errors/ErrorScreen.test.tsx
Normal file
52
apps/website/components/errors/ErrorScreen.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
80
apps/website/components/errors/ErrorScreen.tsx
Normal file
80
apps/website/components/errors/ErrorScreen.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
185
apps/website/components/errors/GlobalErrorScreen.tsx
Normal file
185
apps/website/components/errors/GlobalErrorScreen.tsx
Normal 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
Reference in New Issue
Block a user