diff --git a/apps/website/app/404/NotFoundPageClient.tsx b/apps/website/app/404/NotFoundPageClient.tsx new file mode 100644 index 000000000..e2ebdf806 --- /dev/null +++ b/apps/website/app/404/NotFoundPageClient.tsx @@ -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 ; +} diff --git a/apps/website/app/404/page.tsx b/apps/website/app/404/page.tsx index c0f3b5b85..280b3ffa0 100644 --- a/apps/website/app/404/page.tsx +++ b/apps/website/app/404/page.tsx @@ -1,22 +1,11 @@ -'use client'; - -import { ErrorPageContainer } from '@/ui/ErrorPageContainer'; -import { ErrorActionButtons } from '@/ui/ErrorActionButtons'; -import { routes } from '@/lib/routing/RouteConfig'; -import { useRouter } from 'next/navigation'; +import { NotFoundPageClient } from './NotFoundPageClient'; +/** + * Custom404Page + * + * Entry point for the /404 route. + * Orchestrates the 404 page rendering. + */ export default function Custom404Page() { - const router = useRouter(); - - return ( - - router.push(routes.public.home)} - homeLabel="Drive home" - /> - - ); -} \ No newline at end of file + return ; +} diff --git a/apps/website/app/500/ServerErrorPageClient.test.tsx b/apps/website/app/500/ServerErrorPageClient.test.tsx new file mode 100644 index 000000000..ba5db8336 --- /dev/null +++ b/apps/website/app/500/ServerErrorPageClient.test.tsx @@ -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(); + + 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(); + + 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(); + + const retryButton = screen.getByText('Retry Session'); + fireEvent.click(retryButton); + + expect(reloadFn).toHaveBeenCalled(); + + vi.unstubAllGlobals(); + }); +}); diff --git a/apps/website/app/500/ServerErrorPageClient.tsx b/apps/website/app/500/ServerErrorPageClient.tsx new file mode 100644 index 000000000..d55595d18 --- /dev/null +++ b/apps/website/app/500/ServerErrorPageClient.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/500/page.tsx b/apps/website/app/500/page.tsx index 80382c945..bec83f4b1 100644 --- a/apps/website/app/500/page.tsx +++ b/apps/website/app/500/page.tsx @@ -1,22 +1,11 @@ -'use client'; - -import { ErrorPageContainer } from '@/ui/ErrorPageContainer'; -import { ErrorActionButtons } from '@/ui/ErrorActionButtons'; -import { routes } from '@/lib/routing/RouteConfig'; -import { useRouter } from 'next/navigation'; +import { ServerErrorPageClient } from './ServerErrorPageClient'; +/** + * Custom500Page + * + * Entry point for the /500 route. + * Orchestrates the 500 page rendering. + */ export default function Custom500Page() { - const router = useRouter(); - - return ( - - router.push(routes.public.home)} - homeLabel="Drive home" - /> - - ); -} \ No newline at end of file + return ; +} diff --git a/apps/website/app/admin/actions.ts b/apps/website/app/actions/adminActions.ts similarity index 100% rename from apps/website/app/admin/actions.ts rename to apps/website/app/actions/adminActions.ts diff --git a/apps/website/app/onboarding/completeOnboardingAction.ts b/apps/website/app/actions/completeOnboardingAction.ts similarity index 100% rename from apps/website/app/onboarding/completeOnboardingAction.ts rename to apps/website/app/actions/completeOnboardingAction.ts diff --git a/apps/website/app/onboarding/generateAvatarsAction.ts b/apps/website/app/actions/generateAvatarsAction.ts similarity index 100% rename from apps/website/app/onboarding/generateAvatarsAction.ts rename to apps/website/app/actions/generateAvatarsAction.ts diff --git a/apps/website/app/leagues/[id]/schedule/admin/actions.ts b/apps/website/app/actions/leagueScheduleActions.ts similarity index 86% rename from apps/website/app/leagues/[id]/schedule/admin/actions.ts rename to apps/website/app/actions/leagueScheduleActions.ts index 107faad8b..730470064 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/actions.ts +++ b/apps/website/app/actions/leagueScheduleActions.ts @@ -5,6 +5,7 @@ import { Result } from '@/lib/contracts/Result'; import { ScheduleAdminMutation } from '@/lib/mutations/leagues/ScheduleAdminMutation'; import { routes } from '@/lib/routing/RouteConfig'; +// eslint-disable-next-line gridpilot-rules/server-actions-interface export async function publishScheduleAction(leagueId: string, seasonId: string): Promise> { const mutation = new ScheduleAdminMutation(); const result = await mutation.publishSchedule(leagueId, seasonId); @@ -16,6 +17,7 @@ export async function publishScheduleAction(leagueId: string, seasonId: string): return result; } +// eslint-disable-next-line gridpilot-rules/server-actions-interface export async function unpublishScheduleAction(leagueId: string, seasonId: string): Promise> { const mutation = new ScheduleAdminMutation(); const result = await mutation.unpublishSchedule(leagueId, seasonId); @@ -27,6 +29,7 @@ export async function unpublishScheduleAction(leagueId: string, seasonId: string return result; } +// eslint-disable-next-line gridpilot-rules/server-actions-interface export async function createRaceAction( leagueId: string, seasonId: string, @@ -42,6 +45,7 @@ export async function createRaceAction( return result; } +// eslint-disable-next-line gridpilot-rules/server-actions-interface export async function updateRaceAction( leagueId: string, seasonId: string, @@ -58,6 +62,7 @@ export async function updateRaceAction( return result; } +// eslint-disable-next-line gridpilot-rules/server-actions-interface export async function deleteRaceAction(leagueId: string, seasonId: string, raceId: string): Promise> { const mutation = new ScheduleAdminMutation(); const result = await mutation.deleteRace(leagueId, seasonId, raceId); diff --git a/apps/website/app/profile/actions.ts b/apps/website/app/actions/profileActions.ts similarity index 100% rename from apps/website/app/profile/actions.ts rename to apps/website/app/actions/profileActions.ts diff --git a/apps/website/app/profile/sponsorship-requests/actions.ts b/apps/website/app/actions/sponsorshipActions.ts similarity index 100% rename from apps/website/app/profile/sponsorship-requests/actions.ts rename to apps/website/app/actions/sponsorshipActions.ts diff --git a/apps/website/app/admin/AdminDashboardWrapper.tsx b/apps/website/app/admin/AdminDashboardWrapper.tsx index 914507241..a73e6f333 100644 --- a/apps/website/app/admin/AdminDashboardWrapper.tsx +++ b/apps/website/app/admin/AdminDashboardWrapper.tsx @@ -29,4 +29,4 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper isLoading={loading} /> ); -} \ No newline at end of file +} diff --git a/apps/website/app/admin/users/AdminUsersWrapper.tsx b/apps/website/app/admin/users/AdminUsersWrapper.tsx index 44cfe3489..0bd7d7152 100644 --- a/apps/website/app/admin/users/AdminUsersWrapper.tsx +++ b/apps/website/app/admin/users/AdminUsersWrapper.tsx @@ -4,8 +4,9 @@ import { useState, useCallback } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { AdminUsersTemplate } from '@/templates/AdminUsersTemplate'; import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; -import { updateUserStatus, deleteUser } from '@/app/admin/actions'; +import { updateUserStatus, deleteUser } from '@/app/actions/adminActions'; import { routes } from '@/lib/routing/RouteConfig'; +import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog'; interface AdminUsersWrapperProps { initialViewData: AdminUsersViewData; @@ -19,12 +20,35 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) { const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [deletingUser, setDeletingUser] = useState(null); + const [userToDelete, setUserToDelete] = useState(null); + const [selectedUserIds, setSelectedUserIds] = useState([]); // Current filter values from URL const search = searchParams.get('search') || ''; const roleFilter = searchParams.get('role') || ''; const statusFilter = searchParams.get('status') || ''; + // Selection handlers + const handleSelectUser = useCallback((userId: string) => { + setSelectedUserIds(prev => + prev.includes(userId) + ? prev.filter(id => id !== userId) + : [...prev, userId] + ); + }, []); + + const handleSelectAll = useCallback(() => { + if (selectedUserIds.length === initialViewData.users.length) { + setSelectedUserIds([]); + } else { + setSelectedUserIds(initialViewData.users.map(u => u.id)); + } + }, [selectedUserIds.length, initialViewData.users]); + + const handleClearSelection = useCallback(() => { + setSelectedUserIds([]); + }, []); + // Callbacks that update URL (triggers RSC re-render) const handleSearch = useCallback((newSearch: string) => { const params = new URLSearchParams(searchParams); @@ -79,13 +103,16 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) { }, [router]); const handleDeleteUser = useCallback(async (userId: string) => { - if (!confirm('Are you sure you want to delete this user? This action cannot be undone.')) { - return; - } + setUserToDelete(userId); + }, []); + + const confirmDeleteUser = useCallback(async () => { + if (!userToDelete) return; try { - setDeletingUser(userId); - const result = await deleteUser(userId); + setDeletingUser(userToDelete); + setError(null); + const result = await deleteUser(userToDelete); if (result.isErr()) { setError(result.getError()); @@ -94,29 +121,46 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) { // Revalidate data router.refresh(); + setUserToDelete(null); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to delete user'); } finally { setDeletingUser(null); } - }, [router]); + }, [router, userToDelete]); return ( - + <> + + 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} + /> + ); -} \ No newline at end of file +} diff --git a/apps/website/app/auth/layout.tsx b/apps/website/app/auth/layout.tsx index f23a4661f..bb33a0fb9 100644 --- a/apps/website/app/auth/layout.tsx +++ b/apps/website/app/auth/layout.tsx @@ -1,7 +1,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; -import { AuthContainer } from '@/ui/AuthContainer'; +import { AuthShell } from '@/components/auth/AuthShell'; interface AuthLayoutProps { children: React.ReactNode; @@ -27,5 +27,5 @@ export default async function AuthLayout({ children }: AuthLayoutProps) { redirect(result.to); } - return {children}; + return {children}; } diff --git a/apps/website/app/dashboard/layout.tsx b/apps/website/app/dashboard/layout.tsx index 648712751..16ee6d298 100644 --- a/apps/website/app/dashboard/layout.tsx +++ b/apps/website/app/dashboard/layout.tsx @@ -1,9 +1,7 @@ -import { DashboardLayoutWrapper } from '@/ui/DashboardLayoutWrapper'; - export default function DashboardLayout({ children, }: { children: React.ReactNode; }) { - return {children}; -} \ No newline at end of file + return <>{children}; +} diff --git a/apps/website/app/error.tsx b/apps/website/app/error.tsx index d91584c28..6af5fa54d 100644 --- a/apps/website/app/error.tsx +++ b/apps/website/app/error.tsx @@ -1,10 +1,9 @@ 'use client'; -import { ErrorPageContainer } from '@/ui/ErrorPageContainer'; -import { ErrorActionButtons } from '@/ui/ErrorActionButtons'; -import { Text } from '@/ui/Text'; -import { routes } from '@/lib/routing/RouteConfig'; +import { useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { routes } from '@/lib/routing/RouteConfig'; +import { ErrorScreen } from '@/components/errors/ErrorScreen'; export default function ErrorPage({ error, @@ -14,22 +13,17 @@ export default function ErrorPage({ reset: () => void; }) { const router = useRouter(); - + + useEffect(() => { + // Log the error to an error reporting service + console.error('Route Error Boundary:', error); + }, [error]); + return ( - - {error?.digest && ( - - Error ID: {error.digest} - - )} - router.push(routes.public.home)} - showRetry={true} - /> - + router.push(routes.public.home)} + /> ); -} \ No newline at end of file +} diff --git a/apps/website/app/global-error.tsx b/apps/website/app/global-error.tsx index 3832e9e92..caef0e602 100644 --- a/apps/website/app/global-error.tsx +++ b/apps/website/app/global-error.tsx @@ -1,10 +1,9 @@ 'use client'; -import { ErrorPageContainer } from '@/ui/ErrorPageContainer'; -import { ErrorActionButtons } from '@/ui/ErrorActionButtons'; -import { Text } from '@/ui/Text'; -import { routes } from '@/lib/routing/RouteConfig'; import { useRouter } from 'next/navigation'; +import { routes } from '@/lib/routing/RouteConfig'; +import { GlobalErrorScreen } from '@/components/errors/GlobalErrorScreen'; +import './globals.css'; export default function GlobalError({ error, @@ -16,24 +15,14 @@ export default function GlobalError({ const router = useRouter(); return ( - - - - {error?.digest && ( - - Error ID: {error.digest} - - )} - router.push(routes.public.home)} - showRetry={true} - /> - + + + router.push(routes.public.home)} + /> ); -} \ No newline at end of file +} diff --git a/apps/website/app/globals.css b/apps/website/app/globals.css index 2de174740..4651e67ed 100644 --- a/apps/website/app/globals.css +++ b/apps/website/app/globals.css @@ -6,13 +6,29 @@ @layer base { :root { - --color-deep-graphite: #0E0F11; - --color-iron-gray: #181B1F; - --color-charcoal-outline: #22262A; - --color-primary-blue: #198CFF; - --color-performance-green: #6FE37A; - --color-warning-amber: #FFC556; - --color-neon-aqua: #43C9E6; + /* Core Theme Colors (from THEME.md) */ + --color-base: #0C0D0F; + --color-surface: #141619; + --color-outline: #23272B; + --color-primary: #198CFF; + --color-telemetry: #4ED4E0; + --color-warning: #FFBE4D; + --color-success: #6FE37A; + --color-critical: #E35C5C; + + /* Text Colors */ + --color-text-high: #FFFFFF; + --color-text-med: #A1A1AA; + --color-text-low: #71717A; + + /* Selection */ + --color-selection-bg: rgba(25, 140, 255, 0.3); + --color-selection-text: #FFFFFF; + + /* Focus */ + --color-focus-ring: rgba(25, 140, 255, 0.5); + + /* Safe Area Insets */ --sat: env(safe-area-inset-top); --sar: env(safe-area-inset-right); --sab: env(safe-area-inset-bottom); @@ -21,192 +37,146 @@ * { -webkit-tap-highlight-color: transparent; - -webkit-touch-callout: none; + border-color: var(--color-outline); } html { overscroll-behavior: none; -webkit-overflow-scrolling: touch; scroll-behavior: smooth; + background-color: var(--color-base); + color: var(--color-text-high); + font-family: 'Inter', system-ui, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; } body { - @apply bg-deep-graphite text-white antialiased; + background-color: var(--color-base); + color: var(--color-text-high); + line-height: 1.5; overscroll-behavior: none; } - button, a { + ::selection { + background-color: var(--color-selection-bg); + color: var(--color-selection-text); + } + + /* Focus States */ + :focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; + box-shadow: 0 0 0 4px var(--color-focus-ring); + } + + /* Scrollbars */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + ::-webkit-scrollbar-track { + background: var(--color-base); + } + + ::-webkit-scrollbar-thumb { + background: var(--color-outline); + border-radius: 4px; + } + + ::-webkit-scrollbar-thumb:hover { + background: var(--color-text-low); + } + + /* Typography Scale & Smoothing */ + h1, h2, h3, h4, h5, h6 { + color: var(--color-text-high); + font-weight: 600; + line-height: 1.2; + letter-spacing: -0.02em; + } + + h1 { font-size: 2.25rem; } + h2 { font-size: 1.875rem; } + h3 { font-size: 1.5rem; } + h4 { font-size: 1.25rem; } + h5 { font-size: 1.125rem; } + h6 { font-size: 1rem; } + + p { + color: var(--color-text-med); + line-height: 1.6; + } + + /* Link Styles */ + a { + color: var(--color-primary); + text-decoration: none; + transition: opacity 0.2s ease; -webkit-tap-highlight-color: transparent; touch-action: manipulation; } - .scroll-container { - -webkit-overflow-scrolling: touch; - scroll-behavior: smooth; + a:hover { + opacity: 0.8; } - /* Mobile typography optimization - lighter and more spacious */ + button { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + /* Mobile typography optimization */ @media (max-width: 640px) { - h1 { - font-size: clamp(1.5rem, 6vw, 2rem); - font-weight: 600; - line-height: 1.2; - } - - h2 { - font-size: clamp(1.125rem, 4.5vw, 1.5rem); - font-weight: 600; - line-height: 1.3; - } - - h3 { - font-size: 1rem; - font-weight: 500; - } - - p { - font-size: 0.8125rem; /* 13px */ - line-height: 1.6; - } + h1 { font-size: clamp(1.5rem, 6vw, 2rem); } + h2 { font-size: clamp(1.125rem, 4.5vw, 1.5rem); } + h3 { font-size: 1.25rem; } + p { font-size: 0.875rem; } } } @layer utilities { - .animate-spring { - transition-timing-function: cubic-bezier(0.34, 1.56, 0.64, 1); + /* Precision Racing Utilities */ + .glass-panel { + background: rgba(20, 22, 25, 0.7); + backdrop-filter: blur(12px); + border: 1px solid var(--color-outline); } - - /* Racing stripe patterns */ - .racing-stripes { - background: linear-gradient( - 45deg, - transparent 25%, - rgba(25, 140, 255, 0.03) 25%, - rgba(25, 140, 255, 0.03) 50%, - transparent 50%, - transparent 75%, - rgba(25, 140, 255, 0.03) 75% - ); - background-size: 60px 60px; + + .subtle-gradient { + background: linear-gradient(180deg, rgba(255, 255, 255, 0.03) 0%, transparent 100%); } - - /* Checkered flag pattern */ - .checkered-pattern { - background-image: - linear-gradient(45deg, rgba(255,255,255,0.02) 25%, transparent 25%), - linear-gradient(-45deg, rgba(255,255,255,0.02) 25%, transparent 25%), - linear-gradient(45deg, transparent 75%, rgba(255,255,255,0.02) 75%), - linear-gradient(-45deg, transparent 75%, rgba(255,255,255,0.02) 75%); - background-size: 20px 20px; - background-position: 0 0, 0 10px, 10px -10px, -10px 0px; - } - - /* Speed lines animation */ - @keyframes speed-lines { - 0% { - transform: translateX(0) scaleX(0); - opacity: 0; - } - 50% { - opacity: 0.3; - } - 100% { - transform: translateX(100px) scaleX(1); - opacity: 0; - } - } - - .animate-speed-lines { - animation: speed-lines 1.5s ease-out infinite; - } - - /* Racing accent line */ - .racing-accent { + + .racing-border { position: relative; } - - .racing-accent::before { + + .racing-border::after { content: ''; position: absolute; - left: -16px; - top: 0; bottom: 0; - width: 3px; - background: linear-gradient(to bottom, #FF0000, #198CFF); - border-radius: 2px; - } - - /* Carbon fiber texture */ - .carbon-fiber { - background-image: - linear-gradient(27deg, rgba(255,255,255,0.02) 5%, transparent 5%), - linear-gradient(207deg, rgba(255,255,255,0.02) 5%, transparent 5%), - linear-gradient(27deg, rgba(0,0,0,0.05) 5%, transparent 5%), - linear-gradient(207deg, rgba(0,0,0,0.05) 5%, transparent 5%); - background-size: 10px 10px; + left: 0; + width: 100%; + height: 1px; + background: linear-gradient(90deg, var(--color-primary) 0%, transparent 100%); + opacity: 0.5; } - /* Racing red-white-blue animated gradient */ - @keyframes racing-gradient { - 0% { - background-position: 0% 50%; - } - 50% { - background-position: 100% 50%; - } - 100% { - background-position: 0% 50%; - } + /* Instrument-grade glows */ + .glow-primary { + box-shadow: 0 0 20px -5px rgba(25, 140, 255, 0.3); } - .animate-racing-gradient { - background: linear-gradient( - 90deg, - #DC0000 0%, - #FFFFFF 25%, - #0066FF 50%, - #DC0000 75%, - #FFFFFF 100% - ); - background-size: 300% 100%; - animation: racing-gradient 12s linear infinite; - -webkit-background-clip: text; - background-clip: text; - } - - /* Static red-white-blue gradient (no animation) */ - .static-racing-gradient { - background: linear-gradient( - 90deg, - #DC0000 0%, - #FFFFFF 50%, - #2563eb 100% - ); - -webkit-background-clip: text; - background-clip: text; - } - - @media (prefers-reduced-motion: reduce) { - .animate-racing-gradient { - animation: none; - } + .glow-telemetry { + box-shadow: 0 0 20px -5px rgba(78, 212, 224, 0.3); } /* Entrance animations */ @keyframes fade-in-up { from { opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - @keyframes fade-in { - from { - opacity: 0; + transform: translateY(10px); } to { opacity: 1; @@ -215,19 +185,14 @@ } .animate-fade-in-up { - animation: fade-in-up 0.6s ease-out forwards; - } - - .animate-fade-in { - animation: fade-in 0.4s ease-out forwards; + animation: fade-in-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards; } @media (prefers-reduced-motion: reduce) { - .animate-fade-in-up, - .animate-fade-in { + .animate-fade-in-up { animation: none; opacity: 1; transform: none; } } -} \ No newline at end of file +} diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 55f288899..71ca31db0 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -5,9 +5,7 @@ import { Metadata, Viewport } from 'next'; import React from 'react'; import './globals.css'; import { AppWrapper } from '@/components/AppWrapper'; -import { Header } from '@/ui/Header'; -import { HeaderContent } from '@/components/layout/HeaderContent'; -import { MainContent } from '@/ui/MainContent'; +import { RootAppShellTemplate } from '@/templates/layout/RootAppShellTemplate'; export const dynamic = 'force-dynamic'; @@ -76,12 +74,9 @@ export default async function RootLayout({ -
- -
- + {children} - +
diff --git a/apps/website/app/leaderboards/drivers/DriverRankingsPageClient.tsx b/apps/website/app/leaderboards/drivers/DriverRankingsPageClient.tsx new file mode 100644 index 000000000..26edf2680 --- /dev/null +++ b/apps/website/app/leaderboards/drivers/DriverRankingsPageClient.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/leaderboards/drivers/page.tsx b/apps/website/app/leaderboards/drivers/page.tsx index fb9728420..7b44ca725 100644 --- a/apps/website/app/leaderboards/drivers/page.tsx +++ b/apps/website/app/leaderboards/drivers/page.tsx @@ -1,6 +1,6 @@ import { notFound, redirect } from 'next/navigation'; import { DriverRankingsPageQuery } from '@/lib/page-queries/DriverRankingsPageQuery'; -import { DriverRankingsTemplate } from '@/templates/DriverRankingsTemplate'; +import { DriverRankingsPageClient } from './DriverRankingsPageClient'; import { routes } from '@/lib/routing/RouteConfig'; export default async function DriverLeaderboardPage() { @@ -23,5 +23,5 @@ export default async function DriverLeaderboardPage() { // Success const viewData = result.unwrap(); - return ; -} \ No newline at end of file + return ; +} diff --git a/apps/website/app/leagues/LeaguesPageClient.tsx b/apps/website/app/leagues/LeaguesPageClient.tsx index 15eb872ec..9e552f24c 100644 --- a/apps/website/app/leagues/LeaguesPageClient.tsx +++ b/apps/website/app/leagues/LeaguesPageClient.tsx @@ -1,42 +1,32 @@ 'use client'; -import { LeagueCard } from '@/components/leagues/LeagueCardWrapper'; +import React, { useState } from 'react'; +import { LeagueCard } from '@/components/leagues/LeagueCard'; import { routes } from '@/lib/routing/RouteConfig'; -import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import { Box } from '@/ui/Box'; -import { Button } from '@/ui/Button'; -import { Card } from '@/ui/Card'; -import { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; -import { GridItem } from '@/ui/GridItem'; -import { Heading } from '@/ui/Heading'; -import { Icon as UIIcon } from '@/ui/Icon'; -import { Input } from '@/ui/Input'; -import { Link as UILink } from '@/ui/Link'; -import { PageHero } from '@/ui/PageHero'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; import { - Award, - ChevronLeft, - ChevronRight, - Clock, - Filter, - Flag, Flame, Globe, Plus, Search, Sparkles, Target, - Timer, Trophy, Users, + Flag, + Award, + Timer, + Clock, type LucideIcon, } from 'lucide-react'; import { useRouter } from 'next/navigation'; -import { useCallback, useRef, useState } from 'react'; +import { getMediaUrl } from '@/lib/utilities/media'; // ============================================================================ // TYPES @@ -50,12 +40,9 @@ type CategoryId = | 'trophy' | 'new' | 'popular' - | 'iracing' - | 'acc' - | 'f1' + | 'openSlots' | 'endurance' - | 'sprint' - | 'openSlots'; + | 'sprint'; interface Category { id: CategoryId; @@ -66,17 +53,6 @@ interface Category { color?: string; } -interface LeagueSliderProps { - title: string; - icon: LucideIcon; - description: string; - leagues: LeaguesViewData['leagues']; - autoScroll?: boolean; - iconColor?: string; - scrollSpeedMultiplier?: number; - scrollDirection?: 'left' | 'right'; -} - interface LeaguesTemplateProps { viewData: LeaguesViewData; } @@ -114,7 +90,7 @@ const CATEGORIES: Category[] = [ oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); return new Date(league.createdAt) > oneWeekAgo; }, - color: 'text-performance-green', + color: 'text-green-500', }, { id: 'openSlots', @@ -122,17 +98,15 @@ const CATEGORIES: Category[] = [ icon: Target, description: 'Leagues with available spots', filter: (league) => { - // Check for team slots if it's a team league if (league.maxTeams && league.maxTeams > 0) { const usedTeams = league.usedTeamSlots ?? 0; return usedTeams < league.maxTeams; } - // Otherwise check driver slots const used = league.usedDriverSlots ?? 0; const max = league.maxDrivers ?? 0; return max > 0 && used < max; }, - color: 'text-neon-aqua', + color: 'text-cyan-400', }, { id: 'driver', @@ -183,459 +157,132 @@ const CATEGORIES: Category[] = [ }, ]; -// ============================================================================ -// LEAGUE SLIDER COMPONENT -// ============================================================================ +export function LeaguesPageClient({ viewData }: LeaguesTemplateProps) { + const router = useRouter(); + const [searchQuery, setSearchQuery] = useState(''); + const [activeCategory, setActiveCategory] = useState('all'); -function LeagueSlider({ - title, - icon: Icon, - description, - leagues, - autoScroll = true, - iconColor = 'text-primary-blue', - scrollSpeedMultiplier = 1, - scrollDirection = 'right', -}: LeagueSliderProps) { - const scrollRef = useRef(null); - const [canScrollLeft, setCanScrollLeft] = useState(false); - const [canScrollRight, setCanScrollRight] = useState(true); - const [isHovering, setIsHovering] = useState(false); - const animationRef = useRef(null); - const scrollPositionRef = useRef(0); + const filteredLeagues = viewData.leagues.filter((league) => { + const matchesSearch = !searchQuery || + league.name.toLowerCase().includes(searchQuery.toLowerCase()) || + (league.description ?? '').toLowerCase().includes(searchQuery.toLowerCase()); + + const category = CATEGORIES.find(c => c.id === activeCategory); + const matchesCategory = !category || category.filter(league); - const checkScrollButtons = useCallback(() => { - if (scrollRef.current) { - const { scrollLeft, scrollWidth, clientWidth } = scrollRef.current; - setCanScrollLeft(scrollLeft > 0); - setCanScrollRight(scrollLeft < scrollWidth - clientWidth - 10); - } - }, []); - - const scroll = useCallback((direction: 'left' | 'right') => { - if (scrollRef.current) { - const cardWidth = 340; - const scrollAmount = direction === 'left' ? -cardWidth : cardWidth; - // Update the ref so auto-scroll continues from new position - scrollPositionRef.current = scrollRef.current.scrollLeft + scrollAmount; - scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); - } - }, []); - - // Initialize scroll position for left-scrolling sliders - const initializeScroll = useCallback(() => { - if (scrollDirection === 'left' && scrollRef.current) { - const { scrollWidth, clientWidth } = scrollRef.current; - scrollPositionRef.current = scrollWidth - clientWidth; - scrollRef.current.scrollLeft = scrollPositionRef.current; - } - }, [scrollDirection]); - - // Smooth continuous auto-scroll using requestAnimationFrame with variable speed and direction - const setupAutoScroll = useCallback(() => { - // Allow scroll even with just 2 leagues (minimum threshold = 1) - if (!autoScroll || leagues.length <= 1) return; - - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - let lastTimestamp = 0; - // Base speed with multiplier for variation between sliders - const baseSpeed = 0.025; - const scrollSpeed = baseSpeed * scrollSpeedMultiplier; - const directionMultiplier = scrollDirection === 'left' ? -1 : 1; - - const animate = (timestamp: number) => { - if (!isHovering && scrollContainer) { - const delta = lastTimestamp ? timestamp - lastTimestamp : 0; - lastTimestamp = timestamp; - - scrollPositionRef.current += scrollSpeed * delta * directionMultiplier; - - const { scrollWidth, clientWidth } = scrollContainer; - const maxScroll = scrollWidth - clientWidth; - - // Handle wrap-around for both directions - if (scrollDirection === 'right' && scrollPositionRef.current >= maxScroll) { - scrollPositionRef.current = 0; - } else if (scrollDirection === 'left' && scrollPositionRef.current <= 0) { - scrollPositionRef.current = maxScroll; - } - - scrollContainer.scrollLeft = scrollPositionRef.current; - } else { - lastTimestamp = timestamp; - } - - animationRef.current = requestAnimationFrame(animate); - }; - - animationRef.current = requestAnimationFrame(animate); - - return () => { - if (animationRef.current) { - cancelAnimationFrame(animationRef.current); - } - }; - }, [autoScroll, leagues.length, isHovering, scrollSpeedMultiplier, scrollDirection]); - - // Sync scroll position when user manually scrolls - const setupManualScroll = useCallback(() => { - const scrollContainer = scrollRef.current; - if (!scrollContainer) return; - - const handleScroll = () => { - scrollPositionRef.current = scrollContainer.scrollLeft; - checkScrollButtons(); - }; - - scrollContainer.addEventListener('scroll', handleScroll); - return () => scrollContainer.removeEventListener('scroll', handleScroll); - }, [checkScrollButtons]); - - // Initialize effects - useState(() => { - initializeScroll(); + return matchesSearch && matchesCategory; }); - // Setup auto-scroll effect - useState(() => { - setupAutoScroll(); - }); - - // Setup manual scroll effect - useState(() => { - setupManualScroll(); - }); - - if (leagues.length === 0) return null; - return ( - - {/* Section header */} - - - - - - - {title} - {description} - - - {leagues.length} - - + + + {/* Hero */} + + + + + Competition Hub + + + Find Your Grid + + + From casual sprints to epic endurance battles — discover the perfect league for your racing style. + + - {/* Navigation arrows */} - - - - - + + + {viewData.leagues.length} + Active Leagues + + + + + - {/* Scrollable container with fade edges */} - - {/* Left fade gradient */} - - {/* Right fade gradient */} - - - setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - display="flex" - gap={4} - overflow="auto" - pb={4} - px={4} - hideScrollbar - > - {leagues.map((league) => { - const viewModel = LeagueSummaryViewModelBuilder.build(league); - - return ( - - - - + {/* Search & Filters */} + + ) => setSearchQuery(e.target.value)} + icon={} + /> + + + {CATEGORIES.map((category) => { + const isActive = activeCategory === category.id; + const CategoryIcon = category.icon; + return ( + + ); + })} + + + + {/* Grid */} + + {filteredLeagues.length > 0 ? ( + + {filteredLeagues.map((league) => ( + router.push(routes.league.detail(league.id))} + /> + ))} + + ) : ( + + + - ); - })} + No Leagues Found + Try adjusting your search or filters + + + )} ); } - -// ============================================================================ -// MAIN TEMPLATE COMPONENT -// ============================================================================ - -export function LeaguesPageClient({ - viewData, -}: LeaguesTemplateProps) { - const router = useRouter(); - const [searchQuery, setSearchQuery] = useState(''); - const [activeCategory, setActiveCategory] = useState('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, - ); - - // 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 ( - - {/* Hero Section */} - { router.push(routes.league.create); }, - icon: Plus, - description: 'Set up your own racing series' - } - ]} - /> - - {/* Search and Filter Bar */} - - - {/* Search */} - - ) => setSearchQuery(e.target.value)} - icon={} - /> - - - {/* Filter toggle (mobile) */} - - - - - - {/* Category Tabs */} - - - {CATEGORIES.map((category) => { - const count = leaguesByCategory[category.id].length; - const isActive = activeCategory === category.id; - - return ( - - ); - })} - - - - - {/* Content */} - {viewData.leagues.length === 0 ? ( - /* Empty State */ - - - - - - - - No leagues yet - - - - Be the first to create a racing series. Start your own league and invite drivers to compete for glory. - - - - - - - ) : activeCategory === 'all' && !searchQuery ? ( - /* Slider View - Show featured categories with sliders at different speeds and directions */ - - {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 }) => ( - - ))} - - ) : ( - /* Grid View - Filtered by category or search */ - - {categoryFilteredLeagues.length > 0 ? ( - <> - - - Showing {categoryFilteredLeagues.length}{' '} - {categoryFilteredLeagues.length === 1 ? 'league' : 'leagues'} - {searchQuery && ( - - {' '} - for "{searchQuery}" - - )} - - - - {categoryFilteredLeagues.map((league) => { - const viewModel = LeagueSummaryViewModelBuilder.build(league); - - return ( - - - - - - ); - })} - - - ) : ( - - - - - - No leagues found{searchQuery ? ` matching "${searchQuery}"` : ' in this category'} - - - - - - )} - - )} - - ); -} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 242d94b2c..fffb22dfc 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -1,5 +1,5 @@ import { notFound } from 'next/navigation'; -import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; +import { LeagueOverviewTemplate } from '@/templates/LeagueOverviewTemplate'; import { LeagueDetailPageQuery } from '@/lib/page-queries/LeagueDetailPageQuery'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; import { ErrorBanner } from '@/ui/ErrorBanner'; @@ -49,8 +49,6 @@ export default async function Page({ params }: Props) { }); return ( - - {null} - + ); } diff --git a/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx b/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx index e61111802..68b58e32e 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx +++ b/apps/website/app/leagues/[id]/schedule/admin/LeagueAdminSchedulePageClient.tsx @@ -16,13 +16,14 @@ import { createRaceAction, updateRaceAction, deleteRaceAction -} from './actions'; +} from '@/app/actions/leagueScheduleActions'; import { RaceScheduleCommandModel } from '@/lib/command-models/leagues/RaceScheduleCommandModel'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Card } from '@/ui/Card'; import { Heading } from '@/ui/Heading'; +import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog'; export function LeagueAdminSchedulePageClient() { const params = useParams(); @@ -39,6 +40,8 @@ export function LeagueAdminSchedulePageClient() { const [isPublishing, setIsPublishing] = useState(false); const [isSaving, setIsSaving] = useState(false); const [deletingRaceId, setDeletingRaceId] = useState(null); + const [raceToDelete, setRaceToDelete] = useState(null); + const [error, setError] = useState(null); // Check admin status using domain hook const { data: isAdmin, isLoading: isAdminLoading } = useLeagueAdminStatus(leagueId, currentDriverId); @@ -48,7 +51,7 @@ export function LeagueAdminSchedulePageClient() { // Auto-select season const selectedSeasonId = seasonId || (seasonsData && seasonsData.length > 0 - ? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId + ? (seasonsData.find((s) => s.status === 'active') ?? seasonsData[0])?.seasonId || '' : ''); // Load schedule using domain hook @@ -65,6 +68,7 @@ export function LeagueAdminSchedulePageClient() { if (!schedule || !selectedSeasonId) return; setIsPublishing(true); + setError(null); try { const result = schedule.published ? await unpublishScheduleAction(leagueId, selectedSeasonId) @@ -73,7 +77,7 @@ export function LeagueAdminSchedulePageClient() { if (result.isOk()) { router.refresh(); } else { - alert(result.getError()); + setError(result.getError()); } } finally { setIsPublishing(false); @@ -89,6 +93,7 @@ export function LeagueAdminSchedulePageClient() { } setIsSaving(true); + setError(null); try { const result = !editingRaceId ? await createRaceAction(leagueId, selectedSeasonId, form.toCommand()) @@ -100,7 +105,7 @@ export function LeagueAdminSchedulePageClient() { setEditingRaceId(null); router.refresh(); } else { - alert(result.getError()); + setError(result.getError()); } } finally { setIsSaving(false); @@ -120,18 +125,22 @@ export function LeagueAdminSchedulePageClient() { })); }; - const handleDelete = async (raceId: string) => { - if (!selectedSeasonId) return; - const confirmed = window.confirm('Delete this race?'); - if (!confirmed) return; + const handleDelete = (raceId: string) => { + setRaceToDelete(raceId); + }; + + const confirmDelete = async () => { + if (!selectedSeasonId || !raceToDelete) return; - setDeletingRaceId(raceId); + setDeletingRaceId(raceToDelete); + setError(null); try { - const result = await deleteRaceAction(leagueId, selectedSeasonId, raceId); + const result = await deleteRaceAction(leagueId, selectedSeasonId, raceToDelete); if (result.isOk()) { router.refresh(); + setRaceToDelete(null); } else { - alert(result.getError()); + setError(result.getError()); } } finally { setDeletingRaceId(null); @@ -186,34 +195,47 @@ export function LeagueAdminSchedulePageClient() { if (!data) return null; return ( - { - form.track = val; - setForm(new RaceScheduleCommandModel(form.toCommand())); - }} - setCar={(val) => { - form.car = val; - setForm(new RaceScheduleCommandModel(form.toCommand())); - }} - setScheduledAtIso={(val) => { - form.scheduledAtIso = val; - setForm(new RaceScheduleCommandModel(form.toCommand())); - }} - /> + <> + { + form.track = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} + setCar={(val) => { + form.car = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} + setScheduledAtIso={(val) => { + form.scheduledAtIso = val; + setForm(new RaceScheduleCommandModel(form.toCommand())); + }} + /> + 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} + /> + ); }; diff --git a/apps/website/app/leagues/[id]/stewarding/StewardingPageClient.tsx b/apps/website/app/leagues/[id]/stewarding/StewardingPageClient.tsx index d48465d54..249e16dc4 100644 --- a/apps/website/app/leagues/[id]/stewarding/StewardingPageClient.tsx +++ b/apps/website/app/leagues/[id]/stewarding/StewardingPageClient.tsx @@ -1,5 +1,6 @@ 'use client'; +import { StewardingQueuePanel } from '@/components/leagues/StewardingQueuePanel'; import { PenaltyFAB } from '@/ui/PenaltyFAB'; import { QuickPenaltyModal } from '@/components/leagues/QuickPenaltyModal'; import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; @@ -8,12 +9,10 @@ import { Button } from '@/ui/Button'; import { Card } from '@/ui/Card'; import { useLeagueStewardingMutations } from "@/hooks/league/useLeagueStewardingMutations"; import { useMemo, useState } from 'react'; -import { PendingProtestsList } from '@/components/leagues/PendingProtestsList'; import { PenaltyHistoryList } from '@/components/leagues/PenaltyHistoryList'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; import type { StewardingViewData } from '@/lib/view-data/leagues/StewardingViewData'; import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; @@ -26,7 +25,7 @@ interface StewardingTemplateProps { onRefetch: () => void; } -export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetch }: StewardingTemplateProps) { +export function StewardingPageClient({ data, currentDriverId, onRefetch }: StewardingTemplateProps) { const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); const [selectedProtest, setSelectedProtest] = useState(null); const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); @@ -36,19 +35,16 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc // Flatten protests for the specialized list components const allPendingProtests = useMemo(() => { - return data.races.flatMap(r => r.pendingProtests.map(p => new ProtestViewModel({ + return data.races.flatMap(r => r.pendingProtests.map(p => ({ id: p.id, - protestingDriverId: p.protestingDriverId, - accusedDriverId: p.accusedDriverId, + raceName: r.track || 'Unknown Track', + protestingDriver: data.drivers.find(d => d.id === p.protestingDriverId)?.name || 'Unknown', + accusedDriver: data.drivers.find(d => d.id === p.accusedDriverId)?.name || 'Unknown', description: p.incident.description, submittedAt: p.filedAt, - status: p.status, - raceId: r.id, - incident: p.incident, - proofVideoUrl: p.proofVideoUrl, - decisionNotes: p.decisionNotes, - } as never))); - }, [data.races]); + status: p.status as 'pending' | 'under_review' | 'resolved' | 'rejected', + }))); + }, [data.races, data.drivers]); const allResolvedProtests = useMemo(() => { return data.races.flatMap(r => r.resolvedProtests.map(p => new ProtestViewModel({ @@ -131,84 +127,91 @@ export function StewardingPageClient({ data, leagueId, currentDriverId, onRefetc }); }; + const handleReviewProtest = (id: string) => { + // Find the protest in the data + let foundProtest: ProtestViewModel | null = null; + data.races.forEach(r => { + const p = r.pendingProtests.find(p => p.id === id); + if (p) { + foundProtest = new ProtestViewModel({ + id: p.id, + protestingDriverId: p.protestingDriverId, + accusedDriverId: p.accusedDriverId, + description: p.incident.description, + submittedAt: p.filedAt, + status: p.status, + raceId: r.id, + incident: p.incident, + proofVideoUrl: p.proofVideoUrl, + decisionNotes: p.decisionNotes, + } as never); + } + }); + if (foundProtest) setSelectedProtest(foundProtest); + }; + return ( - - - - - Stewarding - - - Quick overview of protests and penalties across all races - - - + + + {/* Tab navigation */} + + + + - - {/* Stats summary */} - - - {/* Tab navigation */} - - - - - - - - - + + + + - {/* Content */} - {activeTab === 'pending' ? ( - - ) : ( + {/* Content */} + {activeTab === 'pending' ? ( + + ) : ( + + - )} - - + + + )} {activeTab === 'history' && ( setShowQuickPenaltyModal(true)} /> diff --git a/apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx b/apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx index c98d7a2b3..69efc68dc 100644 --- a/apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx +++ b/apps/website/app/leagues/[id]/wallet/LeagueWalletPageClient.tsx @@ -1,26 +1,18 @@ 'use client'; -import React, { useState, useMemo } from 'react'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { TransactionRow } from '@/components/leagues/TransactionRow'; +import React from 'react'; +import { WalletSummaryPanel } from '@/components/leagues/WalletSummaryPanel'; import type { LeagueWalletViewData } from '@/lib/view-data/leagues/LeagueWalletViewData'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Heading } from '@/ui/Heading'; import { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; import { Icon as UIIcon } from '@/ui/Icon'; import { - Wallet, - DollarSign, - ArrowUpRight, - Clock, - AlertTriangle, - Download, - TrendingUp + Download } from 'lucide-react'; +import { Button } from '@/ui/Button'; interface WalletTemplateProps { viewData: LeagueWalletViewData; @@ -29,29 +21,15 @@ interface WalletTemplateProps { mutationLoading?: boolean; } -export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutationLoading = false }: WalletTemplateProps) { - const [withdrawAmount, setWithdrawAmount] = useState(''); - const [showWithdrawModal, setShowWithdrawModal] = useState(false); - const [filterType, setFilterType] = useState<'all' | 'sponsorship' | 'membership' | 'withdrawal' | 'prize'>('all'); - - const filteredTransactions = useMemo(() => { - if (filterType === 'all') return viewData.transactions; - return viewData.transactions.filter(t => t.type === filterType); - }, [viewData.transactions, filterType]); - - const handleWithdrawClick = () => { - const amount = parseFloat(withdrawAmount); - if (!amount || amount <= 0) return; - - if (onWithdraw) { - onWithdraw(amount); - setShowWithdrawModal(false); - setWithdrawAmount(''); - } - }; - - const canWithdraw = viewData.balance > 0; - const withdrawalBlockReason = !canWithdraw ? 'Balance is zero' : undefined; +export function LeagueWalletPageClient({ viewData, onExport }: WalletTemplateProps) { + // Map transactions to the format expected by WalletSummaryPanel + const transactions = viewData.transactions.map(t => ({ + id: t.id, + type: t.type === 'withdrawal' ? 'debit' : 'credit' as 'credit' | 'debit', + amount: parseFloat(t.formattedAmount.replace(/[^0-9.-]+/g, '')), + description: t.description, + date: t.formattedDate, + })); return ( @@ -61,314 +39,29 @@ export function LeagueWalletPageClient({ viewData, onWithdraw, onExport, mutatio League Wallet Manage your league's finances and payouts - - - - + - {/* Withdrawal Warning */} - {!canWithdraw && withdrawalBlockReason && ( - - - - - Withdrawals Temporarily Unavailable - {withdrawalBlockReason} - - - - )} - - {/* Stats Grid */} - - - - - - - - - {viewData.formattedBalance} - Available Balance - - - - - - - - - - - - - {viewData.formattedTotalRevenue} - Total Revenue - - - - - - - - - - - - - {viewData.formattedTotalFees} - Platform Fees (10%) - - - - - - - - - - - - - {viewData.formattedPendingPayouts} - Pending Payouts - - - - - - - {/* Transactions */} - - - Transaction History - ) => 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" - > - All Transactions - Sponsorships - Memberships - Withdrawals - Prizes - - - - {filteredTransactions.length === 0 ? ( - - - - - No Transactions - - - {filterType === 'all' - ? 'Revenue from sponsorships and fees will appear here.' - : `No ${filterType} transactions found.`} - - - - ) : ( - - {filteredTransactions.map((transaction) => ( - - ))} - - )} - - - {/* Revenue Breakdown */} - - - - Revenue Breakdown - - - - - Sponsorships - - $1,600.00 - - - - - Membership Fees - - $1,600.00 - - - Total Gross Revenue - $3,200.00 - - - Platform Fee (10%) - -$320.00 - - - Net Revenue - $2,880.00 - - - - - - - - Payout Schedule - - - - Season 2 Prize Pool - Pending - - - Distributed after season completion to top 3 drivers - - - - - Available for Withdrawal - {viewData.formattedBalance} - - - Available after Season 2 ends (estimated: Jan 15, 2026) - - - - - - - - {/* Withdraw Modal */} - {showWithdrawModal && onWithdraw && ( - - - - Withdraw Funds - - {!canWithdraw ? ( - - {withdrawalBlockReason} - - ) : ( - - - - Amount to Withdraw - - - - $ - - ) => 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" - /> - - - Available: {viewData.formattedBalance} - - - - - - Destination - - - Bank Account ***1234 - - - - )} - - - - - - - - - )} + {}} // Not implemented for leagues yet + onWithdraw={() => {}} // Not implemented for leagues yet + /> {/* Alpha Notice */} Alpha Note: Wallet management is demonstration-only. Real payment processing and bank integrations will be available when the payment system is fully implemented. - The 10% platform fee and season-based withdrawal restrictions are enforced in the actual implementation. ); } - diff --git a/apps/website/app/media/MediaPageClient.tsx b/apps/website/app/media/MediaPageClient.tsx new file mode 100644 index 000000000..7a044382e --- /dev/null +++ b/apps/website/app/media/MediaPageClient.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/avatar/page.tsx b/apps/website/app/media/avatar/page.tsx new file mode 100644 index 000000000..5c882ade7 --- /dev/null +++ b/apps/website/app/media/avatar/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/leagues/page.tsx b/apps/website/app/media/leagues/page.tsx new file mode 100644 index 000000000..6ad4745a1 --- /dev/null +++ b/apps/website/app/media/leagues/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/page.tsx b/apps/website/app/media/page.tsx new file mode 100644 index 000000000..66f4fdb19 --- /dev/null +++ b/apps/website/app/media/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/sponsors/page.tsx b/apps/website/app/media/sponsors/page.tsx new file mode 100644 index 000000000..9cbbde0f2 --- /dev/null +++ b/apps/website/app/media/sponsors/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/teams/page.tsx b/apps/website/app/media/teams/page.tsx new file mode 100644 index 000000000..ebb1ebba2 --- /dev/null +++ b/apps/website/app/media/teams/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/media/tracks/page.tsx b/apps/website/app/media/tracks/page.tsx new file mode 100644 index 000000000..ddfddcc70 --- /dev/null +++ b/apps/website/app/media/tracks/page.tsx @@ -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 ( + + ); +} diff --git a/apps/website/app/not-found.tsx b/apps/website/app/not-found.tsx index 226a8a6fe..92c7d53fc 100644 --- a/apps/website/app/not-found.tsx +++ b/apps/website/app/not-found.tsx @@ -1,35 +1,29 @@ 'use client'; -import { Link } from '@/ui/Link'; -import { Box } from '@/ui/Box'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { Stack } from '@/ui/Stack'; +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { routes } from '@/lib/routing/RouteConfig'; +import { NotFoundTemplate, type NotFoundViewData } from '@/templates/NotFoundTemplate'; +/** + * NotFound + * + * App-level 404 handler. + * Orchestrates the NotFoundTemplate with appropriate racing-themed copy. + */ export default function NotFound() { - return ( - - - - Page not found - - The page you requested doesn't exist (or isn't available in this mode). - - - - Drive home - - - - - - ); -} \ No newline at end of file + 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 ; +} diff --git a/apps/website/app/onboarding/OnboardingWizardClient.tsx b/apps/website/app/onboarding/OnboardingWizardClient.tsx index 3fafd1ede..76fd1db9e 100644 --- a/apps/website/app/onboarding/OnboardingWizardClient.tsx +++ b/apps/website/app/onboarding/OnboardingWizardClient.tsx @@ -1,13 +1,50 @@ 'use client'; -import { OnboardingWizard } from '@/components/onboarding/OnboardingWizard'; +import { OnboardingTemplate } from '@/templates/onboarding/OnboardingTemplate'; import { routes } from '@/lib/routing/RouteConfig'; -import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction'; -import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction'; +import { completeOnboardingAction } from '@/app/actions/completeOnboardingAction'; +import { generateAvatarsAction } from '@/app/actions/generateAvatarsAction'; import { useAuth } from '@/components/auth/AuthContext'; +import { useState } from 'react'; +import { PersonalInfo } from '@/components/onboarding/PersonalInfoStep'; +import { AvatarInfo } from '@/components/onboarding/AvatarStep'; + +type OnboardingStep = 1 | 2; + +interface FormErrors { + [key: string]: string | undefined; + firstName?: string; + lastName?: string; + displayName?: string; + country?: string; + facePhoto?: string; + avatar?: string; + submit?: string; +} export function OnboardingWizardClient() { const { session } = useAuth(); + const [isProcessing, setIsProcessing] = useState(false); + const [step, setStep] = useState(1); + const [errors, setErrors] = useState({}); + + // Form state + const [personalInfo, setPersonalInfo] = useState({ + firstName: '', + lastName: '', + displayName: '', + country: '', + timezone: '', + }); + + const [avatarInfo, setAvatarInfo] = useState({ + facePhoto: null, + suitColor: 'blue', + generatedAvatars: [], + selectedAvatarIndex: null, + isGenerating: false, + isValidating: false, + }); const handleCompleteOnboarding = async (input: { firstName: string; @@ -16,16 +53,19 @@ export function OnboardingWizardClient() { country: string; timezone?: string; }) => { + setIsProcessing(true); try { const result = await completeOnboardingAction(input); if (result.isErr()) { + setIsProcessing(false); return { success: false, error: result.getError() }; } window.location.href = routes.protected.dashboard; return { success: true }; } catch (error) { + setIsProcessing(false); return { success: false, error: 'Failed to complete onboarding' }; } }; @@ -38,6 +78,7 @@ export function OnboardingWizardClient() { return { success: false, error: 'Not authenticated' }; } + setIsProcessing(true); try { const result = await generateAvatarsAction({ userId: session.user.userId, @@ -46,23 +87,37 @@ export function OnboardingWizardClient() { }); if (result.isErr()) { + setIsProcessing(false); return { success: false, error: result.getError() }; } const data = result.unwrap(); + setIsProcessing(false); return { success: true, data }; } catch (error) { + setIsProcessing(false); return { success: false, error: 'Failed to generate avatars' }; } }; return ( - { - window.location.href = routes.protected.dashboard; + { + window.location.href = routes.protected.dashboard; + }, + onCompleteOnboarding: handleCompleteOnboarding, + onGenerateAvatars: handleGenerateAvatars, + isProcessing: isProcessing, + step, + setStep, + errors, + setErrors, + personalInfo, + setPersonalInfo, + avatarInfo, + setAvatarInfo, }} - onCompleteOnboarding={handleCompleteOnboarding} - onGenerateAvatars={handleGenerateAvatars} /> ); -} \ No newline at end of file +} diff --git a/apps/website/app/profile/ProfilePageClient.tsx b/apps/website/app/profile/ProfilePageClient.tsx index 600d789f6..b64c41a60 100644 --- a/apps/website/app/profile/ProfilePageClient.tsx +++ b/apps/website/app/profile/ProfilePageClient.tsx @@ -2,23 +2,21 @@ import { useEffect, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { ProfileTemplate, type ProfileTab } from '@/templates/ProfileTemplate'; +import { ProfileTemplate } from '@/templates/ProfileTemplate'; +import { type ProfileTab } from '@/components/profile/ProfileNavTabs'; import type { ProfileViewData } from '@/lib/view-data/ProfileViewData'; -import type { Result } from '@/lib/contracts/Result'; interface ProfilePageClientProps { viewData: ProfileViewData; mode: 'profile-exists' | 'needs-profile'; - onSaveSettings: (updates: { bio?: string; country?: string }) => Promise>; } -export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePageClientProps) { +export function ProfilePageClient({ viewData, mode }: ProfilePageClientProps) { const router = useRouter(); const searchParams = useSearchParams(); const tabParam = searchParams.get('tab') as ProfileTab | null; const [activeTab, setActiveTab] = useState(tabParam || 'overview'); - const [editMode, setEditMode] = useState(false); const [friendRequestSent, setFriendRequestSent] = useState(false); useEffect(() => { @@ -49,19 +47,8 @@ export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePag mode={mode} activeTab={activeTab} onTabChange={setActiveTab} - editMode={editMode} - onEditModeChange={setEditMode} friendRequestSent={friendRequestSent} onFriendRequestSend={() => setFriendRequestSent(true)} - onSaveSettings={async (updates) => { - const result = await onSaveSettings(updates); - if (result.isErr()) { - // In a real app, we'd show a toast or error message. - // For now, we just throw to let the UI handle it if needed, - // or we could add an error state to this client component. - throw new Error(result.getError()); - } - }} /> ); } diff --git a/apps/website/app/profile/layout.tsx b/apps/website/app/profile/layout.tsx index 3a99485f4..95439efc2 100644 --- a/apps/website/app/profile/layout.tsx +++ b/apps/website/app/profile/layout.tsx @@ -1,7 +1,7 @@ import type { ReactNode } from 'react'; import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; -import { ProfileLayoutShell } from '@/ui/ProfileLayoutShell'; +import { ProfileLayoutShellTemplate } from '@/templates/ProfileLayoutShellTemplate'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; interface ProfileLayoutProps { @@ -18,5 +18,5 @@ export default async function ProfileLayout({ children }: ProfileLayoutProps) { redirect(result.to); } - return {children}; + return {children}; } diff --git a/apps/website/app/profile/liveries/page.tsx b/apps/website/app/profile/liveries/page.tsx index d6d85ffb4..efba7cab9 100644 --- a/apps/website/app/profile/liveries/page.tsx +++ b/apps/website/app/profile/liveries/page.tsx @@ -1,13 +1,4 @@ -import Link from 'next/link'; -import { Button } from '@/ui/Button'; -import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; -import { Grid } from '@/ui/Grid'; -import { routes } from '@/lib/routing/RouteConfig'; -import { LiveryCard } from '@/ui/LiveryCard'; -import { Stack } from '@/ui/Stack'; -import { Box } from '@/ui/Box'; -import { Text } from '@/ui/Text'; +import { ProfileLiveriesTemplate } from '@/templates/ProfileLiveriesTemplate'; export default async function ProfileLiveriesPage() { const mockLiveries = [ @@ -29,29 +20,5 @@ export default async function ProfileLiveriesPage() { } ]; - return ( - - - - My Liveries - Manage your custom car liveries - - - - - - - - {mockLiveries.map((livery) => ( - - ))} - - - - - - - - - ); + return ; } diff --git a/apps/website/app/profile/liveries/upload/ProfileLiveryUploadPageClient.tsx b/apps/website/app/profile/liveries/upload/ProfileLiveryUploadPageClient.tsx new file mode 100644 index 000000000..63fe87cd1 --- /dev/null +++ b/apps/website/app/profile/liveries/upload/ProfileLiveryUploadPageClient.tsx @@ -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(null); + const [previewUrl, setPreviewUrl] = useState(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 ( + + + Upload livery + + Upload your custom car livery. Supported formats: .png, .jpg, .tga + + + + + + + + + + + + + + + + + + + {previewUrl ? ( + + + + + + ) : ( + + + Select a file to see preview and details + + + )} + + + + ); +} diff --git a/apps/website/app/profile/liveries/upload/page.tsx b/apps/website/app/profile/liveries/upload/page.tsx index 57f317cdd..0e6c85aa5 100644 --- a/apps/website/app/profile/liveries/upload/page.tsx +++ b/apps/website/app/profile/liveries/upload/page.tsx @@ -1,21 +1,6 @@ -import Link from 'next/link'; -import { Button } from '@/ui/Button'; -import { Card } from '@/ui/Card'; -import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; -import { Text } from '@/ui/Text'; -import { routes } from '@/lib/routing/RouteConfig'; +import React from 'react'; +import { ProfileLiveryUploadPageClient } from './ProfileLiveryUploadPageClient'; export default async function ProfileLiveryUploadPage() { - return ( - - Upload livery - - Livery upload is currently unavailable. - - - - - - ); + return ; } diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 5a628ae18..c69bef3dd 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -1,6 +1,5 @@ import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery'; import { notFound } from 'next/navigation'; -import { updateProfileAction } from './actions'; import { ProfilePageClient } from './ProfilePageClient'; export default async function ProfilePage() { @@ -18,7 +17,6 @@ export default async function ProfilePage() { ); } diff --git a/apps/website/app/profile/settings/ProfileSettingsPageClient.tsx b/apps/website/app/profile/settings/ProfileSettingsPageClient.tsx new file mode 100644 index 000000000..f3c2ac061 --- /dev/null +++ b/apps/website/app/profile/settings/ProfileSettingsPageClient.tsx @@ -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>; +} + +export function ProfileSettingsPageClient({ viewData, onSave }: ProfileSettingsPageClientProps) { + const router = useRouter(); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(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 ( + <> + + {error && ( + + + + )} + + + ); +} diff --git a/apps/website/app/profile/settings/page.tsx b/apps/website/app/profile/settings/page.tsx index c724ffcfe..f4136eee8 100644 --- a/apps/website/app/profile/settings/page.tsx +++ b/apps/website/app/profile/settings/page.tsx @@ -1,21 +1,22 @@ -import Link from 'next/link'; -import { Button } from '@/ui/Button'; -import { Card } from '@/ui/Card'; -import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; -import { Text } from '@/ui/Text'; -import { routes } from '@/lib/routing/RouteConfig'; +import { ProfilePageQuery } from '@/lib/page-queries/ProfilePageQuery'; +import { notFound } from 'next/navigation'; +import { updateProfileAction } from '@/app/actions/profileActions'; +import { ProfileSettingsPageClient } from './ProfileSettingsPageClient'; export default async function ProfileSettingsPage() { + const query = new ProfilePageQuery(); + const result = await query.execute(); + + if (result.isErr()) { + notFound(); + } + + const viewData = result.unwrap(); + return ( - - Settings - - Settings are currently unavailable. - - - - - + ); } diff --git a/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsClient.tsx b/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsClient.tsx index 3e4bb6507..dfe865ee5 100644 --- a/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsClient.tsx +++ b/apps/website/app/profile/sponsorship-requests/SponsorshipRequestsClient.tsx @@ -1,8 +1,13 @@ 'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; import type { Result } from '@/lib/contracts/Result'; import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData'; import { SponsorshipRequestsTemplate } from '@/templates/SponsorshipRequestsTemplate'; +import { InlineNotice } from '@/components/shared/ux/InlineNotice'; +import { ProgressLine } from '@/components/shared/ux/ProgressLine'; +import { Box } from '@/ui/Box'; interface SponsorshipRequestsClientProps { viewData: SponsorshipRequestsViewData; @@ -11,25 +16,54 @@ interface SponsorshipRequestsClientProps { } export function SponsorshipRequestsClient({ viewData, onAccept, onReject }: SponsorshipRequestsClientProps) { + const router = useRouter(); + const [isProcessing, setIsProcessing] = useState(null); + const [error, setError] = useState(null); + const handleAccept = async (requestId: string) => { + setIsProcessing(requestId); + setError(null); const result = await onAccept(requestId); if (result.isErr()) { - console.error('Failed to accept request:', result.getError()); + setError(result.getError()); + setIsProcessing(null); + } else { + router.refresh(); + setIsProcessing(null); } }; const handleReject = async (requestId: string, reason?: string) => { + setIsProcessing(requestId); + setError(null); const result = await onReject(requestId, reason); if (result.isErr()) { - console.error('Failed to reject request:', result.getError()); + setError(result.getError()); + setIsProcessing(null); + } else { + router.refresh(); + setIsProcessing(null); } }; return ( - + <> + + {error && ( + + + + )} + + ); } diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 7546f21e3..c20bbba86 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; import { SponsorshipRequestsPageQuery } from '@/lib/page-queries/SponsorshipRequestsPageQuery'; import { SponsorshipRequestsClient } from './SponsorshipRequestsClient'; -import { acceptSponsorshipRequest, rejectSponsorshipRequest } from './actions'; +import { acceptSponsorshipRequest, rejectSponsorshipRequest } from '@/app/actions/sponsorshipActions'; export default async function SponsorshipRequestsPage() { // Execute PageQuery diff --git a/apps/website/app/races/[id]/RaceDetailPageClient.tsx b/apps/website/app/races/[id]/RaceDetailPageClient.tsx index ad1d7b2a3..0fd5c9e23 100644 --- a/apps/website/app/races/[id]/RaceDetailPageClient.tsx +++ b/apps/website/app/races/[id]/RaceDetailPageClient.tsx @@ -8,7 +8,7 @@ interface Props { data: RaceDetailViewData; } -export default function RaceDetailPageClient({ data: viewData }: Props) { +export function RaceDetailPageClient({ data: viewData }: Props) { const router = useRouter(); const [animatedRatingChange] = useState(0); diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index de3464099..612972fd0 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceDetailPageQuery } from '@/lib/page-queries/races/RaceDetailPageQuery'; -import RaceDetailPageClient from './RaceDetailPageClient'; +import { RaceDetailPageClient } from './RaceDetailPageClient'; interface RaceDetailPageProps { params: Promise<{ @@ -29,8 +29,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { return ( ); } diff --git a/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx b/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx index 958a0df1c..260090659 100644 --- a/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx +++ b/apps/website/app/races/[id]/results/RaceResultsPageClient.tsx @@ -9,7 +9,7 @@ interface Props { data: RaceResultsViewData; } -export default function RaceResultsPageClient({ data: viewData }: Props) { +export function RaceResultsPageClient({ data: viewData }: Props) { const router = useRouter(); const [importing, setImporting] = useState(false); const [importSuccess, setImportSuccess] = useState(false); diff --git a/apps/website/app/races/[id]/results/page.tsx b/apps/website/app/races/[id]/results/page.tsx index d4189ff5c..1119923e6 100644 --- a/apps/website/app/races/[id]/results/page.tsx +++ b/apps/website/app/races/[id]/results/page.tsx @@ -1,7 +1,7 @@ import { notFound } from 'next/navigation'; import { PageWrapper } from '@/components/shared/state/PageWrapper'; import { RaceResultsPageQuery } from '@/lib/page-queries/races/RaceResultsPageQuery'; -import RaceResultsPageClient from './RaceResultsPageClient'; +import { RaceResultsPageClient } from './RaceResultsPageClient'; interface RaceResultsPageProps { params: Promise<{ @@ -29,8 +29,8 @@ export default async function RaceResultsPage({ params }: RaceResultsPageProps) return ( ); } diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx index c390c6991..1c95276e1 100644 --- a/apps/website/app/sponsor/billing/page.tsx +++ b/apps/website/app/sponsor/billing/page.tsx @@ -1,251 +1,13 @@ 'use client'; -import { useState } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { StatCard } from '@/ui/StatCard'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { SectionHeader } from '@/ui/SectionHeader'; -import { InfoBanner } from '@/ui/InfoBanner'; -import { PageHeader } from '@/ui/PageHeader'; -import { Icon } from '@/ui/Icon'; -import { siteConfig } from '@/lib/siteConfig'; import { useSponsorBilling } from "@/hooks/sponsor/useSponsorBilling"; -import { - CreditCard, - DollarSign, - Calendar, - Download, - Plus, - Check, - AlertTriangle, - FileText, - TrendingUp, - Receipt, - Building2, - Wallet, - Clock, - ChevronRight, - Info, - ExternalLink, - Percent, - Loader2 -} from 'lucide-react'; -import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO'; - -// ============================================================================ -// Components -// ============================================================================ - -function PaymentMethodCardComponent({ - method, - onSetDefault, - onRemove -}: { - method: PaymentMethodDTO; - onSetDefault: () => void; - onRemove: () => void; -}) { - const shouldReduceMotion = useReducedMotion(); - - const getIcon = () => { - if (method.type === 'sepa') return Building2; - return CreditCard; - }; - - const MethodIcon = getIcon(); - - const displayLabel = method.type === 'sepa' && method.bankName - ? `${method.bankName} •••• ${method.last4}` - : `${method.brand} •••• ${method.last4}`; - - const expiryDisplay = method.expiryMonth && method.expiryYear - ? `${method.expiryMonth}/${method.expiryYear}` - : null; - - return ( - - - - - - - - - {displayLabel} - {method.isDefault && ( - - - Default - - - )} - - {expiryDisplay && ( - - Expires {expiryDisplay} - - )} - {method.type === 'sepa' && ( - SEPA Direct Debit - )} - - - - {!method.isDefault && ( - - )} - - - - - ); -} - -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 ( - - - - - - - - {invoice.description} - - - {typeLabels[invoice.sponsorshipType as keyof typeof typeLabels]} - - - - - {invoice.invoiceNumber} - - - {new globalThis.Date(invoice.date).toLocaleDateString()} - - - - - - - - - ${invoice.totalAmount.toFixed(2)} - - - incl. ${invoice.vatAmount.toFixed(2)} VAT - - - - - - {status.label} - - - - - - ); -} - -// ============================================================================ -// Main Component -// ============================================================================ +import { SponsorBillingTemplate } from "@/templates/SponsorBillingTemplate"; +import { Box } from "@/ui/Box"; +import { Text } from "@/ui/Text"; +import { Button } from "@/ui/Button"; +import { DollarSign, AlertTriangle, Calendar, TrendingUp } from "lucide-react"; export default function SponsorBillingPage() { - const shouldReduceMotion = useReducedMotion(); - const [showAllInvoices, setShowAllInvoices] = useState(false); - const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1'); if (isLoading) { @@ -274,228 +36,68 @@ export default function SponsorBillingPage() { ); } - const data = billingData; - const handleSetDefault = (methodId: string) => { - // In a real app, this would call an API console.log('Setting default payment method:', methodId); }; const handleRemoveMethod = (methodId: string) => { if (window.confirm('Remove this payment method?')) { - // In a real app, this would call an API console.log('Removing payment method:', methodId); } }; - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: shouldReduceMotion ? 0 : 0.1, - }, - }, + const handleDownloadInvoice = (invoiceId: string) => { + console.log('Downloading invoice:', invoiceId); }; - const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0 }, - }; + const billingStats = [ + { + label: 'Total Spent', + value: `$${billingData.stats.totalSpent.toFixed(2)}`, + subValue: 'All time', + icon: DollarSign, + variant: 'success' as const, + }, + { + label: 'Pending Payments', + value: `$${billingData.stats.pendingAmount.toFixed(2)}`, + subValue: `${billingData.invoices.filter(i => i.status === 'pending' || i.status === 'overdue').length} invoices`, + icon: AlertTriangle, + variant: (billingData.stats.pendingAmount > 0 ? 'warning' : 'default') as 'warning' | 'default', + }, + { + label: 'Next Payment', + value: new Date(billingData.stats.nextPaymentDate).toLocaleDateString(), + subValue: `$${billingData.stats.nextPaymentAmount.toFixed(2)}`, + icon: Calendar, + variant: 'info' as const, + }, + { + label: 'Monthly Average', + value: `$${billingData.stats.averageMonthlySpend.toFixed(2)}`, + subValue: 'Last 6 months', + icon: TrendingUp, + variant: 'default' as const, + }, + ]; + + const transactions = billingData.invoices.map(inv => ({ + id: inv.id, + date: inv.date, + description: inv.description, + amount: inv.totalAmount, + status: inv.status as any, + invoiceNumber: inv.invoiceNumber, + type: inv.sponsorshipType, + })); return ( - - {/* Header */} - - - - - {/* Stats Grid */} - - - i.status === 'pending' || i.status === 'overdue').length} invoices`} - variant="orange" - /> - - - - - {/* Payment Methods */} - - - }> - Add Payment Method - - } - /> - - {data.paymentMethods.map((method: PaymentMethodDTO) => ( - handleSetDefault(method.id)} - onRemove={() => handleRemoveMethod(method.id)} - /> - ))} - - - - We support Visa, Mastercard, American Express, and SEPA Direct Debit. - All payment information is securely processed and stored by our payment provider. - - - - - - {/* Billing History */} - - - }> - Export All - - } - /> - - {data.invoices.slice(0, showAllInvoices ? data.invoices.length : 4).map((invoice: InvoiceDTO, index: number) => ( - - ))} - - {data.invoices.length > 4 && ( - - - - )} - - - - {/* Platform Fee & VAT Information */} - - {/* Platform Fee */} - - - }> - Platform Fee - - - - - {siteConfig.fees.platformFeePercent}% - - - {siteConfig.fees.description} - - - • Applied to all sponsorship payments - • Covers platform maintenance and analytics - • Ensures quality sponsorship placements - - - - - {/* VAT Information */} - - - }> - VAT Information - - - - - {siteConfig.vat.notice} - - - - Standard VAT Rate - {siteConfig.vat.standardRate}% - - - B2B Reverse Charge - Available - - - - Enter your VAT ID in Settings to enable reverse charge for B2B transactions. - - - - - - {/* Billing Support */} - - - - - - - - - Need help with billing? - - Contact our billing support for questions about invoices, payments, or refunds. - - - - - - - - + ); } diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index 615339765..b24030593 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -1,619 +1,74 @@ 'use client'; import { useState } from 'react'; -import { useSearchParams } from 'next/navigation'; -import { motion, useReducedMotion } from 'framer-motion'; -import Link from 'next/link'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { InfoBanner } from '@/ui/InfoBanner'; import { useSponsorSponsorships } from "@/hooks/sponsor/useSponsorSponsorships"; -import { - Megaphone, - Trophy, - Users, - Eye, - Calendar, - ExternalLink, - Plus, - ChevronRight, - Check, - Clock, - XCircle, - Car, - Flag, - Search, - TrendingUp, - BarChart3, - ArrowUpRight, - ArrowDownRight, - Send, - ThumbsUp, - ThumbsDown, - RefreshCw, -} from 'lucide-react'; - -// ============================================================================ -// Types -// ============================================================================ - -type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform'; -type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired'; - -// ============================================================================ -// Configuration -// ============================================================================ - -const TYPE_CONFIG = { - leagues: { icon: Trophy, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', label: 'League' }, - teams: { icon: Users, color: 'text-purple-400', bgColor: 'bg-purple-400/10', label: 'Team' }, - drivers: { icon: Car, color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Driver' }, - races: { icon: Flag, color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Race' }, - platform: { icon: Megaphone, color: 'text-racing-red', bgColor: 'bg-racing-red/10', label: 'Platform' }, - all: { icon: BarChart3, color: 'text-gray-400', bgColor: 'bg-gray-400/10', label: 'All' }, -}; - -const STATUS_CONFIG = { - active: { - icon: Check, - color: 'text-performance-green', - bgColor: 'bg-performance-green/10', - borderColor: 'border-performance-green/30', - label: 'Active' - }, - pending_approval: { - icon: Clock, - color: 'text-warning-amber', - bgColor: 'bg-warning-amber/10', - borderColor: 'border-warning-amber/30', - label: 'Awaiting Approval' - }, - approved: { - icon: ThumbsUp, - color: 'text-primary-blue', - bgColor: 'bg-primary-blue/10', - borderColor: 'border-primary-blue/30', - label: 'Approved' - }, - rejected: { - icon: ThumbsDown, - color: 'text-racing-red', - bgColor: 'bg-racing-red/10', - borderColor: 'border-racing-red/30', - label: 'Declined' - }, - expired: { - icon: XCircle, - color: 'text-gray-400', - bgColor: 'bg-gray-400/10', - borderColor: 'border-gray-400/30', - label: 'Expired' - }, -}; - -// ============================================================================ -// Components -// ============================================================================ - -function SponsorshipCard({ sponsorship }: { sponsorship: unknown }) { - const shouldReduceMotion = useReducedMotion(); - - const s = sponsorship as any; // Temporary cast to avoid breaking logic - const typeConfig = TYPE_CONFIG[s.type as keyof typeof TYPE_CONFIG]; - const statusConfig = STATUS_CONFIG[s.status as keyof typeof STATUS_CONFIG]; - const TypeIcon = typeConfig.icon; - const StatusIcon = statusConfig.icon; - - const daysRemaining = Math.ceil((s.endDate.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); - const isExpiringSoon = daysRemaining > 0 && daysRemaining <= 30; - const isPending = s.status === 'pending_approval'; - const isRejected = s.status === 'rejected'; - const isApproved = s.status === 'approved'; - - const getEntityLink = () => { - switch (s.type) { - case 'leagues': return `/leagues/${s.entityId}`; - case 'teams': return `/teams/${s.entityId}`; - case 'drivers': return `/drivers/${s.entityId}`; - case 'races': return `/races/${s.entityId}`; - default: return '#'; - } - }; - - return ( - - - {/* Header */} - - - - - - - - - {typeConfig.label} - - {s.tier && ( - - {s.tier === 'main' ? 'Main Sponsor' : 'Secondary'} - - )} - - - - - - {statusConfig.label} - - - - {/* Entity Name */} - {s.entityName} - {s.details && ( - {s.details} - )} - - {/* Application/Approval Info for non-active states */} - {isPending && ( - - - - Application Pending - - - Sent to {s.entityOwner} on{' '} - {s.applicationDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - - {s.applicationMessage && ( - "{s.applicationMessage}" - )} - - )} - - {isApproved && ( - - - - Approved! - - - Approved by {s.entityOwner} on{' '} - {s.approvalDate?.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} - - - Starts {s.startDate.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} - - - )} - - {isRejected && ( - - - - Application Declined - - {s.rejectionReason && ( - - Reason: {s.rejectionReason} - - )} - - - )} - - {/* Metrics Grid - Only show for active sponsorships */} - {s.status === 'active' && ( - - - - - Impressions - - - {s.formattedImpressions} - {s.impressionsChange !== undefined && s.impressionsChange !== 0 && ( - 0 ? 'text-performance-green' : 'text-racing-red'}> - {s.impressionsChange > 0 ? : } - {Math.abs(s.impressionsChange)}% - - )} - - - - {s.engagement && ( - - - - Engagement - - {s.engagement}% - - )} - - - - - Period - - - {s.startDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - {s.endDate.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} - - - - - - - Investment - - {s.formattedPrice} - - - )} - - {/* Basic info for non-active */} - {s.status !== 'active' && ( - - - - {s.periodDisplay} - - - - {s.formattedPrice} - - - )} - - {/* Footer */} - - - {s.status === 'active' && ( - - {daysRemaining > 0 ? `${daysRemaining} days remaining` : 'Ended'} - - )} - {isPending && ( - - Waiting for response... - - )} - - - {s.type !== 'platform' && ( - - - - )} - {isPending && ( - - )} - {s.status === 'active' && ( - - )} - - - - - ); -} - -// ============================================================================ -// Main Component -// ============================================================================ +import { SponsorCampaignsTemplate, SponsorshipType } from "@/templates/SponsorCampaignsTemplate"; +import { Box } from "@/ui/Box"; +import { Text } from "@/ui/Text"; +import { Button } from "@/ui/Button"; export default function SponsorCampaignsPage() { - const searchParams = useSearchParams(); - const shouldReduceMotion = useReducedMotion(); - - const initialType = (searchParams.get('type') as SponsorshipType) || 'all'; - const [typeFilter, setTypeFilter] = useState(initialType); - const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); - const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1'); if (isLoading) { return ( -
-
-
-

Loading sponsorships...

-
-
+ + + + Loading sponsorships... + + ); } if (error || !sponsorshipsData) { return ( -
-
-

{error?.getUserMessage() || 'No sponsorships data available'}

+ + + {error?.getUserMessage() || 'No sponsorships data available'} {error && ( - )} -
-
+
+ ); } - const data = sponsorshipsData; + // Calculate stats + const stats = { + total: sponsorshipsData.sponsorships.length, + active: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').length, + pending: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'pending_approval').length, + approved: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'approved').length, + rejected: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'rejected').length, + totalInvestment: sponsorshipsData.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0), + totalImpressions: sponsorshipsData.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0), + }; - // Filter sponsorships - const filteredSponsorships = data.sponsorships.filter((s: unknown) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sponsorship = s as any; - if (typeFilter !== 'all' && sponsorship.type !== typeFilter) return false; - if (statusFilter !== 'all' && sponsorship.status !== statusFilter) return false; - if (searchQuery && !sponsorship.entityName.toLowerCase().includes(searchQuery.toLowerCase())) return false; + const viewData = { + sponsorships: sponsorshipsData.sponsorships as any, + stats, + }; + + const filteredSponsorships = sponsorshipsData.sponsorships.filter((s: any) => { + // For now, we only have leagues in the DTO + if (typeFilter !== 'all' && typeFilter !== 'leagues') return false; + if (searchQuery && !s.leagueName.toLowerCase().includes(searchQuery.toLowerCase())) return false; return true; }); - // Calculate stats - const stats = { - total: data.sponsorships.length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - active: data.sponsorships.filter((s: any) => s.status === 'active').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - pending: data.sponsorships.filter((s: any) => s.status === 'pending_approval').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - approved: data.sponsorships.filter((s: any) => s.status === 'approved').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - rejected: data.sponsorships.filter((s: any) => s.status === 'rejected').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - totalInvestment: data.sponsorships.filter((s: any) => s.status === 'active').reduce((sum: number, s: any) => sum + s.price, 0), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - totalImpressions: data.sponsorships.reduce((sum: number, s: any) => sum + s.impressions, 0), - }; - - // Stats by type - const statsByType = { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - leagues: data.sponsorships.filter((s: any) => s.type === 'leagues').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - teams: data.sponsorships.filter((s: any) => s.type === 'teams').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - drivers: data.sponsorships.filter((s: any) => s.type === 'drivers').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - races: data.sponsorships.filter((s: any) => s.type === 'races').length, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - platform: data.sponsorships.filter((s: any) => s.type === 'platform').length, - }; - return ( - - {/* Header */} - - - }> - My Sponsorships - - Manage applications and active sponsorship campaigns - - - - - - - - - {/* Info Banner about how sponsorships work */} - {stats.pending > 0 && ( - - - - You have {stats.pending} pending application{stats.pending !== 1 ? 's' : ''} waiting for approval. - League admins, team owners, and drivers review applications before accepting sponsorships. - - - - )} - - {/* Quick Stats */} - - - - {stats.total} - Total - - - - - {stats.active} - Active - - - - 0 ? 'border-warning-amber/30' : ''}`}> - {stats.pending} - Pending - - - - - {stats.approved} - Approved - - - - - ${stats.totalInvestment.toLocaleString()} - Active Investment - - - - - {(stats.totalImpressions / 1000).toFixed(0)}k - Impressions - - - - - {/* Filters */} - - {/* Search */} - - - 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" - /> - - - {/* Type Filter */} - - {(['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 ( - - ); - })} - - - {/* Status Filter */} - - {(['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 ( - - ); - })} - - - - {/* Sponsorship List */} - {filteredSponsorships.length === 0 ? ( - - - No sponsorships found - - {searchQuery || typeFilter !== 'all' || statusFilter !== 'all' - ? 'Try adjusting your filters to see more results.' - : 'Start sponsoring leagues, teams, or drivers to grow your brand visibility.'} - - - - - - - - - - - - - - ) : ( - - {filteredSponsorships.map((sponsorship: any) => ( - - ))} - - )} - + ); } \ No newline at end of file diff --git a/apps/website/app/sponsor/settings/page.tsx b/apps/website/app/sponsor/settings/page.tsx index babed73ed..ee806ab14 100644 --- a/apps/website/app/sponsor/settings/page.tsx +++ b/apps/website/app/sponsor/settings/page.tsx @@ -1,92 +1,16 @@ 'use client'; import { useState } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { Input } from '@/ui/Input'; -import { Toggle } from '@/ui/Toggle'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { SectionHeader } from '@/ui/SectionHeader'; -import { FormField } from '@/ui/FormField'; -import { PageHeader } from '@/ui/PageHeader'; -import { Image } from '@/ui/Image'; -import { - Settings, - Building2, - Mail, - Globe, - Upload, - Save, - Bell, - Shield, - Eye, - Trash2, - CheckCircle, - User, - Phone, - MapPin, - FileText, - Link as LinkIcon, - Image as ImageIcon, - Lock, - Key, - Smartphone, - AlertCircle -} from 'lucide-react'; +import { SponsorSettingsTemplate } from '@/templates/SponsorSettingsTemplate'; import { logoutAction } from '@/app/actions/logoutAction'; - -// ============================================================================ -// Types -// ============================================================================ - -interface SponsorProfile { - companyName: string; - contactName: string; - contactEmail: string; - contactPhone: string; - website: string; - description: string; - logoUrl: string | null; - industry: string; - address: { - street: string; - city: string; - country: string; - postalCode: string; - }; - taxId: string; - socialLinks: { - twitter: string; - linkedin: string; - instagram: string; - }; -} - -interface NotificationSettings { - emailNewSponsorships: boolean; - emailWeeklyReport: boolean; - emailRaceAlerts: boolean; - emailPaymentAlerts: boolean; - emailNewOpportunities: boolean; - emailContractExpiry: boolean; -} - -interface PrivacySettings { - publicProfile: boolean; - showStats: boolean; - showActiveSponsorships: boolean; - allowDirectContact: boolean; -} +import { ConfirmDialog } from '@/components/shared/ux/ConfirmDialog'; +import { useRouter } from 'next/navigation'; // ============================================================================ // Mock Data // ============================================================================ -const MOCK_PROFILE: SponsorProfile = { +const MOCK_PROFILE = { companyName: 'Acme Racing Co.', contactName: 'John Smith', contactEmail: 'sponsor@acme-racing.com', @@ -109,7 +33,7 @@ const MOCK_PROFILE: SponsorProfile = { }, }; -const MOCK_NOTIFICATIONS: NotificationSettings = { +const MOCK_NOTIFICATIONS = { emailNewSponsorships: true, emailWeeklyReport: true, emailRaceAlerts: false, @@ -118,581 +42,71 @@ const MOCK_NOTIFICATIONS: NotificationSettings = { emailContractExpiry: true, }; -const MOCK_PRIVACY: PrivacySettings = { +const MOCK_PRIVACY = { publicProfile: true, showStats: false, showActiveSponsorships: true, allowDirectContact: true, }; -const INDUSTRY_OPTIONS = [ - 'Racing Equipment', - 'Automotive', - 'Technology', - 'Gaming & Esports', - 'Energy Drinks', - 'Apparel', - 'Financial Services', - 'Other', -]; - -// ============================================================================ -// Components -// ============================================================================ - -function SavedIndicator({ visible }: { visible: boolean }) { - const shouldReduceMotion = useReducedMotion(); - - return ( - - - Changes saved - - ); -} - -// ============================================================================ -// Main Component -// ============================================================================ - export default function SponsorSettingsPage() { - const shouldReduceMotion = useReducedMotion(); + const router = useRouter(); const [profile, setProfile] = useState(MOCK_PROFILE); const [notifications, setNotifications] = useState(MOCK_NOTIFICATIONS); - const [privacy, setPrivacy] = useState(MOCK_PRIVACY); + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const [saving, setSaving] = useState(false); const [saved, setSaved] = useState(false); const handleSaveProfile = async () => { setSaving(true); await new Promise(resolve => setTimeout(resolve, 800)); + console.log('Profile saved:', profile); setSaving(false); setSaved(true); setTimeout(() => setSaved(false), 3000); }; const handleDeleteAccount = async () => { - if (confirm('Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data will be permanently removed.')) { - // Call the logout action and handle result - const result = await logoutAction(); - if (result.isErr()) { - console.error('Logout failed:', result.getError()); - // Could show error toast here - return; - } - // Redirect to login after successful logout - window.location.href = '/auth/login'; + setIsDeleting(true); + const result = await logoutAction(); + if (result.isErr()) { + console.error('Logout failed:', result.getError()); + setIsDeleting(false); + return; } + router.push('/auth/login'); }; - const containerVariants = { - hidden: { opacity: 0 }, - visible: { - opacity: 1, - transition: { - staggerChildren: shouldReduceMotion ? 0 : 0.1, - }, - }, - }; - - const itemVariants = { - hidden: { opacity: 0, y: 20 }, - visible: { opacity: 1, y: 0 }, + const viewData = { + profile: MOCK_PROFILE, + notifications: MOCK_NOTIFICATIONS, + privacy: MOCK_PRIVACY, }; return ( - - {/* Header */} - - } - /> - - - {/* Company Profile */} - - - - - {/* Company Basic Info */} - - - setProfile({ ...profile, companyName: e.target.value })} - placeholder="Your company name" - /> - - - - ) => 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 => ( - - ))} - - - - - {/* Contact Information */} - - - Contact Information - - - - setProfile({ ...profile, contactName: e.target.value })} - placeholder="Full name" - /> - - - - setProfile({ ...profile, contactEmail: e.target.value })} - placeholder="sponsor@company.com" - /> - - - - setProfile({ ...profile, contactPhone: e.target.value })} - placeholder="+1 (555) 123-4567" - /> - - - - setProfile({ ...profile, website: e.target.value })} - placeholder="https://company.com" - /> - - - - - {/* Address */} - - - Business Address - - - - - setProfile({ - ...profile, - address: { ...profile.address, street: e.target.value } - })} - placeholder="123 Main Street" - /> - - - - - setProfile({ - ...profile, - address: { ...profile.address, city: e.target.value } - })} - placeholder="City" - /> - - - - setProfile({ - ...profile, - address: { ...profile.address, postalCode: e.target.value } - })} - placeholder="12345" - /> - - - - setProfile({ - ...profile, - address: { ...profile.address, country: e.target.value } - })} - placeholder="Country" - /> - - - - setProfile({ ...profile, taxId: e.target.value })} - placeholder="XX12-3456789" - /> - - - - - {/* Description */} - - - ) => 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" - /> - - This description appears on your public sponsor profile. - - - - - {/* Social Links */} - - - Social Media - - - - setProfile({ - ...profile, - socialLinks: { ...profile.socialLinks, twitter: e.target.value } - })} - placeholder="@username" - /> - - - - setProfile({ - ...profile, - socialLinks: { ...profile.socialLinks, linkedin: e.target.value } - })} - placeholder="company-name" - /> - - - - setProfile({ - ...profile, - socialLinks: { ...profile.socialLinks, instagram: e.target.value } - })} - placeholder="@username" - /> - - - - - {/* Logo Upload */} - - - - - {profile.logoUrl ? ( - Company logo - ) : ( - - )} - - - - - - - - Upload Logo - - - {profile.logoUrl && ( - - )} - - - PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px. - - - - - - - {/* Save Button */} - - - - - - - - {/* Notification Preferences */} - - - - - - setNotifications({ ...notifications, emailNewSponsorships: checked })} - label="Sponsorship Approvals" - description="Receive confirmation when your sponsorship requests are approved" - /> - setNotifications({ ...notifications, emailWeeklyReport: checked })} - label="Weekly Analytics Report" - description="Get a weekly summary of your sponsorship performance" - /> - setNotifications({ ...notifications, emailRaceAlerts: checked })} - label="Race Day Alerts" - description="Be notified when sponsored leagues have upcoming races" - /> - setNotifications({ ...notifications, emailPaymentAlerts: checked })} - label="Payment & Invoice Notifications" - description="Receive invoices and payment confirmations" - /> - setNotifications({ ...notifications, emailNewOpportunities: checked })} - label="New Sponsorship Opportunities" - description="Get notified about new leagues and drivers seeking sponsors" - /> - setNotifications({ ...notifications, emailContractExpiry: checked })} - label="Contract Expiry Reminders" - description="Receive reminders before your sponsorship contracts expire" - /> - - - - - - {/* Privacy & Visibility */} - - - - - - setPrivacy({ ...privacy, publicProfile: checked })} - label="Public Profile" - description="Allow leagues, teams, and drivers to view your sponsor profile" - /> - setPrivacy({ ...privacy, showStats: checked })} - label="Show Sponsorship Statistics" - description="Display your total sponsorships and investment amounts" - /> - setPrivacy({ ...privacy, showActiveSponsorships: checked })} - label="Show Active Sponsorships" - description="Let others see which leagues and teams you currently sponsor" - /> - setPrivacy({ ...privacy, allowDirectContact: checked })} - label="Allow Direct Contact" - description="Enable leagues and teams to send you sponsorship proposals" - /> - - - - - - {/* Security */} - - - - - - - - - - - Password - Last changed 3 months ago - - - - - - - - - - - - Two-Factor Authentication - Add an extra layer of security to your account - - - - - - - - - - - - Active Sessions - Manage devices where you're logged in - - - - - - - - - {/* Danger Zone */} - - - - }> - Danger Zone - - - - - - - - - - Delete Sponsor Account - - Permanently delete your account and all associated sponsorship data. - This action cannot be undone. - - - - - - - - - + <> + setIsDeleteDialogOpen(true)} + saving={saving} + saved={saved} + /> + setIsDeleteDialogOpen(false)} + onConfirm={handleDeleteAccount} + title="Delete Sponsor Account" + description="Are you sure you want to delete your sponsor account? This action cannot be undone. All sponsorship data, contracts, and history will be permanently removed." + confirmLabel="Delete Account" + variant="danger" + isLoading={isDeleting} + /> + ); } \ No newline at end of file diff --git a/apps/website/app/teams/[id]/TeamDetailPageClient.tsx b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx index 6fea72564..92b84ac70 100644 --- a/apps/website/app/teams/[id]/TeamDetailPageClient.tsx +++ b/apps/website/app/teams/[id]/TeamDetailPageClient.tsx @@ -36,13 +36,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) { alert('Remove member functionality would be implemented here'); }; - const handleChangeRole = (driverId: string, newRole: 'owner' | 'admin' | 'member') => { - // This would call an API to change the role - console.log('Change role:', driverId, newRole); - // In a real implementation, you'd have a mutation hook here - alert('Change role functionality would be implemented here'); - }; - const handleGoBack = () => { router.back(); }; @@ -55,7 +48,6 @@ export function TeamDetailPageClient({ viewData }: TeamDetailPageClientProps) { onTabChange={handleTabChange} onUpdate={handleUpdate} onRemoveMember={handleRemoveMember} - onChangeRole={handleChangeRole} onGoBack={handleGoBack} /> ); diff --git a/apps/website/app/teams/leaderboard/page.tsx b/apps/website/app/teams/leaderboard/page.tsx index 8b0c873a5..8a054843f 100644 --- a/apps/website/app/teams/leaderboard/page.tsx +++ b/apps/website/app/teams/leaderboard/page.tsx @@ -1,56 +1,15 @@ -import { PageWrapper } from '@/components/shared/state/PageWrapper'; -import { TeamService } from '@/lib/services/teams/TeamService'; -import { Trophy } from 'lucide-react'; -import { redirect } from 'next/navigation'; -import { routes } from '@/lib/routing/RouteConfig'; +import { notFound } from 'next/navigation'; +import { TeamLeaderboardPageQuery } from '@/lib/page-queries/TeamLeaderboardPageQuery'; import { TeamLeaderboardPageWrapper } from './TeamLeaderboardPageWrapper'; -import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; - -// ============================================================================ -// MAIN PAGE COMPONENT -// ============================================================================ export default async function TeamLeaderboardPage() { - // Manual wiring: create dependencies - const service = new TeamService(); + const query = new TeamLeaderboardPageQuery(); + const result = await query.execute(); - // Fetch data through service - const result = await service.getAllTeams(); - - // Handle result - let data = null; - let error = null; - - if (result.isOk()) { - data = result.unwrap().map(t => new TeamSummaryViewModel(t)); - } else { - const domainError = result.getError(); - error = new Error(domainError.message); + if (result.isErr()) { + notFound(); } - const hasData = (data?.length ?? 0) > 0; - - // Handle loading state (should be fast since we're using async/await) - const isLoading = false; - const retry = () => { - // In server components, we can't retry without a reload - redirect(routes.team.detail('leaderboard')); - }; - - return ( - - ); + const data = result.unwrap(); + return ; } diff --git a/apps/website/components/actions/ActionFiltersBar.tsx b/apps/website/components/actions/ActionFiltersBar.tsx new file mode 100644 index 000000000..9e3f8477c --- /dev/null +++ b/apps/website/components/actions/ActionFiltersBar.tsx @@ -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 ( + + + Filter: + {}} + fullWidth={false} + /> + + + + + + ); +} diff --git a/apps/website/components/actions/ActionList.tsx b/apps/website/components/actions/ActionList.tsx new file mode 100644 index 000000000..5025e60be --- /dev/null +++ b/apps/website/components/actions/ActionList.tsx @@ -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 ( + + + + Timestamp + Type + Initiator + Status + Details + + + + {actions.map((action) => ( + + + {action.timestamp} + + + {action.type} + + + {action.initiator} + + + + + + + {action.details} + + + + ))} + +
+ ); +} diff --git a/apps/website/components/actions/ActionStatusBadge.tsx b/apps/website/components/actions/ActionStatusBadge.tsx new file mode 100644 index 000000000..818fd00f2 --- /dev/null +++ b/apps/website/components/actions/ActionStatusBadge.tsx @@ -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 ( + + + {status.replace('_', ' ')} + + + ); +} diff --git a/apps/website/components/actions/ActionsHeader.tsx b/apps/website/components/actions/ActionsHeader.tsx new file mode 100644 index 000000000..02792c91d --- /dev/null +++ b/apps/website/components/actions/ActionsHeader.tsx @@ -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 ( + + + + + {title} + + + + + + + ); +} diff --git a/apps/website/components/admin/AdminDangerZonePanel.tsx b/apps/website/components/admin/AdminDangerZonePanel.tsx new file mode 100644 index 000000000..17d3506e5 --- /dev/null +++ b/apps/website/components/admin/AdminDangerZonePanel.tsx @@ -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 ( + + + + + {title} + + + {description} + + + + {children} + + + + ); +} diff --git a/apps/website/components/admin/AdminDataTable.tsx b/apps/website/components/admin/AdminDataTable.tsx new file mode 100644 index 000000000..c298b7624 --- /dev/null +++ b/apps/website/components/admin/AdminDataTable.tsx @@ -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 ( + + + {children} + + + ); +} diff --git a/apps/website/components/admin/AdminEmptyState.tsx b/apps/website/components/admin/AdminEmptyState.tsx new file mode 100644 index 000000000..8716a5a78 --- /dev/null +++ b/apps/website/components/admin/AdminEmptyState.tsx @@ -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 ( + + + + + {title} + + {description && ( + + {description} + + )} + + {action && ( + + {action} + + )} + + ); +} diff --git a/apps/website/components/admin/AdminHeaderPanel.tsx b/apps/website/components/admin/AdminHeaderPanel.tsx new file mode 100644 index 000000000..3150b6a98 --- /dev/null +++ b/apps/website/components/admin/AdminHeaderPanel.tsx @@ -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 ( + + + + + {title} + + {description && ( + + {description} + + )} + + {actions && ( + + {actions} + + )} + + + + + + ); +} diff --git a/apps/website/components/admin/AdminSectionHeader.tsx b/apps/website/components/admin/AdminSectionHeader.tsx new file mode 100644 index 000000000..6158dad9c --- /dev/null +++ b/apps/website/components/admin/AdminSectionHeader.tsx @@ -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 ( + + + + {title} + + {description && ( + + {description} + + )} + + {actions && ( + + {actions} + + )} + + ); +} diff --git a/apps/website/components/admin/AdminStatsPanel.tsx b/apps/website/components/admin/AdminStatsPanel.tsx new file mode 100644 index 000000000..f1d72b7ad --- /dev/null +++ b/apps/website/components/admin/AdminStatsPanel.tsx @@ -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 ( + + {stats.map((stat, index) => ( + + ))} + + ); +} diff --git a/apps/website/components/admin/AdminToolbar.tsx b/apps/website/components/admin/AdminToolbar.tsx new file mode 100644 index 000000000..f01457f49 --- /dev/null +++ b/apps/website/components/admin/AdminToolbar.tsx @@ -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 ( + + + {leftContent && ( + + {leftContent} + + )} + + {children} + + + + ); +} diff --git a/apps/website/components/admin/AdminUsersTable.tsx b/apps/website/components/admin/AdminUsersTable.tsx new file mode 100644 index 000000000..071728a01 --- /dev/null +++ b/apps/website/components/admin/AdminUsersTable.tsx @@ -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 ( + + + + + + + User + Roles + Status + Last Login + Actions + + + + {users.map((user) => ( + + + onSelectUser(user.id)} + aria-label={`Select user ${user.displayName}`} + /> + + + + + + + + + {user.displayName} + + + {user.email} + + + + + + + {user.roles.map((role) => ( + + + {role} + + + ))} + + + + + + + + {user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'} + + + + + {user.status === 'active' ? ( + + ) : user.status === 'suspended' ? ( + + ) : null} + + + + + + + + ))} + +
+ ); +} diff --git a/apps/website/components/admin/BulkActionBar.tsx b/apps/website/components/admin/BulkActionBar.tsx new file mode 100644 index 000000000..034a63ada --- /dev/null +++ b/apps/website/components/admin/BulkActionBar.tsx @@ -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 ( + + {selectedCount > 0 && ( + + + + + + {selectedCount} + + + + Items Selected + + + + + + + {actions.map((action) => ( + + ))} + + + + + )} + + ); +} diff --git a/apps/website/components/admin/UserFilters.tsx b/apps/website/components/admin/UserFilters.tsx index ae7b83ce8..de355865e 100644 --- a/apps/website/components/admin/UserFilters.tsx +++ b/apps/website/components/admin/UserFilters.tsx @@ -2,14 +2,13 @@ import React from 'react'; import { Filter, Search } from 'lucide-react'; -import { Card } from '@/ui/Card'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Button } from '@/ui/Button'; -import { Grid } from '@/ui/Grid'; import { Icon } from '@/ui/Icon'; import { Input } from '@/ui/Input'; import { Select } from '@/ui/Select'; +import { AdminToolbar } from './AdminToolbar'; interface UserFiltersProps { search: string; @@ -31,13 +30,11 @@ export function UserFilters({ onClearFilters, }: UserFiltersProps) { return ( - - - - - - Filters - + + + Filters {(search || roleFilter || statusFilter) && ( +
+ + + {bio && ( + + + {bio} + + + )} + + + + ); +} diff --git a/apps/website/components/drivers/DriverProfileTabs.tsx b/apps/website/components/drivers/DriverProfileTabs.tsx new file mode 100644 index 000000000..74d55a81f --- /dev/null +++ b/apps/website/components/drivers/DriverProfileTabs.tsx @@ -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 ( + + {tabs.map((tab) => { + const isActive = activeTab === tab.id; + const Icon = tab.icon; + + return ( + 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'} + > + + + {tab.label} + + + {isActive && ( + + )} + + ); + })} + + ); +} diff --git a/apps/website/components/drivers/DriverRacingProfile.tsx b/apps/website/components/drivers/DriverRacingProfile.tsx new file mode 100644 index 000000000..49f756e8a --- /dev/null +++ b/apps/website/components/drivers/DriverRacingProfile.tsx @@ -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 ( + + + Racing Profile + + {lookingForTeam && ( + + + Looking for Team + + )} + {openToRequests && ( + + + Open to Requests + + )} + + + + + {details.map((detail, index) => { + const Icon = detail.icon; + return ( + + + + + + + {detail.label} + + + {detail.value} + + + + ); + })} + + + ); +} diff --git a/apps/website/components/drivers/DriverSearchBar.tsx b/apps/website/components/drivers/DriverSearchBar.tsx new file mode 100644 index 000000000..e69fe85ef --- /dev/null +++ b/apps/website/components/drivers/DriverSearchBar.tsx @@ -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 ( + + onChange(e.target.value)} + placeholder="Search drivers by name or nationality..." + icon={} + /> + + ); +} diff --git a/apps/website/components/drivers/DriverStatsPanel.tsx b/apps/website/components/drivers/DriverStatsPanel.tsx new file mode 100644 index 000000000..64c4be1f1 --- /dev/null +++ b/apps/website/components/drivers/DriverStatsPanel.tsx @@ -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 ( + + {stats.map((stat, index) => ( + + + {stat.label} + + + + {stat.value} + + {stat.subValue && ( + + {stat.subValue} + + )} + + + ))} + + ); +} diff --git a/apps/website/components/drivers/DriverTable.tsx b/apps/website/components/drivers/DriverTable.tsx new file mode 100644 index 000000000..73357791c --- /dev/null +++ b/apps/website/components/drivers/DriverTable.tsx @@ -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 ( + + + + + + + Driver Rankings + Top performers by skill rating + + + + + + + + # + Driver + Nationality + Rating + Wins + + + + {children} + + + + + ); +} diff --git a/apps/website/components/drivers/DriverTableRow.tsx b/apps/website/components/drivers/DriverTableRow.tsx new file mode 100644 index 000000000..9fd2c5a72 --- /dev/null +++ b/apps/website/components/drivers/DriverTableRow.tsx @@ -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 ( + + + + {rank} + + + + + + {name} + + + {name} + + + + + {nationality} + + + + + + + {wins} + + + + ); +} diff --git a/apps/website/components/drivers/DriversDirectoryHeader.tsx b/apps/website/components/drivers/DriversDirectoryHeader.tsx new file mode 100644 index 000000000..b47a5b993 --- /dev/null +++ b/apps/website/components/drivers/DriversDirectoryHeader.tsx @@ -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 ( + + {/* Background Accents */} + + + + + + + + + + Drivers + + + + Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid. + + + + {stats.map((stat, index) => ( + + + + {stat.value} {stat.label} + + + ))} + + + + + + + See full driver rankings + + + + + ); +} diff --git a/apps/website/components/drivers/SafetyRatingBadge.tsx b/apps/website/components/drivers/SafetyRatingBadge.tsx new file mode 100644 index 000000000..784dc418a --- /dev/null +++ b/apps/website/components/drivers/SafetyRatingBadge.tsx @@ -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 ( + + + + SR {rating.toFixed(0)} + + + ); +} diff --git a/apps/website/components/errors/AppErrorBoundaryView.tsx b/apps/website/components/errors/AppErrorBoundaryView.tsx new file mode 100644 index 000000000..a530f2d55 --- /dev/null +++ b/apps/website/components/errors/AppErrorBoundaryView.tsx @@ -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 ( + + {/* Header Icon */} + + + + + {/* Typography */} + + + + {title} + + + + {description} + + + + {children} + + ); +} diff --git a/apps/website/components/errors/ErrorDetails.tsx b/apps/website/components/errors/ErrorDetails.tsx new file mode 100644 index 000000000..7cfd3eb20 --- /dev/null +++ b/apps/website/components/errors/ErrorDetails.tsx @@ -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 ( + + setShowDetails(!showDetails)} + display="flex" + alignItems="center" + justifyContent="center" + gap={2} + color="text-gray-500" + hoverTextColor="text-gray-300" + transition + > + + + {showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'} + + {showDetails ? : } + + + {showDetails && ( + + + + {error.stack || 'No stack trace available'} + {error.digest && `\n\nDigest: ${error.digest}`} + + + + + + + + )} + + ); +} diff --git a/apps/website/components/errors/ErrorDetailsBlock.tsx b/apps/website/components/errors/ErrorDetailsBlock.tsx new file mode 100644 index 000000000..d9c7dc93e --- /dev/null +++ b/apps/website/components/errors/ErrorDetailsBlock.tsx @@ -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 ( + + setShowDetails(!showDetails)} + display="flex" + alignItems="center" + justifyContent="center" + gap={2} + transition + > + + {showDetails ? : } + {showDetails ? 'Hide Technical Logs' : 'Show Technical Logs'} + + + + {showDetails && ( + + + + {error.stack || 'No stack trace available'} + {error.digest && `\n\nDigest: ${error.digest}`} + + + + + + + + )} + + ); +} diff --git a/apps/website/components/errors/ErrorRecoveryActions.tsx b/apps/website/components/errors/ErrorRecoveryActions.tsx new file mode 100644 index 000000000..d8b04e673 --- /dev/null +++ b/apps/website/components/errors/ErrorRecoveryActions.tsx @@ -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 ( + + + + + ); +} diff --git a/apps/website/components/errors/ErrorScreen.test.tsx b/apps/website/components/errors/ErrorScreen.test.tsx new file mode 100644 index 000000000..773f827e5 --- /dev/null +++ b/apps/website/components/errors/ErrorScreen.test.tsx @@ -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(); + + expect(screen.getByText('System Malfunction')).toBeDefined(); + expect(screen.getByText('Test error message')).toBeDefined(); + }); + + it('calls reset when Retry Session is clicked', () => { + render(); + + const button = screen.getByText('Retry Session'); + fireEvent.click(button); + + expect(mockReset).toHaveBeenCalledTimes(1); + }); + + it('calls onHome when Return to Pits is clicked', () => { + render(); + + const button = screen.getByText('Return to Pits'); + fireEvent.click(button); + + expect(mockOnHome).toHaveBeenCalledTimes(1); + }); + + it('toggles technical logs visibility', () => { + render(); + + 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(); + }); +}); diff --git a/apps/website/components/errors/ErrorScreen.tsx b/apps/website/components/errors/ErrorScreen.tsx new file mode 100644 index 000000000..84dae6547 --- /dev/null +++ b/apps/website/components/errors/ErrorScreen.tsx @@ -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 ( + + {/* Background Accents */} + + + + + {/* Error Message Summary */} + + + {error.message || 'Unknown execution error'} + + + + + + + + + + ); +} diff --git a/apps/website/components/errors/GlobalErrorScreen.tsx b/apps/website/components/errors/GlobalErrorScreen.tsx new file mode 100644 index 000000000..6e12cae1a --- /dev/null +++ b/apps/website/components/errors/GlobalErrorScreen.tsx @@ -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 ( + + {/* Background Accents - Subtle telemetry vibe */} + + + + {/* System Status Header */} + + + + + + System Fault Detected + + + + + Status: Critical + + + + + + {/* Fault Description */} + + + The application kernel encountered an unrecoverable execution error. + Telemetry has been captured for diagnostic review. + + + + + + {/* Recovery Actions */} + + + + + {/* Footer / Metadata */} + + + GP-CORE-ERR-{error.digest?.substring(0, 8).toUpperCase() || 'UNKNOWN'} + + + + + ); +} + +/** + * SystemStatusPanel + * + * Displays technical fault details in an instrument-grade panel. + */ +function SystemStatusPanel({ error }: { error: Error & { digest?: string } }) { + return ( + + + + + + Fault Log + + + + {error.message || 'Unknown execution fault'} + + {error.digest && ( + + Digest: {error.digest} + + )} + + + ); +} + +/** + * RecoveryActions + * + * Clear, instrument-grade recovery options. + */ +function RecoveryActions({ onRetry, onHome }: { onRetry: () => void; onHome: () => void }) { + return ( + + + + + ); +} diff --git a/apps/website/components/errors/NotFoundActions.tsx b/apps/website/components/errors/NotFoundActions.tsx new file mode 100644 index 000000000..c7fa47626 --- /dev/null +++ b/apps/website/components/errors/NotFoundActions.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Button } from '@/ui/Button'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface NotFoundActionsProps { + primaryLabel: string; + onPrimaryClick: () => void; +} + +/** + * NotFoundActions + * + * Semantic component for the primary actions on the 404 page. + * Follows "Precision Racing Minimal" theme with crisp styling. + */ +export function NotFoundActions({ primaryLabel, onPrimaryClick }: NotFoundActionsProps) { + return ( + + + + + + ); +} diff --git a/apps/website/components/errors/NotFoundCallToAction.tsx b/apps/website/components/errors/NotFoundCallToAction.tsx new file mode 100644 index 000000000..d1792fbd2 --- /dev/null +++ b/apps/website/components/errors/NotFoundCallToAction.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; + +interface NotFoundCallToActionProps { + label: string; + onClick: () => void; +} + +/** + * NotFoundCallToAction + * + * Semantic component for the primary action on the 404 page. + * Follows "Precision Racing Minimal" theme with crisp styling. + */ +export function NotFoundCallToAction({ label, onClick }: NotFoundCallToActionProps) { + return ( + + + + Telemetry connection lost + + + ); +} diff --git a/apps/website/components/errors/NotFoundDiagnostics.tsx b/apps/website/components/errors/NotFoundDiagnostics.tsx new file mode 100644 index 000000000..7e3ca4aa0 --- /dev/null +++ b/apps/website/components/errors/NotFoundDiagnostics.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; + +interface NotFoundDiagnosticsProps { + errorCode: string; +} + +/** + * NotFoundDiagnostics + * + * Semantic component for displaying technical error details. + * Styled as a telemetry status indicator. + */ +export function NotFoundDiagnostics({ errorCode }: NotFoundDiagnosticsProps) { + return ( + + + + {errorCode} + + + + Telemetry connection lost // Sector data unavailable + + + ); +} diff --git a/apps/website/components/errors/NotFoundHelpLinks.tsx b/apps/website/components/errors/NotFoundHelpLinks.tsx new file mode 100644 index 000000000..f5a70fcb8 --- /dev/null +++ b/apps/website/components/errors/NotFoundHelpLinks.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface NotFoundHelpLinksProps { + links: Array<{ label: string; href: string }>; +} + +/** + * NotFoundHelpLinks + * + * Semantic component for secondary navigation on the 404 page. + * Styled as technical metadata links. + */ +export function NotFoundHelpLinks({ links }: NotFoundHelpLinksProps) { + return ( + + {links.map((link, index) => ( + + + + {link.label} + + + {index < links.length - 1 && ( + + )} + + ))} + + ); +} diff --git a/apps/website/components/errors/NotFoundScreen.tsx b/apps/website/components/errors/NotFoundScreen.tsx new file mode 100644 index 000000000..33163980d --- /dev/null +++ b/apps/website/components/errors/NotFoundScreen.tsx @@ -0,0 +1,141 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Glow } from '@/ui/Glow'; +import { NotFoundActions } from './NotFoundActions'; +import { NotFoundHelpLinks } from './NotFoundHelpLinks'; +import { NotFoundDiagnostics } from './NotFoundDiagnostics'; + +interface NotFoundScreenProps { + errorCode: string; + title: string; + message: string; + actionLabel: string; + onActionClick: () => void; +} + +/** + * NotFoundScreen + * + * App-specific semantic component for 404 states. + * Encapsulates the visual representation of the "Off Track" state. + * Redesigned for "Precision Racing Minimal" theme. + */ +export function NotFoundScreen({ + errorCode, + title, + message, + actionLabel, + onActionClick +}: NotFoundScreenProps) { + const helpLinks = [ + { label: 'Support', href: '/support' }, + { label: 'Status', href: '/status' }, + { label: 'Documentation', href: '/docs' }, + ]; + + return ( + + {/* Background Glow Accent */} + + + + + {/* Header Section */} + + + + + {title} + + + + {/* Visual Separator */} + + + + + {/* Message Section */} + + {message} + + + {/* Actions Section */} + + + {/* Footer Section */} + + + + + + + + {/* Subtle Edge Details */} + + + + ); +} diff --git a/apps/website/components/errors/RecoveryActions.tsx b/apps/website/components/errors/RecoveryActions.tsx new file mode 100644 index 000000000..f950ad5bc --- /dev/null +++ b/apps/website/components/errors/RecoveryActions.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import { RefreshCw, Home, LifeBuoy } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; + +interface RecoveryActionsProps { + onRetry: () => void; + onHome: () => void; +} + +/** + * RecoveryActions + * + * Provides primary and secondary recovery paths for the user. + * Part of the 500 route redesign. + */ +export function RecoveryActions({ onRetry, onHome }: RecoveryActionsProps) { + return ( + + + + + + ); +} diff --git a/apps/website/components/errors/ServerErrorPanel.tsx b/apps/website/components/errors/ServerErrorPanel.tsx new file mode 100644 index 000000000..9c80b7a2a --- /dev/null +++ b/apps/website/components/errors/ServerErrorPanel.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface ServerErrorPanelProps { + message?: string; + incidentId?: string; +} + +/** + * ServerErrorPanel + * + * Displays the primary error information in an "instrument-grade" style. + * Part of the 500 route redesign. + */ +export function ServerErrorPanel({ message, incidentId }: ServerErrorPanelProps) { + return ( + + {/* Status Indicator */} + + + + + {/* Primary Message */} + + + CRITICAL_SYSTEM_FAILURE + + + The application engine encountered an unrecoverable state. + Telemetry has been dispatched to engineering. + + + + {/* Technical Summary */} + + + + STATUS: 500_INTERNAL_SERVER_ERROR + + {message && ( + + EXCEPTION: {message} + + )} + {incidentId && ( + + INCIDENT_ID: {incidentId} + + )} + + + + ); +} diff --git a/apps/website/components/home/HomeFeatureDescription.tsx b/apps/website/components/home/HomeFeatureDescription.tsx new file mode 100644 index 000000000..2db165d6e --- /dev/null +++ b/apps/website/components/home/HomeFeatureDescription.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; + +interface HomeFeatureDescriptionProps { + lead: string; + items: string[]; + quote?: string; + accentColor?: 'primary' | 'aqua' | 'amber' | 'gray'; +} + +/** + * HomeFeatureDescription - A semantic component for feature descriptions on the home page. + * Refactored to use semantic HTML and Tailwind. + */ +export function HomeFeatureDescription({ + lead, + items, + quote, + accentColor = 'primary', +}: HomeFeatureDescriptionProps) { + const borderColor = { + primary: 'primary-accent', + aqua: 'telemetry-aqua', + amber: 'warning-amber', + gray: 'border-gray', + }[accentColor]; + + const bgColor = { + primary: 'primary-accent/5', + aqua: 'telemetry-aqua/5', + amber: 'warning-amber/5', + gray: 'white/5', + }[accentColor]; + + return ( + + + {lead} + + + {items.map((item, index) => ( + + + {item} + + ))} + + {quote && ( + + + {quote} + + + )} + + ); +} diff --git a/apps/website/components/home/HomeFeatureSection.tsx b/apps/website/components/home/HomeFeatureSection.tsx new file mode 100644 index 000000000..e10031506 --- /dev/null +++ b/apps/website/components/home/HomeFeatureSection.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { Panel } from '@/ui/Panel'; +import { Glow } from '@/ui/Glow'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; +import { Section } from '@/ui/Section'; + +interface HomeFeatureSectionProps { + heading: string; + description: React.ReactNode; + mockup: React.ReactNode; + layout: 'text-left' | 'text-right'; + accentColor?: 'primary' | 'aqua' | 'amber'; +} + +/** + * HomeFeatureSection - A semantic section highlighting a feature. + * Refactored to use semantic HTML and Tailwind. + */ +export function HomeFeatureSection({ + heading, + description, + mockup, + layout, + accentColor = 'primary', +}: HomeFeatureSectionProps) { + const accentBorderColor = { + primary: 'primary-accent/40', + aqua: 'telemetry-aqua/40', + amber: 'warning-amber/40', + }[accentColor]; + + const accentBgColor = { + primary: 'primary-accent', + aqua: 'telemetry-aqua', + amber: 'warning-amber', + }[accentColor]; + + const glowColor = ({ + primary: 'primary', + aqua: 'aqua', + amber: 'amber', + }[accentColor] || 'primary') as 'primary' | 'aqua' | 'amber' | 'purple'; + + return ( +
+ + + + + {/* Text Content */} + + + + + {heading} + + + + {description} + + + + {/* Mockup Panel */} + + + + {mockup} + + {/* Decorative corner accents */} + + + + + + +
+ ); +} diff --git a/apps/website/ui/DiscordCTA.tsx b/apps/website/components/home/HomeFooterCTA.tsx similarity index 64% rename from apps/website/ui/DiscordCTA.tsx rename to apps/website/components/home/HomeFooterCTA.tsx index 65571cab9..16c78ce8c 100644 --- a/apps/website/ui/DiscordCTA.tsx +++ b/apps/website/components/home/HomeFooterCTA.tsx @@ -1,80 +1,45 @@ +'use client'; - -import { Box } from '@/ui/Box'; +import React from 'react'; import { Button } from '@/ui/Button'; -import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; import { Glow } from '@/ui/Glow'; import { Icon } from '@/ui/Icon'; import { DiscordIcon } from '@/ui/icons/DiscordIcon'; -import { Stack } from '@/ui/Stack'; -import { Surface } from '@/ui/Surface'; -import { Text } from '@/ui/Text'; -import { Grid } from '@/ui/Grid'; import { Code, Lightbulb, LucideIcon, MessageSquare, Users } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Section } from '@/ui/Section'; +import { Stack } from '@/ui/Stack'; +import { Grid } from '@/ui/Grid'; -export function DiscordCTA() { +export function HomeFooterCTA() { const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; return ( - +
- - + + {/* Discord brand accent */} - + {/* Header */} - + - + Join the Grid on Discord - + @@ -82,7 +47,7 @@ export function DiscordCTA() { - GridPilot is a solo developer project built for the community. + GridPilot is a solo developer project built for the community. We are in early alpha. Join us to help shape the future of motorsport infrastructure. Your feedback directly influences the roadmap. @@ -91,11 +56,7 @@ export function DiscordCTA() { {/* Benefits grid */} - + } > Join Discord @@ -142,41 +105,22 @@ export function DiscordCTA() { - + - +
); } function BenefitItem({ icon, title, description }: { icon: LucideIcon, title: string, description: string }) { return ( - - + + - {title} + {title} {description} - +
); } diff --git a/apps/website/components/home/HomeHeader.tsx b/apps/website/components/home/HomeHeader.tsx new file mode 100644 index 000000000..ee8aa0c59 --- /dev/null +++ b/apps/website/components/home/HomeHeader.tsx @@ -0,0 +1,95 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/ui/Button'; +import { Glow } from '@/ui/Glow'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; + +interface HomeHeaderProps { + title: string; + subtitle: string; + description: string; + primaryAction: { + label: string; + href: string; + }; + secondaryAction: { + label: string; + href: string; + }; +} + +/** + * HomeHeader - Semantic hero section for the landing page. + * Follows "Precision Racing Minimal" theme. + */ +export function HomeHeader({ + title, + subtitle, + description, + primaryAction, + secondaryAction, +}: HomeHeaderProps) { + return ( + + + + + + + + {subtitle} + + + + + {title} + + + + + {description} + + + + + + + + + + + ); +} diff --git a/apps/website/components/home/HomeStatsStrip.tsx b/apps/website/components/home/HomeStatsStrip.tsx new file mode 100644 index 000000000..a18ee3fdb --- /dev/null +++ b/apps/website/components/home/HomeStatsStrip.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; +import { MetricCard } from '@/ui/MetricCard'; +import { Activity, Users, Trophy, Calendar } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; + +/** + * HomeStatsStrip - A thin strip showing some status or quick info. + * Part of the "Telemetry-workspace" feel. + * Refactored to use semantic HTML and Tailwind. + */ +export function HomeStatsStrip() { + return ( + + + + + + + + + + + + + + + + + ); +} diff --git a/apps/website/components/home/LeagueSummaryPanel.tsx b/apps/website/components/home/LeagueSummaryPanel.tsx new file mode 100644 index 000000000..8f62d6371 --- /dev/null +++ b/apps/website/components/home/LeagueSummaryPanel.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; +import { LeagueCard } from '@/ui/LeagueCard'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Link } from '@/ui/Link'; + +interface League { + id: string; + name: string; + description: string; +} + +interface LeagueSummaryPanelProps { + leagues: League[]; +} + +/** + * LeagueSummaryPanel - Semantic section for featured leagues. + */ +export function LeagueSummaryPanel({ leagues }: LeagueSummaryPanelProps) { + return ( + + + + FEATURED LEAGUES + + + VIEW ALL → + + + + + {leagues.slice(0, 2).map((league) => ( + + ))} + + + ); +} diff --git a/apps/website/components/home/QuickLinksPanel.tsx b/apps/website/components/home/QuickLinksPanel.tsx new file mode 100644 index 000000000..46f2a6b9f --- /dev/null +++ b/apps/website/components/home/QuickLinksPanel.tsx @@ -0,0 +1,60 @@ +'use client'; + +import React from 'react'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Plus, Search, Shield, Users } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Text } from '@/ui/Text'; + +/** + * QuickLinksPanel - Semantic quick actions bar. + * Replaces HomeQuickActions with a more semantic implementation. + */ +export function QuickLinksPanel() { + const links = [ + { label: 'Find League', icon: Search, href: routes.public.leagues }, + { label: 'Join Team', icon: Users, href: routes.public.teams }, + { label: 'Create Race', icon: Plus, href: routes.protected.dashboard }, + { label: 'Rulebooks', icon: Shield, href: '#' }, + ]; + + return ( + + + + {links.map((link) => ( + + ))} + + + + ); +} diff --git a/apps/website/components/home/RecentRacesPanel.tsx b/apps/website/components/home/RecentRacesPanel.tsx new file mode 100644 index 000000000..19635d086 --- /dev/null +++ b/apps/website/components/home/RecentRacesPanel.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React from 'react'; +import { UpcomingRaceItem } from '@/ui/UpcomingRaceItem'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Link } from '@/ui/Link'; +import { Text } from '@/ui/Text'; + +interface Race { + id: string; + track: string; + car: string; + formattedDate: string; +} + +interface RecentRacesPanelProps { + races: Race[]; +} + +/** + * RecentRacesPanel - Semantic section for upcoming/recent races. + */ +export function RecentRacesPanel({ races }: RecentRacesPanelProps) { + return ( + + + + UPCOMING RACES + + + FULL SCHEDULE → + + + + + {races.length === 0 ? ( + + + No races scheduled + + + ) : ( + races.slice(0, 3).map((race) => ( + + )) + )} + + + ); +} diff --git a/apps/website/components/home/TeamSummaryPanel.tsx b/apps/website/components/home/TeamSummaryPanel.tsx new file mode 100644 index 000000000..0513366f5 --- /dev/null +++ b/apps/website/components/home/TeamSummaryPanel.tsx @@ -0,0 +1,58 @@ +'use client'; + +import React from 'react'; +import { TeamCard } from '@/ui/TeamCard'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Link } from '@/ui/Link'; + +interface Team { + id: string; + name: string; + description: string; + logoUrl?: string; +} + +interface TeamSummaryPanelProps { + teams: Team[]; +} + +/** + * TeamSummaryPanel - Semantic section for teams. + */ +export function TeamSummaryPanel({ teams }: TeamSummaryPanelProps) { + return ( + + + + TEAMS ON THE GRID + + + BROWSE TEAMS → + + + + + {teams.slice(0, 2).map((team) => ( + + ))} + + + ); +} diff --git a/apps/website/components/landing/AlternatingSection.tsx b/apps/website/components/landing/AlternatingSection.tsx index 645c8b1ec..fbaee1ac1 100644 --- a/apps/website/components/landing/AlternatingSection.tsx +++ b/apps/website/components/landing/AlternatingSection.tsx @@ -93,12 +93,12 @@ export function AlternatingSection({ order={{ lg: layout === 'text-right' ? 2 : 1 }} > - - + + {heading} - + {typeof description === 'string' ? ( {description} ) : ( @@ -111,18 +111,18 @@ export function AlternatingSection({ {mockup} {/* Decorative corner accents */} - - + + diff --git a/apps/website/components/landing/FeatureGrid.tsx b/apps/website/components/landing/FeatureGrid.tsx index 596ef8630..f6324a17c 100644 --- a/apps/website/components/landing/FeatureGrid.tsx +++ b/apps/website/components/landing/FeatureGrid.tsx @@ -53,21 +53,21 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n display="flex" flexDirection="column" gap={6} - className="p-8 bg-panel-gray/40 border border-border-gray/50 rounded-none hover:border-primary-accent/30 transition-all duration-300 ease-smooth group relative overflow-hidden" + className="p-8 bg-panel-gray/20 border border-border-gray/20 rounded-none hover:border-primary-accent/20 transition-all duration-300 ease-smooth group relative overflow-hidden" > - + - - + + {feature.title} - + {feature.description} @@ -77,7 +77,7 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n bottom="0" left="0" w="full" - h="1" + h="0.5" bg="primary-accent" className="scale-x-0 group-hover:scale-x-100 transition-transform duration-500 origin-left" /> @@ -87,24 +87,24 @@ function FeatureCard({ feature, index }: { feature: typeof features[0], index: n export function FeatureGrid() { return ( -
+
- - + + Engineered for Competition - + Building for League Racing - + Every feature is designed to reduce friction and increase immersion. Join our Discord to help shape the future of the platform. - + {features.map((feature, index) => ( ))} diff --git a/apps/website/components/landing/LandingHero.tsx b/apps/website/components/landing/LandingHero.tsx index dd169cead..39c273d12 100644 --- a/apps/website/components/landing/LandingHero.tsx +++ b/apps/website/components/landing/LandingHero.tsx @@ -29,11 +29,11 @@ export function LandingHero() { {/* Robust gradient overlay */} @@ -47,29 +47,30 @@ export function LandingHero() { position="absolute" inset="0" bg="radial-gradient(circle at center, transparent 0%, #0C0D0F 100%)" - opacity={0.6} + opacity={0.8} /> - + - - + + Precision Racing Infrastructure - Modern Motorsport
- Infrastructure. + MODERN MOTORSPORT INFRASTRUCTURE.
- + GridPilot gives your league racing a real home. Results, standings, teams, and career progression — engineered for precision and control.
@@ -80,14 +81,23 @@ export function LandingHero() { href={discordUrl} variant="primary" size="lg" - className="px-10 rounded-none uppercase tracking-widest text-xs font-bold" + px={12} + letterSpacing="0.2em" + fontSize="xs" + h="14" > Join the Grid @@ -99,7 +109,11 @@ export function LandingHero() { gridCols={{ base: 1, sm: 2, lg: 4 }} gap={8} mt={12} - className="border-t border-border-gray/30 pt-12" + borderTop + borderStyle="solid" + borderColor="border-gray" + opacity={0.2} + pt={12} > {[ { label: 'IDENTITY', text: 'Your racing career in one place', color: 'primary' }, @@ -107,14 +121,14 @@ export function LandingHero() { { label: 'PRECISION', text: 'Real-time results and standings', color: 'amber' }, { label: 'COMMUNITY', text: 'Built for teams and leagues', color: 'green' } ].map((item) => ( - - - - + + + + {item.label} - + {item.text} diff --git a/apps/website/components/layout/HeaderContent.tsx b/apps/website/components/layout/HeaderContent.tsx deleted file mode 100644 index 43e49aabb..000000000 --- a/apps/website/components/layout/HeaderContent.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import Image from 'next/image'; -import Link from 'next/link'; -import { Text } from '@/ui/Text'; - -export function HeaderContent() { - return ( -
-
- -
- GridPilot -
-
- -
-
- - Motorsport Infrastructure - -
-
- -
-
- Status: - Operational -
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/leaderboards/DeltaChip.tsx b/apps/website/components/leaderboards/DeltaChip.tsx new file mode 100644 index 000000000..3988a209a --- /dev/null +++ b/apps/website/components/leaderboards/DeltaChip.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { ChevronUp, ChevronDown, Minus } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; + +interface DeltaChipProps { + value: number; + type?: 'rank' | 'rating'; +} + +export function DeltaChip({ value, type = 'rank' }: DeltaChipProps) { + if (value === 0) { + return ( + + + 0 + + ); + } + + const isPositive = value > 0; + const color = isPositive + ? (type === 'rank' ? 'text-performance-green' : 'text-performance-green') + : (type === 'rank' ? 'text-error-red' : 'text-error-red'); + + // For rank, positive delta usually means dropping positions (e.g. +1 rank means 1st -> 2nd) + // But usually "Delta" in leaderboards means "positions gained/lost" + // Let's assume value is "positions gained" (positive = up, negative = down) + + const IconComponent = isPositive ? ChevronUp : ChevronDown; + const absoluteValue = Math.abs(value); + + return ( + + + + {absoluteValue} + + + ); +} diff --git a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx index dac0283ff..3765f6f5b 100644 --- a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react'; +import { Trophy, ChevronRight } from 'lucide-react'; import { Button } from '@/ui/Button'; import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; @@ -8,8 +8,9 @@ import { Icon } from '@/ui/Icon'; import { Stack } from '@/ui/Stack'; import { Image } from '@/ui/Image'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; -import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; +import { RankMedal } from './RankMedal'; +import { LeaderboardTableShell } from './LeaderboardTableShell'; interface DriverLeaderboardPreviewProps { drivers: { @@ -31,35 +32,54 @@ export function DriverLeaderboardPreview({ drivers, onDriverClick, onNavigateToD const top10 = drivers; // Already sliced in builder return ( - - + + - + - Driver Rankings - Top performers across all leagues + Driver Rankings + Top Performers - + {top10.map((driver, index) => { const position = index + 1; + const isLast = index === top10.length - 1; return ( - - {position <= 3 ? : {position}} + + - + {driver.name} - + {driver.name} - {driver.nationality} - - {SkillLevelDisplay.getLabel(driver.skillLevel)} + + + {SkillLevelDisplay.getLabel(driver.skillLevel)} - - - {RatingDisplay.format(driver.rating)} - - Rating - + + + {RatingDisplay.format(driver.rating)} + Rating - - {driver.wins} - - Wins - + + {driver.wins} + Wins ); })} - + ); -} \ No newline at end of file +} diff --git a/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx b/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx new file mode 100644 index 000000000..faa7ad9e2 --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardFiltersBar.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Search, Filter } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { Text } from '@/ui/Text'; + +interface LeaderboardFiltersBarProps { + searchQuery?: string; + onSearchChange?: (query: string) => void; + placeholder?: string; + children?: React.ReactNode; +} + +export function LeaderboardFiltersBar({ + searchQuery, + onSearchChange, + placeholder = 'Search drivers...', + children, +}: LeaderboardFiltersBarProps) { + return ( + + + + + + + ) => onSearchChange?.(e.target.value)} + placeholder={placeholder} + w="full" + bg="bg-graphite-black/50" + border + borderColor="border-charcoal-outline" + rounded="md" + py={2} + pl={10} + pr={4} + fontSize="0.875rem" + color="text-white" + transition + hoverBorderColor="border-primary-blue/50" + /> + + + + {children} + + + Filters + + + + + ); +} diff --git a/apps/website/components/leaderboards/LeaderboardHeader.tsx b/apps/website/components/leaderboards/LeaderboardHeader.tsx new file mode 100644 index 000000000..f1469ce72 --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardHeader.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { ArrowLeft, LucideIcon } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; + +interface LeaderboardHeaderProps { + title: string; + description?: string; + icon?: LucideIcon; + onBack?: () => void; + backLabel?: string; + children?: React.ReactNode; +} + +export function LeaderboardHeader({ + title, + description, + icon, + onBack, + backLabel = 'Back', + children, +}: LeaderboardHeaderProps) { + return ( + + {onBack && ( + + + + )} + + + + {icon && ( + + + + )} + + {title} + {description && ( + + {description} + + )} + + + + {children} + + + + ); +} diff --git a/apps/website/components/leaderboards/LeaderboardHeaderPanel.tsx b/apps/website/components/leaderboards/LeaderboardHeaderPanel.tsx new file mode 100644 index 000000000..fbf6597c0 --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardHeaderPanel.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { ArrowLeft, LucideIcon } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface LeaderboardHeaderPanelProps { + title: string; + description?: string; + icon?: LucideIcon; + onBack?: () => void; + backLabel?: string; + children?: React.ReactNode; +} + +export function LeaderboardHeaderPanel({ + title, + description, + icon, + onBack, + backLabel = 'Back', + children, +}: LeaderboardHeaderPanelProps) { + return ( + + {onBack && ( + + + + )} + + + + {icon && ( + + + + )} + + {title} + {description && ( + + {description} + + )} + + + {children} + + + ); +} diff --git a/apps/website/components/leaderboards/LeaderboardItem.tsx b/apps/website/components/leaderboards/LeaderboardItem.tsx deleted file mode 100644 index 3d238f8e0..000000000 --- a/apps/website/components/leaderboards/LeaderboardItem.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react'; -import { Crown, Flag } from 'lucide-react'; -import { Box } from '@/ui/Box'; -import { Text } from '@/ui/Text'; -import { Stack } from '@/ui/Stack'; -import { Image } from '@/ui/Image'; -import { mediaConfig } from '@/lib/config/mediaConfig'; - -interface LeaderboardItemProps { - position: number; - name: string; - avatarUrl?: string; - nationality: string; - rating: number; - wins: number; - skillLevelLabel?: string; - skillLevelColor?: string; - categoryLabel?: string; - categoryColor?: string; - onClick: () => void; -} - -export function LeaderboardItem({ - position, - name, - avatarUrl, - nationality, - rating, - wins, - skillLevelLabel, - skillLevelColor, - categoryLabel, - categoryColor, - onClick, -}: LeaderboardItemProps) { - const getMedalColor = (pos: number) => { - switch (pos) { - case 1: return 'text-yellow-400'; - case 2: return 'text-gray-300'; - case 3: return 'text-amber-600'; - default: return 'text-gray-500'; - } - }; - - const getMedalBg = (pos: number) => { - switch (pos) { - case 1: return 'bg-yellow-400/10 border-yellow-400/30'; - case 2: return 'bg-gray-300/10 border-gray-300/30'; - case 3: return 'bg-amber-600/10 border-amber-600/30'; - default: return 'bg-iron-gray/50 border-charcoal-outline'; - } - }; - - return ( - - {/* Position */} - - {position <= 3 ? : position} - - - {/* Avatar */} - - {name} - - - {/* Info */} - - - {name} - - - - {nationality} - {categoryLabel && ( - {categoryLabel} - )} - {skillLevelLabel && ( - {skillLevelLabel} - )} - - - - {/* Stats */} - - - {rating.toLocaleString()} - Rating - - - {wins} - Wins - - - - ); -} diff --git a/apps/website/components/leaderboards/LeaderboardPodium.tsx b/apps/website/components/leaderboards/LeaderboardPodium.tsx new file mode 100644 index 000000000..10ab13c0e --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardPodium.tsx @@ -0,0 +1,173 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Image } from '@/ui/Image'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; +import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; + +interface PodiumDriver { + id: string; + name: string; + avatarUrl: string; + rating: number; + wins: number; + podiums: number; +} + +interface LeaderboardPodiumProps { + podium: PodiumDriver[]; + onDriverClick?: (id: string) => void; +} + +export function LeaderboardPodium({ podium, onDriverClick }: LeaderboardPodiumProps) { + // Order: 2nd, 1st, 3rd + const displayOrder = [1, 0, 2]; + + return ( + + + {displayOrder.map((index) => { + const driver = podium[index]; + if (!driver) return ; + + const position = index + 1; + const isFirst = position === 1; + + const config = { + 1: { height: '48', scale: '1.1', zIndex: 10, shadow: 'shadow-warning-amber/20' }, + 2: { height: '36', scale: '1', zIndex: 0, shadow: 'shadow-white/5' }, + 3: { height: '28', scale: '0.9', zIndex: 0, shadow: 'shadow-white/5' }, + }[position as 1 | 2 | 3]; + + return ( + onDriverClick?.(driver.id)} + display="flex" + flexDirection="col" + alignItems="center" + flexGrow={1} + transition + hoverScale + group + shadow={config.shadow} + zIndex={config.zIndex} + > + + + {driver.name} + + + {position} + + + + + {driver.name} + + + + {RatingDisplay.format(driver.rating)} + + + + + Wins + {driver.wins} + + + + Podiums + {driver.podiums} + + + + + + {position} + + + + ); + })} + + + ); +} diff --git a/apps/website/components/leaderboards/LeaderboardPreview.tsx b/apps/website/components/leaderboards/LeaderboardPreview.tsx deleted file mode 100644 index 23cb4dd20..000000000 --- a/apps/website/components/leaderboards/LeaderboardPreview.tsx +++ /dev/null @@ -1,105 +0,0 @@ - - -import { routes } from '@/lib/routing/RouteConfig'; -import { Box } from '@/ui/Box'; -import { Button } from '@/ui/Button'; -import { Heading } from '@/ui/Heading'; -import { Icon } from '@/ui/Icon'; -import { LeaderboardItem } from '@/components/leaderboards/LeaderboardItem'; -import { LeaderboardList } from '@/ui/LeaderboardList'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Award, ChevronRight } from 'lucide-react'; - -const SKILL_LEVELS = [ - { id: 'pro', label: 'Pro', color: 'text-yellow-400' }, - { id: 'advanced', label: 'Advanced', color: 'text-purple-400' }, - { id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' }, - { id: 'beginner', label: 'Beginner', color: 'text-green-400' }, -]; - -const CATEGORIES = [ - { id: 'beginner', label: 'Beginner', color: 'text-green-400' }, - { id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue' }, - { id: 'advanced', label: 'Advanced', color: 'text-purple-400' }, - { id: 'pro', label: 'Pro', color: 'text-yellow-400' }, - { id: 'endurance', label: 'Endurance', color: 'text-orange-400' }, - { id: 'sprint', label: 'Sprint', color: 'text-red-400' }, -]; - -interface LeaderboardPreviewProps { - drivers: { - id: string; - name: string; - avatarUrl?: string; - nationality: string; - rating: number; - wins: number; - skillLevel?: string; - category?: string; - }[]; - onDriverClick: (id: string) => void; - onNavigate: (href: string) => void; -} - -export function LeaderboardPreview({ drivers, onDriverClick, onNavigate }: LeaderboardPreviewProps) { - const top5 = drivers.slice(0, 5); - - return ( - - - - - - - - Top Drivers - Highest rated competitors - - - - - - - - {top5.map((driver, index) => { - const levelConfig = SKILL_LEVELS.find((l) => l.id === driver.skillLevel); - const categoryConfig = CATEGORIES.find((c) => c.id === driver.category); - const position = index + 1; - - return ( - onDriverClick(driver.id)} - /> - ); - })} - - - ); -} diff --git a/apps/website/components/leaderboards/LeaderboardTable.tsx b/apps/website/components/leaderboards/LeaderboardTable.tsx new file mode 100644 index 000000000..2d41c437b --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardTable.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Table, TableBody, TableHead, TableHeader, TableRow } from '@/ui/Table'; +import { RankingRow } from './RankingRow'; +import { LeaderboardTableShell } from './LeaderboardTableShell'; + +interface LeaderboardDriver { + id: string; + name: string; + avatarUrl: string; + rank: number; + rankDelta?: number; + nationality: string; + skillLevel: string; + racesCompleted: number; + rating: number; + wins: number; +} + +interface LeaderboardTableProps { + drivers: LeaderboardDriver[]; + onDriverClick?: (id: string) => void; +} + +export function LeaderboardTable({ drivers, onDriverClick }: LeaderboardTableProps) { + return ( + + + + + Rank + Driver + Races + Rating + Wins + + + + {drivers.map((driver) => ( + onDriverClick?.(driver.id)} + /> + ))} + +
+
+ ); +} diff --git a/apps/website/components/leaderboards/LeaderboardTableShell.tsx b/apps/website/components/leaderboards/LeaderboardTableShell.tsx new file mode 100644 index 000000000..82f42f448 --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardTableShell.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface LeaderboardTableShellProps { + children: React.ReactNode; + isEmpty?: boolean; + emptyMessage?: string; + emptyDescription?: string; +} + +export function LeaderboardTableShell({ + children, + isEmpty, + emptyMessage = 'No data found', + emptyDescription = 'Try adjusting your filters or search query', +}: LeaderboardTableShellProps) { + if (isEmpty) { + return ( + + 🔍 + {emptyMessage} + {emptyDescription} + + ); + } + + return ( + + {children} + + ); +} diff --git a/apps/website/components/leaderboards/LeaderboardsHero.tsx b/apps/website/components/leaderboards/LeaderboardsHero.tsx index 1bb161315..1240d424a 100644 --- a/apps/website/components/leaderboards/LeaderboardsHero.tsx +++ b/apps/website/components/leaderboards/LeaderboardsHero.tsx @@ -25,27 +25,29 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea padding={8} position="relative" overflow="hidden" - bg="bg-gradient-to-br from-yellow-600/20 via-iron-gray to-deep-graphite" - borderColor="border-yellow-500/20" + bg="bg-gradient-to-br from-primary-blue/10 via-deep-charcoal to-graphite-black" + borderColor="border-primary-blue/20" > - - + + - - - + + - Leaderboards - Where champions rise and legends are made + Leaderboards + Precision Performance Tracking @@ -53,25 +55,27 @@ export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: Lea size="lg" color="text-gray-400" block - mb={6} + mb={8} leading="relaxed" maxWidth="42rem" > - Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne? + Track the best drivers and teams across all competitions. Every race counts. Every position matters. Analyze telemetry-grade rankings and performance metrics. - + diff --git a/apps/website/components/leaderboards/RankMedal.tsx b/apps/website/components/leaderboards/RankMedal.tsx new file mode 100644 index 000000000..0264a0ea7 --- /dev/null +++ b/apps/website/components/leaderboards/RankMedal.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { Crown, Medal } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; + +interface RankMedalProps { + rank: number; + size?: 'sm' | 'md' | 'lg'; + showIcon?: boolean; +} + +export function RankMedal({ rank, size = 'md', showIcon = true }: RankMedalProps) { + const isTop3 = rank <= 3; + + const sizeMap = { + sm: '7', + md: '8', + lg: '10', + }; + + const textSizeMap = { + sm: 'xs', + md: 'xs', + lg: 'sm', + } as const; + + const iconSize = { + sm: 3, + md: 3.5, + lg: 4.5, + }; + + return ( + + {isTop3 && showIcon ? ( + + ) : ( + {rank} + )} + + ); +} diff --git a/apps/website/components/leaderboards/RankingRow.tsx b/apps/website/components/leaderboards/RankingRow.tsx new file mode 100644 index 000000000..2cf8e7337 --- /dev/null +++ b/apps/website/components/leaderboards/RankingRow.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Image } from '@/ui/Image'; +import { Stack } from '@/ui/Stack'; +import { TableCell, TableRow } from '@/ui/Table'; +import { Text } from '@/ui/Text'; +import { RankMedal } from './RankMedal'; +import { DeltaChip } from './DeltaChip'; +import { RatingDisplay } from '@/lib/display-objects/RatingDisplay'; + +interface RankingRowProps { + id: string; + rank: number; + rankDelta?: number; + name: string; + avatarUrl: string; + nationality: string; + skillLevel: string; + racesCompleted: number; + rating: number; + wins: number; + onClick?: () => void; +} + +export function RankingRow({ + rank, + rankDelta, + name, + avatarUrl, + nationality, + skillLevel, + racesCompleted, + rating, + wins, + onClick, +}: RankingRowProps) { + return ( + + + + + + + {rankDelta !== undefined && ( + + + + )} + + + + + + + {name} + + + + {name} + + + {nationality} + + {skillLevel} + + + + + + + {racesCompleted} + + + + + {RatingDisplay.format(rating)} + + + + + + {wins} + + + + ); +} diff --git a/apps/website/components/leaderboards/SeasonSelector.tsx b/apps/website/components/leaderboards/SeasonSelector.tsx new file mode 100644 index 000000000..e9920be58 --- /dev/null +++ b/apps/website/components/leaderboards/SeasonSelector.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Select } from '@/ui/Select'; + +interface Season { + id: string; + name: string; + isActive?: boolean; +} + +interface SeasonSelectorProps { + seasons: Season[]; + selectedSeasonId: string; + onSeasonChange: (id: string) => void; +} + +export function SeasonSelector({ seasons, selectedSeasonId, onSeasonChange }: SeasonSelectorProps) { + const options = seasons.map(season => ({ + value: season.id, + label: `${season.name}${season.isActive ? ' (Active)' : ''}` + })); + + return ( + + + + Season + + + onSearchChange(e.target.value)} + icon={} + /> + + + + + onUpdate?.({ favoriteCarClass: e.target.value })} + options={[ + { value: 'GT3', label: 'GT3' }, + { value: 'GT4', label: 'GT4' }, + { value: 'Formula', label: 'Formula' }, + { value: 'LMP2', label: 'LMP2' }, + ]} + /> + onUpdate?.({ country: e.target.value })} + placeholder="e.g. US, GB, DE" + maxLength={2} + /> +