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 */} - - scroll('left')} - disabled={!canScrollLeft} - size="sm" - w="2rem" - h="2rem" - p={0} - > - - - scroll('right')} - disabled={!canScrollRight} - size="sm" - w="2rem" - h="2rem" - p={0} - > - - - - + + + {viewData.leagues.length} + Active Leagues + + + router.push(routes.league.create)} + variant="primary" + size="lg" + > + + + Create League + + + + - {/* 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 ( + setActiveCategory(category.id)} + variant={isActive ? 'primary' : 'secondary'} + size="sm" + > + + + + + {category.label} + + + ); + })} + + + + {/* Grid */} + + {filteredLeagues.length > 0 ? ( + + {filteredLeagues.map((league) => ( + router.push(routes.league.detail(league.id))} + /> + ))} + + ) : ( + + + - ); - })} + No Leagues Found + Try adjusting your search or filters + { setSearchQuery(''); setActiveCategory('all'); }} + > + Clear All 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) */} - - setShowFilters(!showFilters)} - > - - - Filters - - - - - - {/* Category Tabs */} - - - {CATEGORIES.map((category) => { - const count = leaguesByCategory[category.id].length; - const isActive = activeCategory === category.id; - - return ( - setActiveCategory(category.id)} - size="sm" - rounded="full" - > - - - {category.label} - {count > 0 && ( - - {count} - - )} - - - ); - })} - - - - - {/* 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. - - - { router.push(routes.league.create); }} - > - - - Create Your First League - - - - - - ) : 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'} - - { - setSearchQuery(''); - setActiveCategory('all'); - }} - > - Clear filters - - - - - )} - - )} - - ); -} 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 */} + + + + setActiveTab('pending')} + rounded="none" + > + + Pending Protests + {data.totalPending > 0 && ( + + {data.totalPending} + + )} + + - - {/* Stats summary */} - - - {/* Tab navigation */} - - - - setActiveTab('pending')} - rounded="none" - > - - Pending Protests - {data.totalPending > 0 && ( - - {data.totalPending} - - )} - - - - - setActiveTab('history')} - rounded="none" - > - History - - - + + setActiveTab('history')} + rounded="none" + > + History + + + - {/* 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 - - - - - Export - - - setShowWithdrawModal(true)} - disabled={!canWithdraw || !onWithdraw} - > - - - Withdraw - - - + + + + Export + + - {/* 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 - - - - )} - - - setShowWithdrawModal(false)} - fullWidth - > - Cancel - - - {mutationLoading ? 'Processing...' : 'Withdraw'} - - - - - - )} + {}} // 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 - - - Upload livery - - - - - {mockLiveries.map((livery) => ( - - ))} - - - - - Back to profile - - - - ); + 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 + + + + + + + + + + + Cancel + + + Upload Livery + + + + + + + {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. - - Back to liveries - - - - ); + 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. - - Back to profile - - - + ); } 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 && ( - - Set Default - - )} - - Remove - - - - - ); -} - -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} - - - }> - PDF - - - - ); -} - -// ============================================================================ -// 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 && ( - - setShowAllInvoices(!showAllInvoices)} - icon={} - flexDirection="row-reverse" - > - {showAllInvoices ? 'Show Less' : `View All ${data.invoices.length} Invoices`} - - - )} - - - - {/* 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. - - - - }> - Contact Support - - - - - + ); } 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} - - )} - - - Reapply - - - )} - - {/* 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' && ( - - - - View - - - )} - {isPending && ( - - Cancel Application - - )} - {s.status === 'active' && ( - - Details - - - )} - - - - - ); -} - -// ============================================================================ -// 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 && ( - + Retry )} - - + + ); } - 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 - - - - - - Find Opportunities - - - - - - {/* 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 ( - setTypeFilter(type)} - className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium whitespace-nowrap transition-colors ${ - typeFilter === type - ? 'bg-primary-blue text-white' - : 'bg-iron-gray/50 text-gray-400 hover:bg-iron-gray' - } border-0 cursor-pointer`} - > - - {config.label} - - {count} - - - ); - })} - - - {/* 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 ( - setStatusFilter(status)} - className={`px-3 py-2 rounded-lg text-sm font-medium transition-colors whitespace-nowrap ${ - statusFilter === status - ? 'bg-iron-gray text-white border border-charcoal-outline' - : 'text-gray-500 hover:text-gray-300' - } border-0 cursor-pointer`} - > - {config.label} - {count > 0 && status !== 'all' && ( - - {count} - - )} - - ); - })} - - - - {/* 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.'} - - - - - - Browse Leagues - - - - - - Browse Teams - - - - - - Browse Drivers - - - - - ) : ( - - {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 => ( - {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 ? ( - - ) : ( - - )} - - - - - - - - Upload Logo - - - {profile.logoUrl && ( - - Remove - - )} - - - PNG, JPEG, or SVG. Max 2MB. Recommended size: 400x400px. - - - - - - - {/* Save Button */} - - - {saving ? ( - - - Saving... - - ) : ( - - - Save Profile - - )} - - - - - - - {/* 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 - - - - Change Password - - - - - - - - - - Two-Factor Authentication - Add an extra layer of security to your account - - - - Enable 2FA - - - - - - - - - - Active Sessions - Manage devices where you're logged in - - - - View Sessions - - - - - - - {/* Danger Zone */} - - - - }> - Danger Zone - - - - - - - - - - Delete Sponsor Account - - Permanently delete your account and all associated sponsorship data. - This action cannot be undone. - - - - - Delete Account - - - - - - + <> + 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: + setFilter(e.target.value)} + fullWidth={false} + /> + + + Status: + {}} + 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' ? ( + onUpdateStatus(user.id, 'suspended')} + > + Suspend + + ) : user.status === 'suspended' ? ( + onUpdateStatus(user.id, 'active')} + > + Activate + + ) : null} + + onDeleteUser(user.id)} + disabled={deletingUserId === user.id} + icon={} + > + {deletingUserId === user.id ? '...' : ''} + + + } + > + {''} + + + + + ))} + + + ); +} 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) => ( + + {action.label} + + ))} + + Cancel + + + + + )} + + ); +} 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) && ( )} - - - onSearch(e.target.value)} - icon={} - /> + } + > + onSearch(e.target.value)} + icon={} + width="300px" + /> - onFilterRole(e.target.value)} - options={[ - { value: '', label: 'All Roles' }, - { value: 'owner', label: 'Owner' }, - { value: 'admin', label: 'Admin' }, - { value: 'user', label: 'User' }, - ]} - /> + onFilterRole(e.target.value)} + options={[ + { value: '', label: 'All Roles' }, + { value: 'owner', label: 'Owner' }, + { value: 'admin', label: 'Admin' }, + { value: 'user', label: 'User' }, + ]} + /> - onFilterStatus(e.target.value)} - options={[ - { value: '', label: 'All Status' }, - { value: 'active', label: 'Active' }, - { value: 'suspended', label: 'Suspended' }, - { value: 'deleted', label: 'Deleted' }, - ]} - /> - - - + onFilterStatus(e.target.value)} + options={[ + { value: '', label: 'All Status' }, + { value: 'active', label: 'Active' }, + { value: 'suspended', label: 'Suspended' }, + { value: 'deleted', label: 'Deleted' }, + ]} + /> + ); } diff --git a/apps/website/components/admin/UserStatusTag.tsx b/apps/website/components/admin/UserStatusTag.tsx new file mode 100644 index 000000000..8e08b0d03 --- /dev/null +++ b/apps/website/components/admin/UserStatusTag.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { StatusBadge } from '@/ui/StatusBadge'; +import { + CheckCircle2, + AlertTriangle, + XCircle, + Clock, + LucideIcon +} from 'lucide-react'; + +export type UserStatus = 'active' | 'suspended' | 'deleted' | 'pending'; + +interface UserStatusTagProps { + status: UserStatus | string; +} + +interface StatusConfig { + variant: 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending'; + icon: LucideIcon; + label: string; +} + +/** + * UserStatusTag + * + * Semantic status indicator for users. + * Maps status strings to appropriate visual variants and icons. + */ +export function UserStatusTag({ status }: UserStatusTagProps) { + const normalizedStatus = status.toLowerCase() as UserStatus; + + const config: Record = { + active: { + variant: 'success', + icon: CheckCircle2, + label: 'Active' + }, + suspended: { + variant: 'warning', + icon: AlertTriangle, + label: 'Suspended' + }, + deleted: { + variant: 'error', + icon: XCircle, + label: 'Deleted' + }, + pending: { + variant: 'pending', + icon: Clock, + label: 'Pending' + } + }; + + const { variant, icon, label } = config[normalizedStatus] || { + variant: 'neutral', + icon: Clock, + label: status + }; + + return ( + + {label} + + ); +} diff --git a/apps/website/components/auth/AuthCard.tsx b/apps/website/components/auth/AuthCard.tsx new file mode 100644 index 000000000..30249eddb --- /dev/null +++ b/apps/website/components/auth/AuthCard.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface AuthCardProps { + children: React.ReactNode; + title: string; + description?: string; +} + +/** + * AuthCard + * + * A matte surface container for auth forms with a subtle accent glow. + */ +export function AuthCard({ children, title, description }: AuthCardProps) { + return ( + + {/* Subtle top accent line */} + + + + + + {title} + + {description && ( + + {description} + + )} + + + {children} + + + ); +} diff --git a/apps/website/components/auth/AuthFooterLinks.tsx b/apps/website/components/auth/AuthFooterLinks.tsx new file mode 100644 index 000000000..35522b90c --- /dev/null +++ b/apps/website/components/auth/AuthFooterLinks.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; + +interface AuthFooterLinksProps { + children: React.ReactNode; +} + +/** + * AuthFooterLinks + * + * Semantic container for links at the bottom of auth cards. + */ +export function AuthFooterLinks({ children }: AuthFooterLinksProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/website/components/auth/AuthForm.tsx b/apps/website/components/auth/AuthForm.tsx new file mode 100644 index 000000000..cdd879f91 --- /dev/null +++ b/apps/website/components/auth/AuthForm.tsx @@ -0,0 +1,25 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; + +interface AuthFormProps { + children: React.ReactNode; + onSubmit: (e: React.FormEvent) => void; +} + +/** + * AuthForm + * + * Semantic form wrapper for auth flows. + */ +export function AuthForm({ children, onSubmit }: AuthFormProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/website/components/auth/AuthProviderButtons.tsx b/apps/website/components/auth/AuthProviderButtons.tsx new file mode 100644 index 000000000..df4fe9f30 --- /dev/null +++ b/apps/website/components/auth/AuthProviderButtons.tsx @@ -0,0 +1,21 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; + +interface AuthProviderButtonsProps { + children: React.ReactNode; +} + +/** + * AuthProviderButtons + * + * Container for social login buttons (Google, Discord, etc.) + */ +export function AuthProviderButtons({ children }: AuthProviderButtonsProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/components/auth/AuthShell.tsx b/apps/website/components/auth/AuthShell.tsx new file mode 100644 index 000000000..2c123e7fd --- /dev/null +++ b/apps/website/components/auth/AuthShell.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; + +interface AuthShellProps { + children: React.ReactNode; +} + +/** + * AuthShell + * + * The outermost container for all authentication pages. + * Provides the "calm intensity" background and centered layout. + */ +export function AuthShell({ children }: AuthShellProps) { + return ( + + {/* Subtle background glow - top right */} + + {/* Subtle background glow - bottom left */} + + + + {children} + + + ); +} diff --git a/apps/website/components/dashboard/ActivityFeedPanel.tsx b/apps/website/components/dashboard/ActivityFeedPanel.tsx new file mode 100644 index 000000000..42f3682bf --- /dev/null +++ b/apps/website/components/dashboard/ActivityFeedPanel.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Panel } from '@/ui/Panel'; +import { Box } from '@/ui/Box'; +import { ActivityFeed } from '../feed/ActivityFeed'; + +interface FeedItem { + id: string; + type: string; + headline: string; + body?: string; + timestamp: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} + +interface ActivityFeedPanelProps { + items: FeedItem[]; + hasItems: boolean; +} + +/** + * ActivityFeedPanel + * + * A semantic wrapper for the activity feed. + */ +export function ActivityFeedPanel({ items, hasItems }: ActivityFeedPanelProps) { + return ( + + + + + + ); +} diff --git a/apps/website/components/dashboard/DashboardControlBar.tsx b/apps/website/components/dashboard/DashboardControlBar.tsx new file mode 100644 index 000000000..a74dddb27 --- /dev/null +++ b/apps/website/components/dashboard/DashboardControlBar.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Stack } from '@/ui/Stack'; + +interface DashboardControlBarProps { + title: string; + actions?: React.ReactNode; +} + +/** + * DashboardControlBar + * + * The top header bar for page-level controls and context. + * Uses UI primitives to comply with architectural constraints. + */ +export function DashboardControlBar({ title, actions }: DashboardControlBarProps) { + return ( + + + {title} + + + {actions} + + + ); +} diff --git a/apps/website/components/dashboard/DashboardHeroWrapper.tsx b/apps/website/components/dashboard/DashboardHeroWrapper.tsx index f47aaba31..dbdd557df 100644 --- a/apps/website/components/dashboard/DashboardHeroWrapper.tsx +++ b/apps/website/components/dashboard/DashboardHeroWrapper.tsx @@ -1,5 +1,3 @@ - - import { routes } from '@/lib/routing/RouteConfig'; import { Button } from '@/ui/Button'; import { DashboardHero as UiDashboardHero } from '@/ui/DashboardHero'; @@ -48,10 +46,10 @@ export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHe } stats={ <> - - - - + + + + > } /> diff --git a/apps/website/components/dashboard/DashboardKpiRow.tsx b/apps/website/components/dashboard/DashboardKpiRow.tsx new file mode 100644 index 000000000..c3256da93 --- /dev/null +++ b/apps/website/components/dashboard/DashboardKpiRow.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Grid } from '@/ui/Grid'; + +interface KpiItem { + label: string; + value: string | number; + color?: string; +} + +interface DashboardKpiRowProps { + items: KpiItem[]; +} + +/** + * DashboardKpiRow + * + * A horizontal row of key performance indicators with telemetry styling. + * Uses UI primitives to comply with architectural constraints. + */ +export function DashboardKpiRow({ items }: DashboardKpiRowProps) { + return ( + + {items.map((item, index) => ( + + + {item.label} + + + {item.value} + + + ))} + + ); +} diff --git a/apps/website/components/dashboard/DashboardRail.tsx b/apps/website/components/dashboard/DashboardRail.tsx new file mode 100644 index 000000000..9ef1faa61 --- /dev/null +++ b/apps/website/components/dashboard/DashboardRail.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; + +interface DashboardRailProps { + children: React.ReactNode; +} + +/** + * DashboardRail + * + * A thin sidebar rail for high-level navigation and status indicators. + * Uses UI primitives to comply with architectural constraints. + */ +export function DashboardRail({ children }: DashboardRailProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/components/dashboard/DashboardShell.tsx b/apps/website/components/dashboard/DashboardShell.tsx new file mode 100644 index 000000000..f9958192f --- /dev/null +++ b/apps/website/components/dashboard/DashboardShell.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; + +interface DashboardShellProps { + children: React.ReactNode; + rail?: React.ReactNode; + controlBar?: React.ReactNode; +} + +/** + * DashboardShell + * + * The primary layout container for the Telemetry Workspace. + * Orchestrates the sidebar rail, top control bar, and main content area. + * Uses UI primitives to comply with architectural constraints. + */ +export function DashboardShell({ children, rail, controlBar }: DashboardShellProps) { + return ( + + {rail && ( + + {rail} + + )} + + {controlBar && ( + + {controlBar} + + )} + + + {children} + + + + + ); +} diff --git a/apps/website/components/dashboard/QuickActions.tsx b/apps/website/components/dashboard/QuickActions.tsx deleted file mode 100644 index 54a198cbc..000000000 --- a/apps/website/components/dashboard/QuickActions.tsx +++ /dev/null @@ -1,29 +0,0 @@ - - -import { routes } from '@/lib/routing/RouteConfig'; -import { Trophy, Users } from 'lucide-react'; -import { Box } from '@/ui/Box'; -import { Heading } from '@/ui/Heading'; -import { QuickActionItem } from '@/ui/QuickActionItem'; - -export function QuickActions() { - return ( - - Quick Actions - - - - - - ); -} diff --git a/apps/website/components/dashboard/RecentActivityTable.tsx b/apps/website/components/dashboard/RecentActivityTable.tsx new file mode 100644 index 000000000..c5b4ba801 --- /dev/null +++ b/apps/website/components/dashboard/RecentActivityTable.tsx @@ -0,0 +1,74 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { StatusDot } from '@/ui/StatusDot'; + +export interface ActivityItem { + id: string; + type: string; + description: string; + timestamp: string; + status?: 'success' | 'warning' | 'critical' | 'info'; +} + +interface RecentActivityTableProps { + items: ActivityItem[]; +} + +/** + * RecentActivityTable + * + * A high-density table for displaying recent events and telemetry logs. + * Uses UI primitives to comply with architectural constraints. + */ +export function RecentActivityTable({ items }: RecentActivityTableProps) { + const getStatusColor = (status?: string) => { + switch (status) { + case 'success': return 'var(--color-success)'; + case 'warning': return 'var(--color-warning)'; + case 'critical': return 'var(--color-critical)'; + default: return 'var(--color-primary)'; + } + }; + + return ( + + + + + + Type + + + Description + + + Time + + + Status + + + + + {items.map((item) => ( + + + {item.type} + + + {item.description} + + + {item.timestamp} + + + + + + ))} + + + + ); +} diff --git a/apps/website/components/dashboard/TelemetryPanel.tsx b/apps/website/components/dashboard/TelemetryPanel.tsx new file mode 100644 index 000000000..fc436ea5d --- /dev/null +++ b/apps/website/components/dashboard/TelemetryPanel.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Surface } from '@/ui/Surface'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; + +interface TelemetryPanelProps { + title: string; + children: React.ReactNode; +} + +/** + * TelemetryPanel + * + * A dense, instrument-grade panel for displaying data and controls. + * Uses UI primitives to comply with architectural constraints. + */ +export function TelemetryPanel({ title, children }: TelemetryPanelProps) { + return ( + + + {title} + + + {children} + + + ); +} diff --git a/apps/website/components/drivers/DriverPerformanceOverview.tsx b/apps/website/components/drivers/DriverPerformanceOverview.tsx new file mode 100644 index 000000000..464ba6bcd --- /dev/null +++ b/apps/website/components/drivers/DriverPerformanceOverview.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; + +interface DriverPerformanceOverviewProps { + stats: { + wins: number; + podiums: number; + totalRaces: number; + consistency: number; + dnfs: number; + bestFinish: number; + avgFinish: number; + }; +} + +export function DriverPerformanceOverview({ stats }: DriverPerformanceOverviewProps) { + const winRate = stats.totalRaces > 0 ? (stats.wins / stats.totalRaces) * 100 : 0; + const podiumRate = stats.totalRaces > 0 ? (stats.podiums / stats.totalRaces) * 100 : 0; + + const metrics = [ + { label: 'Win Rate', value: `${winRate.toFixed(1)}%`, color: 'text-performance-green' }, + { label: 'Podium Rate', value: `${podiumRate.toFixed(1)}%`, color: 'text-warning-amber' }, + { label: 'Best Finish', value: `P${stats.bestFinish}`, color: 'text-white' }, + { label: 'Avg Finish', value: `P${stats.avgFinish.toFixed(1)}`, color: 'text-gray-400' }, + { label: 'Consistency', value: `${stats.consistency}%`, color: 'text-neon-aqua' }, + { label: 'DNFs', value: stats.dnfs, color: 'text-red-500' }, + ]; + + return ( + + Performance Overview + + + {metrics.map((metric, index) => ( + + + {metric.label} + + + {metric.value} + + + ))} + + + {/* Visual Progress Bars */} + + + + Win Rate + {winRate.toFixed(1)}% + + + + + + + + + Podium Rate + {podiumRate.toFixed(1)}% + + + + + + + + ); +} diff --git a/apps/website/components/drivers/DriverProfileHeader.tsx b/apps/website/components/drivers/DriverProfileHeader.tsx new file mode 100644 index 000000000..b3b2e2ef1 --- /dev/null +++ b/apps/website/components/drivers/DriverProfileHeader.tsx @@ -0,0 +1,106 @@ +'use client'; + +import React from 'react'; +import { Globe, Trophy, UserPlus, Check } from 'lucide-react'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { RatingBadge } from '@/ui/RatingBadge'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Image } from '@/ui/Image'; +import { SafetyRatingBadge } from './SafetyRatingBadge'; + +interface DriverProfileHeaderProps { + name: string; + avatarUrl?: string | null; + nationality: string; + rating: number; + safetyRating?: number; + globalRank?: number; + bio?: string | null; + friendRequestSent: boolean; + onAddFriend: () => void; +} + +export function DriverProfileHeader({ + name, + avatarUrl, + nationality, + rating, + safetyRating = 92, + globalRank, + bio, + friendRequestSent, + onAddFriend, +}: DriverProfileHeaderProps) { + const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png'; + + return ( + + {/* Background Accents */} + + + + {/* Avatar */} + + + + + {/* Info */} + + + + + {name} + {globalRank && ( + + + + #{globalRank} + + + )} + + + + + {nationality} + + + + + + + + + + + : } + > + {friendRequestSent ? 'Request Sent' : 'Add Friend'} + + + + + {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} + + + + + {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} + + + ))} + + + + + } + > + View Leaderboard + + + 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}`} + + + + + } + height="8" + fontSize="10px" + > + {copied ? 'Copied to Clipboard' : 'Copy Error Details'} + + + + )} + + ); +} 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}`} + + + + + } + height="8" + fontSize="10px" + > + {copied ? 'Copied to Clipboard' : 'Copy Error Details'} + + + + )} + + ); +} 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 ( + + } + width="160px" + > + Retry Session + + } + width="160px" + > + Return to Pits + + + ); +} 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 ( + + } + rounded="none" + px={8} + > + Reboot Session + + } + rounded="none" + px={8} + > + Return to Pits + + + ); +} 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 ( + + + {primaryLabel} + + + window.history.back()} + > + + + + Previous Sector + + + + + ); +} 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 ( + + + {label} + + + 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 ( + + } + width="160px" + > + Retry Session + + } + width="160px" + > + Return to Pits + + } + width="160px" + > + Contact Support + + + ); +} 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} + + + + + + {primaryAction.label} + + + {secondaryAction.label} + + + + + + ); +} 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) => ( + + + + {link.label} + + + ))} + + + + ); +} 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 Explore Leagues @@ -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 ( - - - - - - - - - - - - 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 - View All + View All - + {top10.map((driver, index) => { const position = index + 1; + const isLast = index === top10.length - 1; return ( - - {position <= 3 ? : {position}} + + - + - + {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 && ( + + } + > + {backLabel} + + + )} + + + + {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 && ( + + } + > + {backLabel} + + + )} + + + + {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 */} - - - - - {/* 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} + > + + + + + + {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 - - - - onNavigate(routes.leaderboards.drivers)} - icon={} - > - Full Rankings - - - - - {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. - + } + icon={} + shadow="shadow-lg shadow-primary-blue/20" > Driver Rankings } + icon={} + hoverBg="bg-white/5" > Team Rankings 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} + + + {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 + + + onSeasonChange(e.target.value)} + fullWidth={true} + /> + + + ); +} diff --git a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx index 94a0f8e63..b7a757d87 100644 --- a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Users, Crown, ChevronRight } from 'lucide-react'; +import { Users, ChevronRight } from 'lucide-react'; import { Button } from '@/ui/Button'; import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; @@ -8,8 +8,8 @@ import { Icon } from '@/ui/Icon'; import { Stack } from '@/ui/Stack'; import { Image } from '@/ui/Image'; import { getMediaUrl } from '@/lib/utilities/media'; -import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; -import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; +import { RankMedal } from './RankMedal'; +import { LeaderboardTableShell } from './LeaderboardTableShell'; interface TeamLeaderboardPreviewProps { teams: { @@ -27,38 +27,57 @@ interface TeamLeaderboardPreviewProps { } export function TeamLeaderboardPreview({ teams, onTeamClick, onNavigateToTeams }: TeamLeaderboardPreviewProps) { - const top5 = teams; // Already sliced in builder when implemented + const top5 = teams; return ( - - + + - + - Team Rankings - Top performing racing teams + Team Rankings + Top Performing Teams - View All + View All - - {top5.map((team) => { + + {top5.map((team, index) => { const position = team.position; + const isLast = index === top5.length - 1; return ( - - {position <= 3 ? : {position}} + + - + - + {team.name} {team.category && ( - {team.category} + {team.category} )} + {team.memberCount} members - - {SkillLevelDisplay.getLabel(team.category || '')} - - - - {team.memberCount} - - Members - + + + {team.memberCount} + Members - - {team.totalWins} - - Wins - + + {team.totalWins} + Wins ); })} - + ); -} \ No newline at end of file +} diff --git a/apps/website/components/leaderboards/TeamRankingRow.tsx b/apps/website/components/leaderboards/TeamRankingRow.tsx new file mode 100644 index 000000000..ace8dc850 --- /dev/null +++ b/apps/website/components/leaderboards/TeamRankingRow.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Image } from '@/ui/Image'; +import { TableCell, TableRow } from '@/ui/Table'; +import { Text } from '@/ui/Text'; +import { RankMedal } from './RankMedal'; +import { getMediaUrl } from '@/lib/utilities/media'; + +interface TeamRankingRowProps { + id: string; + rank: number; + name: string; + logoUrl?: string; + rating: number; + wins: number; + races: number; + memberCount: number; + onClick?: () => void; +} + +export function TeamRankingRow({ + id, + rank, + name, + logoUrl, + rating, + wins, + races, + memberCount, + onClick, +}: TeamRankingRowProps) { + return ( + + + + + + + + + + + + + + + {name} + + + {memberCount} Members + + + + + + + + {rating} + + + + + + {wins} + + + + + {races} + + + ); +} diff --git a/apps/website/components/leagues/LeagueCard.tsx b/apps/website/components/leagues/LeagueCard.tsx new file mode 100644 index 000000000..6a3681226 --- /dev/null +++ b/apps/website/components/leagues/LeagueCard.tsx @@ -0,0 +1,183 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Image } from '@/ui/Image'; +import { Trophy, Users, Calendar, ChevronRight } from 'lucide-react'; + +interface LeagueCardProps { + id: string; + name: string; + description?: string; + coverUrl: string; + logoUrl?: string; + gameName?: string; + memberCount: number; + maxMembers?: number; + nextRaceDate?: string; + championshipType: 'driver' | 'team' | 'nations' | 'trophy'; + onClick?: () => void; +} + +export function LeagueCard({ + name, + description, + coverUrl, + logoUrl, + gameName, + memberCount, + maxMembers, + nextRaceDate, + championshipType, + onClick, +}: LeagueCardProps) { + const fillPercentage = maxMembers ? (memberCount / maxMembers) * 100 : 0; + + return ( + + {/* Cover Image */} + + + + + + + {/* Game Badge */} + {gameName && ( + + + {gameName} + + + )} + + {/* Championship Icon */} + + {championshipType === 'driver' && } + {championshipType === 'team' && } + + + + {/* Content */} + + {/* Logo */} + + {logoUrl ? ( + + ) : ( + + + + )} + + + + + {name} + + + {description || 'No description available'} + + + + {/* Stats */} + + + + Drivers + {memberCount}/{maxMembers || '∞'} + + + 90 ? 'bg-amber-500' : 'bg-blue-500'} + w={`${Math.min(fillPercentage, 100)}%`} + /> + + + + + + + + {nextRaceDate || 'TBD'} + + + + View + + + + + + + + + ); +} diff --git a/apps/website/components/leagues/LeagueHeader.tsx b/apps/website/components/leagues/LeagueHeader.tsx index bddf50788..565b3be0b 100644 --- a/apps/website/components/leagues/LeagueHeader.tsx +++ b/apps/website/components/leagues/LeagueHeader.tsx @@ -1,11 +1,13 @@ -import React from 'react'; -import { MembershipStatus } from './MembershipStatus'; -import { getMediaUrl } from '@/lib/utilities/media'; -import { Text } from '@/ui/Text'; -import { Box } from '@/ui/Box'; -import { LeagueHeader as UiLeagueHeader } from '@/ui/LeagueHeader'; +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Image } from '@/ui/Image'; +import { MembershipStatus } from './MembershipStatus'; -// Main sponsor info for "by XYZ" display interface MainSponsorInfo { name: string; logoUrl?: string; @@ -27,33 +29,61 @@ export function LeagueHeader({ description, mainSponsor, }: LeagueHeaderProps) { - const logoUrl = getMediaUrl('league-logo', leagueId); - return ( - } - sponsorContent={ - mainSponsor ? ( - mainSponsor.websiteUrl ? ( - - {mainSponsor.name} - - ) : ( - {mainSponsor.name} - ) - ) : undefined - } - /> + + + + + + + + + {leagueName} + {mainSponsor && ( + + by{' '} + {mainSponsor.websiteUrl ? ( + + {mainSponsor.name} + + ) : ( + {mainSponsor.name} + )} + + )} + + + + {description && ( + + {description} + + )} + + + ); } diff --git a/apps/website/components/leagues/LeagueHeaderPanel.tsx b/apps/website/components/leagues/LeagueHeaderPanel.tsx new file mode 100644 index 000000000..c00159dbb --- /dev/null +++ b/apps/website/components/leagues/LeagueHeaderPanel.tsx @@ -0,0 +1,85 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Trophy, Users, Timer, Activity, type LucideIcon } from 'lucide-react'; +import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; + +interface LeagueHeaderPanelProps { + viewData: LeagueDetailViewData; +} + +export function LeagueHeaderPanel({ viewData }: LeagueHeaderPanelProps) { + return ( + + {/* Background Accent */} + + + + + + + + + + {viewData.name} + + + + {viewData.description} + + + + + + + + + + + ); +} + +function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string, color: string }) { + return ( + + + + + {label.toUpperCase()} + + + + {value} + + + ); +} diff --git a/apps/website/components/leagues/LeagueNavTabs.tsx b/apps/website/components/leagues/LeagueNavTabs.tsx new file mode 100644 index 000000000..c6086af69 --- /dev/null +++ b/apps/website/components/leagues/LeagueNavTabs.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Link } from '@/ui/Link'; + +interface Tab { + label: string; + href: string; + exact?: boolean; +} + +interface LeagueNavTabsProps { + tabs: Tab[]; + currentPathname: string; +} + +export function LeagueNavTabs({ tabs, currentPathname }: LeagueNavTabsProps) { + return ( + + + {tabs.map((tab) => { + const isActive = tab.exact + ? currentPathname === tab.href + : currentPathname.startsWith(tab.href); + + return ( + + + {tab.label} + + {isActive && ( + + )} + + ); + })} + + + ); +} diff --git a/apps/website/components/leagues/LeagueRulesPanel.tsx b/apps/website/components/leagues/LeagueRulesPanel.tsx new file mode 100644 index 000000000..6e78171cb --- /dev/null +++ b/apps/website/components/leagues/LeagueRulesPanel.tsx @@ -0,0 +1,53 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Shield, Info } from 'lucide-react'; + +interface Rule { + id: string; + title: string; + content: string; +} + +interface LeagueRulesPanelProps { + rules: Rule[]; +} + +export function LeagueRulesPanel({ rules }: LeagueRulesPanelProps) { + return ( + + + + + + Code of Conduct + + All drivers are expected to maintain a high standard of sportsmanship. + Intentional wrecking or abusive behavior will result in immediate disqualification. + + + + + + {rules.map((rule) => ( + + + + + + {rule.title} + + + {rule.content} + + + ))} + + + + ); +} diff --git a/apps/website/components/leagues/LeagueSchedulePanel.tsx b/apps/website/components/leagues/LeagueSchedulePanel.tsx new file mode 100644 index 000000000..e704d9b90 --- /dev/null +++ b/apps/website/components/leagues/LeagueSchedulePanel.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { MapPin, Clock } from 'lucide-react'; + +interface RaceEvent { + id: string; + title: string; + trackName: string; + date: string; + time: string; + status: 'upcoming' | 'live' | 'completed'; +} + +interface LeagueSchedulePanelProps { + events: RaceEvent[]; +} + +export function LeagueSchedulePanel({ events }: LeagueSchedulePanelProps) { + return ( + + + {events.map((event) => ( + + + + {new Date(event.date).toLocaleDateString('en-US', { month: 'short' })} + + + {new Date(event.date).toLocaleDateString('en-US', { day: 'numeric' })} + + + + + {event.title} + + + + {event.trackName} + + + + {event.time} + + + + + + {event.status === 'live' && ( + + + + Live + + + )} + {event.status === 'upcoming' && ( + + + Upcoming + + + )} + {event.status === 'completed' && ( + + + Results + + + )} + + + ))} + + + ); +} diff --git a/apps/website/components/leagues/LeagueSlider.tsx b/apps/website/components/leagues/LeagueSlider.tsx new file mode 100644 index 000000000..4161a24e9 --- /dev/null +++ b/apps/website/components/leagues/LeagueSlider.tsx @@ -0,0 +1,147 @@ +'use client'; + +import React, { useCallback, useRef, useState } from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Link } from '@/ui/Link'; +import { ChevronLeft, ChevronRight, type LucideIcon } from 'lucide-react'; +import { LeagueCard } from '@/components/leagues/LeagueCardWrapper'; +import { LeagueSummaryViewModelBuilder } from '@/lib/builders/view-models/LeagueSummaryViewModelBuilder'; +import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface LeagueSliderProps { + title: string; + icon: LucideIcon; + description: string; + leagues: LeaguesViewData['leagues']; + autoScroll?: boolean; + iconColor?: string; + scrollSpeedMultiplier?: number; + scrollDirection?: 'left' | 'right'; +} + +export function LeagueSlider({ + title, + icon: IconComp, + description, + leagues, + iconColor = 'text-primary-blue', +}: LeagueSliderProps) { + const scrollRef = useRef(null); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(true); + + 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; + scrollRef.current.scrollBy({ left: scrollAmount, behavior: 'smooth' }); + } + }, []); + + if (leagues.length === 0) return null; + + return ( + + {/* Section header */} + + + + + + + {title} + {description} + + + {leagues.length} + + + + {/* Navigation arrows */} + + scroll('left')} + disabled={!canScrollLeft} + size="sm" + w="2rem" + h="2rem" + > + + + scroll('right')} + disabled={!canScrollRight} + size="sm" + w="2rem" + h="2rem" + > + + + + + + {/* Scrollable container with fade edges */} + + + + + + {leagues.map((league) => { + const viewModel = LeagueSummaryViewModelBuilder.build(league); + + return ( + + + + + + ); + })} + + + + ); +} diff --git a/apps/website/components/leagues/LeagueStandingsTable.tsx b/apps/website/components/leagues/LeagueStandingsTable.tsx new file mode 100644 index 000000000..f3c442e64 --- /dev/null +++ b/apps/website/components/leagues/LeagueStandingsTable.tsx @@ -0,0 +1,80 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface StandingEntry { + position: number; + driverName: string; + teamName?: string; + points: number; + wins: number; + podiums: number; + gap: string; +} + +interface LeagueStandingsTableProps { + standings: StandingEntry[]; +} + +export function LeagueStandingsTable({ standings }: LeagueStandingsTableProps) { + return ( + + + + + + Pos + + + Driver + + + Team + + + Wins + + + Podiums + + + Points + + + Gap + + + + + {standings.map((entry) => ( + + + {entry.position} + + + {entry.driverName} + + + {entry.teamName || '—'} + + + {entry.wins} + + + {entry.podiums} + + + {entry.points} + + + {entry.gap} + + + ))} + + + + ); +} diff --git a/apps/website/components/leagues/ScheduleTable.tsx b/apps/website/components/leagues/ScheduleTable.tsx new file mode 100644 index 000000000..69709b61b --- /dev/null +++ b/apps/website/components/leagues/ScheduleTable.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Calendar, MapPin, ChevronRight } from 'lucide-react'; +import { Surface } from '@/ui/Surface'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Link } from '@/ui/Link'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface RaceEntry { + id: string; + name: string; + track: string; + scheduledAt: string; + status: 'upcoming' | 'live' | 'completed'; +} + +interface ScheduleTableProps { + races: RaceEntry[]; +} + +export function ScheduleTable({ races }: ScheduleTableProps) { + return ( + + + + + Race + Track + Date + Status + Actions + + + + {races.length === 0 ? ( + + + No races scheduled yet. + + + ) : ( + races.map((race) => ( + + + {race.name} + + + + + {race.track} + + + + + + + {new Date(race.scheduledAt).toLocaleDateString()} + + + + + + + + + + DETAILS + + + + + + )) + )} + + + + ); +} + +function StatusBadge({ status }: { status: RaceEntry['status'] }) { + const styles = { + upcoming: 'bg-gray-500/10 text-gray-500 border-gray-500/20', + live: 'bg-performance-green/10 text-performance-green border-performance-green/20 animate-pulse', + completed: 'bg-primary-blue/10 text-primary-blue border-primary-blue/20', + }; + + return ( + + {status.toUpperCase()} + + ); +} diff --git a/apps/website/components/leagues/StandingsTableShell.tsx b/apps/website/components/leagues/StandingsTableShell.tsx new file mode 100644 index 000000000..61c88cb00 --- /dev/null +++ b/apps/website/components/leagues/StandingsTableShell.tsx @@ -0,0 +1,118 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Trophy, TrendingUp } from 'lucide-react'; + +interface StandingsEntry { + position: number; + driverName: string; + points: number; + wins: number; + podiums: number; + change?: number; +} + +interface StandingsTableShellProps { + standings: StandingsEntry[]; + title?: string; +} + +export function StandingsTableShell({ standings, title = 'Championship Standings' }: StandingsTableShellProps) { + return ( + + + + + + + {title.toUpperCase()} + + + + {standings.length} Drivers + + + + + + + + Pos + Driver + Wins + Podiums + Points + + + + {standings.map((entry) => ( + + + + + + + {entry.driverName} + {entry.change !== undefined && entry.change !== 0 && ( + + 0 ? 'text-performance-green' : 'text-error-red'} + transform={entry.change < 0 ? 'rotate(180deg)' : undefined} + /> + 0 ? 'text-performance-green' : 'text-error-red'}> + {Math.abs(entry.change)} + + + )} + + + + 0 ? 'text-white' : 'text-gray-500'}>{entry.wins} + + + 0 ? 'text-white' : 'text-gray-500'}>{entry.podiums} + + + {entry.points} + + + ))} + + + + ); +} + +function PositionBadge({ position }: { position: number }) { + const isPodium = position <= 3; + const colors = { + 1: 'text-warning-amber bg-warning-amber/10 border-warning-amber/20', + 2: 'text-gray-300 bg-gray-300/10 border-gray-300/20', + 3: 'text-orange-400 bg-orange-400/10 border-orange-400/20', + }; + + return ( + + + {position} + + + ); +} diff --git a/apps/website/components/leagues/StewardingQueuePanel.tsx b/apps/website/components/leagues/StewardingQueuePanel.tsx new file mode 100644 index 000000000..f14d5de98 --- /dev/null +++ b/apps/website/components/leagues/StewardingQueuePanel.tsx @@ -0,0 +1,117 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Clock, ShieldAlert, MessageSquare } from 'lucide-react'; +import { Button } from '@/ui/Button'; + +interface Protest { + id: string; + raceName: string; + protestingDriver: string; + accusedDriver: string; + description: string; + submittedAt: string; + status: 'pending' | 'under_review' | 'resolved' | 'rejected'; +} + +interface StewardingQueuePanelProps { + protests: Protest[]; + onReview: (id: string) => void; +} + +export function StewardingQueuePanel({ protests, onReview }: StewardingQueuePanelProps) { + return ( + + + + + + + STEWARDING QUEUE + + + + + {protests.filter(p => p.status === 'pending').length} Pending + + + + + + + {protests.length === 0 ? ( + + + + No active protests in the queue. + + + ) : ( + protests.map((protest) => ( + + + + + + + {protest.raceName.toUpperCase()} + + + + + + {new Date(protest.submittedAt).toLocaleString()} + + + + + + + {protest.protestingDriver} + VS + {protest.accusedDriver} + + + “{protest.description}” + + + + + + onReview(protest.id)} + > + + + Review + + + + + + )) + )} + + + ); +} + +function StatusIndicator({ status }: { status: Protest['status'] }) { + const colors = { + pending: 'bg-warning-amber', + under_review: 'bg-primary-blue', + resolved: 'bg-performance-green', + rejected: 'bg-gray-500', + }; + + return ( + + ); +} diff --git a/apps/website/components/leagues/WalletSummaryPanel.tsx b/apps/website/components/leagues/WalletSummaryPanel.tsx new file mode 100644 index 000000000..e645ac552 --- /dev/null +++ b/apps/website/components/leagues/WalletSummaryPanel.tsx @@ -0,0 +1,128 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; +import { Wallet, ArrowUpRight, ArrowDownLeft, History } from 'lucide-react'; + +interface Transaction { + id: string; + type: 'credit' | 'debit'; + amount: number; + description: string; + date: string; +} + +interface WalletSummaryPanelProps { + balance: number; + currency: string; + transactions: Transaction[]; + onDeposit: () => void; + onWithdraw: () => void; +} + +export function WalletSummaryPanel({ balance, currency, transactions, onDeposit, onWithdraw }: WalletSummaryPanelProps) { + return ( + + + {/* Background Pattern */} + + + + + + + AVAILABLE BALANCE + + + + {balance.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + + {currency} + + + + + + + + Deposit + + + + + + Withdraw + + + + + + + + + + + + RECENT TRANSACTIONS + + + + + + {transactions.length === 0 ? ( + + No recent transactions. + + ) : ( + transactions.map((tx) => ( + + + + + + + + {tx.description} + {new Date(tx.date).toLocaleDateString()} + + + + {tx.type === 'credit' ? '+' : '-'}{tx.amount.toFixed(2)} + + + + )) + )} + + + + ); +} diff --git a/apps/website/components/media/MediaCard.tsx b/apps/website/components/media/MediaCard.tsx new file mode 100644 index 000000000..6d594a1f3 --- /dev/null +++ b/apps/website/components/media/MediaCard.tsx @@ -0,0 +1,105 @@ +'use client'; + +import React from 'react'; +import { motion } from 'framer-motion'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Image } from '@/ui/Image'; +import { ImagePlaceholder } from '@/ui/ImagePlaceholder'; + +export interface MediaCardProps { + src?: string; + alt?: string; + title?: string; + subtitle?: string; + aspectRatio?: string; + isLoading?: boolean; + error?: string; + onClick?: () => void; + actions?: React.ReactNode; +} + +export function MediaCard({ + src, + alt = 'Media asset', + title, + subtitle, + aspectRatio = '16/9', + isLoading, + error, + onClick, + actions, +}: MediaCardProps) { + return ( + + + + {isLoading ? ( + + ) : error ? ( + + ) : src ? ( + + + + ) : ( + + )} + + {actions && ( + + {actions} + + )} + + + {(title || subtitle) && ( + + {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + )} + + + ); +} diff --git a/apps/website/components/media/MediaFiltersBar.tsx b/apps/website/components/media/MediaFiltersBar.tsx new file mode 100644 index 000000000..37ff71921 --- /dev/null +++ b/apps/website/components/media/MediaFiltersBar.tsx @@ -0,0 +1,81 @@ +import React from 'react'; +import { Search, Grid, List } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Input } from '@/ui/Input'; +import { IconButton } from '@/ui/IconButton'; +import { Select } from '@/ui/Select'; + +export interface MediaFiltersBarProps { + searchQuery: string; + onSearchChange: (query: string) => void; + category: string; + onCategoryChange: (category: string) => void; + categories: { label: string; value: string }[]; + viewMode?: 'grid' | 'list'; + onViewModeChange?: (mode: 'grid' | 'list') => void; +} + +export function MediaFiltersBar({ + searchQuery, + onSearchChange, + category, + onCategoryChange, + categories, + viewMode = 'grid', + onViewModeChange, +}: MediaFiltersBarProps) { + return ( + + + onSearchChange(e.target.value)} + icon={} + /> + + + + + onCategoryChange(e.target.value)} + options={categories} + /> + + + {onViewModeChange && ( + + onViewModeChange('grid')} + color={viewMode === 'grid' ? 'text-white' : 'text-gray-400'} + backgroundColor={viewMode === 'grid' ? 'bg-blue-600' : undefined} + /> + onViewModeChange('list')} + color={viewMode === 'list' ? 'text-white' : 'text-gray-400'} + backgroundColor={viewMode === 'list' ? 'bg-blue-600' : undefined} + /> + + )} + + + ); +} diff --git a/apps/website/components/media/MediaGallery.tsx b/apps/website/components/media/MediaGallery.tsx new file mode 100644 index 000000000..a8dd6ad72 --- /dev/null +++ b/apps/website/components/media/MediaGallery.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React, { useState } from 'react'; +import { Box } from '@/ui/Box'; +import { MediaGrid } from './MediaGrid'; +import { MediaCard } from './MediaCard'; +import { MediaFiltersBar } from './MediaFiltersBar'; +import { MediaViewerModal } from './MediaViewerModal'; +import { Text } from '@/ui/Text'; + +export interface MediaAsset { + id: string; + src: string; + title: string; + category: string; + date?: string; + dimensions?: string; +} + +export interface MediaGalleryProps { + assets: MediaAsset[]; + categories: { label: string; value: string }[]; + title?: string; + description?: string; +} + +export function MediaGallery({ + assets, + categories, + title = 'Media Gallery', + description, +}: MediaGalleryProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [selectedCategory, setSelectedCategory] = useState('all'); + const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); + const [viewerAsset, setViewerAsset] = useState(null); + + const filteredAssets = assets.filter((asset) => { + const matchesSearch = asset.title.toLowerCase().includes(searchQuery.toLowerCase()); + const matchesCategory = selectedCategory === 'all' || asset.category === selectedCategory; + return matchesSearch && matchesCategory; + }); + + const handleNext = () => { + if (!viewerAsset) return; + const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id); + if (currentIndex < filteredAssets.length - 1) { + const nextAsset = filteredAssets[currentIndex + 1]; + if (nextAsset) setViewerAsset(nextAsset); + } + }; + + const handlePrev = () => { + if (!viewerAsset) return; + const currentIndex = filteredAssets.findIndex((a) => a.id === viewerAsset.id); + if (currentIndex > 0) { + const prevAsset = filteredAssets[currentIndex - 1]; + if (prevAsset) setViewerAsset(prevAsset); + } + }; + + return ( + + + + {title} + + {description && ( + + {description} + + )} + + + + + {filteredAssets.length > 0 ? ( + + {filteredAssets.map((asset) => ( + setViewerAsset(asset)} + /> + ))} + + ) : ( + + No media assets found matching your criteria. + + )} + + setViewerAsset(null)} + src={viewerAsset?.src} + alt={viewerAsset?.title} + title={viewerAsset?.title} + onNext={handleNext} + onPrev={handlePrev} + /> + + ); +} diff --git a/apps/website/components/media/MediaGrid.tsx b/apps/website/components/media/MediaGrid.tsx new file mode 100644 index 000000000..19bb4cbdd --- /dev/null +++ b/apps/website/components/media/MediaGrid.tsx @@ -0,0 +1,34 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; + +export interface MediaGridProps { + children: React.ReactNode; + columns?: { + base?: number; + sm?: number; + md?: number; + lg?: number; + xl?: number; + }; + gap?: 2 | 3 | 4 | 6 | 8; +} + +export function MediaGrid({ + children, + columns = { base: 1, sm: 2, md: 3, lg: 4 }, + gap = 4, +}: MediaGridProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/components/media/MediaViewerModal.tsx b/apps/website/components/media/MediaViewerModal.tsx new file mode 100644 index 000000000..7d7e7683a --- /dev/null +++ b/apps/website/components/media/MediaViewerModal.tsx @@ -0,0 +1,170 @@ +'use client'; + +import React from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { IconButton } from '@/ui/IconButton'; +import { Image } from '@/ui/Image'; +import { Text } from '@/ui/Text'; + +export interface MediaViewerModalProps { + isOpen: boolean; + onClose: () => void; + src?: string; + alt?: string; + title?: string; + onNext?: () => void; + onPrev?: () => void; +} + +export function MediaViewerModal({ + isOpen, + onClose, + src, + alt = 'Media viewer', + title, + onNext, + onPrev, +}: MediaViewerModalProps) { + return ( + + {isOpen && ( + + {/* Backdrop with frosted blur */} + + + {/* Content Container */} + + {/* Header */} + + + {title && ( + + {title} + + )} + + + src && window.open(src, '_blank')} + color="text-white" + backgroundColor="bg-white/10" + /> + + + + + {/* Image Area */} + + {src ? ( + + ) : ( + + No image selected + + )} + + {/* Navigation Controls */} + {onPrev && ( + + + + )} + {onNext && ( + + + + )} + + + {/* Footer / Info */} + + + Precision Racing Media Viewer + + + + + )} + + ); +} diff --git a/apps/website/components/media/UploadDropzone.tsx b/apps/website/components/media/UploadDropzone.tsx new file mode 100644 index 000000000..11f833b72 --- /dev/null +++ b/apps/website/components/media/UploadDropzone.tsx @@ -0,0 +1,187 @@ +import React, { useState, useRef } from 'react'; +import { Upload, File, X, CheckCircle2, AlertCircle } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; + +export interface UploadDropzoneProps { + onFilesSelected: (files: File[]) => void; + accept?: string; + multiple?: boolean; + maxSize?: number; // in bytes + isLoading?: boolean; + error?: string; +} + +export function UploadDropzone({ + onFilesSelected, + accept, + multiple = false, + maxSize, + isLoading, + error, +}: UploadDropzoneProps) { + const [isDragging, setIsDragging] = useState(false); + const [selectedFiles, setSelectedFiles] = useState([]); + const fileInputRef = useRef(null); + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = () => { + setIsDragging(false); + }; + + const handleDrop = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + const files = Array.from(e.dataTransfer.files); + validateAndSelectFiles(files); + }; + + const handleFileSelect = (e: React.ChangeEvent) => { + if (e.target.files) { + const files = Array.from(e.target.files); + validateAndSelectFiles(files); + } + }; + + const validateAndSelectFiles = (files: File[]) => { + let filteredFiles = files; + + if (accept) { + const acceptedTypes = accept.split(',').map(t => t.trim()); + filteredFiles = filteredFiles.filter(file => { + return acceptedTypes.some(type => { + if (type.startsWith('.')) { + return file.name.endsWith(type); + } + if (type.endsWith('/*')) { + return file.type.startsWith(type.replace('/*', '')); + } + return file.type === type; + }); + }); + } + + if (maxSize) { + filteredFiles = filteredFiles.filter(file => file.size <= maxSize); + } + + if (!multiple) { + filteredFiles = filteredFiles.slice(0, 1); + } + + setSelectedFiles(filteredFiles); + onFilesSelected(filteredFiles); + }; + + const removeFile = (index: number) => { + const newFiles = [...selectedFiles]; + newFiles.splice(index, 1); + setSelectedFiles(newFiles); + onFilesSelected(newFiles); + }; + + return ( + + fileInputRef.current?.click()} + display="flex" + flexDirection="col" + alignItems="center" + justifyContent="center" + p={8} + border + borderStyle="dashed" + borderColor={isDragging ? 'border-blue-500' : error ? 'border-amber-500' : 'border-charcoal-outline'} + bg={isDragging ? 'bg-blue-500/5' : 'bg-charcoal-outline/10'} + rounded="xl" + cursor="pointer" + transition + hoverBg="bg-charcoal-outline/20" + > + + + 0 ? CheckCircle2 : Upload)} + size={10} + color={isDragging ? 'text-blue-500' : error ? 'text-amber-500' : 'text-gray-500'} + animate={isLoading ? 'pulse' : 'none'} + mb={4} + /> + + + {isDragging ? 'Drop files here' : 'Click or drag to upload'} + + + + {accept ? `Accepted formats: ${accept}` : 'All file types accepted'} + {maxSize && ` (Max ${Math.round(maxSize / 1024 / 1024)}MB)`} + + + {error && ( + + + {error} + + )} + + + {selectedFiles.length > 0 && ( + + {selectedFiles.map((file, index) => ( + + + + + + {file.name} + + + {Math.round(file.size / 1024)} KB + + + + { + e.stopPropagation(); + removeFile(index); + }} + p={1} + h="auto" + > + + + + ))} + + )} + + ); +} diff --git a/apps/website/components/onboarding/AvatarStep.tsx b/apps/website/components/onboarding/AvatarStep.tsx index 369303cbe..a5b7209f7 100644 --- a/apps/website/components/onboarding/AvatarStep.tsx +++ b/apps/website/components/onboarding/AvatarStep.tsx @@ -1,7 +1,6 @@ import { useRef, ChangeEvent } from 'react'; -import { Camera, Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react'; +import { Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react'; import { Button } from '@/ui/Button'; -import { Heading } from '@/ui/Heading'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; @@ -93,16 +92,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen }; return ( - - - }> - Create Your Racing Avatar - - - Upload a photo and we will generate a unique racing avatar for you - - - + {/* Photo Upload */} @@ -180,7 +170,7 @@ export function AvatarStep({ avatarInfo, setAvatarInfo, errors, setErrors, onGen {/* Preview area */} - + {(() => { const selectedAvatarUrl = avatarInfo.selectedAvatarIndex !== null diff --git a/apps/website/components/onboarding/OnboardingHelpPanel.tsx b/apps/website/components/onboarding/OnboardingHelpPanel.tsx new file mode 100644 index 000000000..5c6eaf3ed --- /dev/null +++ b/apps/website/components/onboarding/OnboardingHelpPanel.tsx @@ -0,0 +1,42 @@ +import { Surface } from '@/ui/Surface'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { HelpCircle } from 'lucide-react'; + +interface OnboardingHelpPanelProps { + title?: string; + children: React.ReactNode; +} + +/** + * OnboardingHelpPanel + * + * A semantic panel for providing contextual help or information during onboarding. + * Usually placed in the sidebar. + */ +export function OnboardingHelpPanel({ title = 'Need Help?', children }: OnboardingHelpPanelProps) { + return ( + + + + + + {title} + + + + + {children} + + + + ); +} diff --git a/apps/website/components/onboarding/OnboardingPrimaryActions.tsx b/apps/website/components/onboarding/OnboardingPrimaryActions.tsx new file mode 100644 index 000000000..455eaafe6 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingPrimaryActions.tsx @@ -0,0 +1,61 @@ +import { Button } from '@/ui/Button'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { ChevronLeft, ChevronRight, Check } from 'lucide-react'; +import { Box } from '@/ui/Box'; + +interface OnboardingPrimaryActionsProps { + onBack?: () => void; + onNext?: () => void; + nextLabel?: string; + isLastStep?: boolean; + canNext?: boolean; + isLoading?: boolean; + type?: 'button' | 'submit'; +} + +/** + * OnboardingPrimaryActions + * + * Semantic component for the main navigation actions in the onboarding flow. + */ +export function OnboardingPrimaryActions({ + onBack, + onNext, + nextLabel = 'Continue', + isLastStep = false, + canNext = true, + isLoading = false, + type = 'button', +}: OnboardingPrimaryActionsProps) { + return ( + + {onBack ? ( + } + > + Back + + ) : ( + + )} + + + + {isLoading ? 'Processing...' : isLastStep ? 'Complete Setup' : nextLabel} + {!isLoading && (isLastStep ? : )} + + + + ); +} diff --git a/apps/website/components/onboarding/OnboardingShell.tsx b/apps/website/components/onboarding/OnboardingShell.tsx new file mode 100644 index 000000000..9d980ed05 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingShell.tsx @@ -0,0 +1,56 @@ +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; + +interface OnboardingShellProps { + children: React.ReactNode; + header?: React.ReactNode; + footer?: React.ReactNode; + sidebar?: React.ReactNode; +} + +/** + * OnboardingShell + * + * Semantic layout wrapper for the onboarding flow. + * Follows "Precision Racing Minimal" theme with dark surfaces and clean structure. + */ +export function OnboardingShell({ children, header, footer, sidebar }: OnboardingShellProps) { + return ( + + {header && ( + + + {header} + + + )} + + + + + + + {children} + + + + {sidebar && ( + + {sidebar} + + )} + + + + + {footer && ( + + + {footer} + + + )} + + ); +} diff --git a/apps/website/components/onboarding/OnboardingStepPanel.tsx b/apps/website/components/onboarding/OnboardingStepPanel.tsx new file mode 100644 index 000000000..be83257f2 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingStepPanel.tsx @@ -0,0 +1,43 @@ +import { Surface } from '@/ui/Surface'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; + +interface OnboardingStepPanelProps { + title: string; + description?: string; + children: React.ReactNode; +} + +/** + * OnboardingStepPanel + * + * A semantic container for a single onboarding step. + * Provides a consistent header and surface. + */ +export function OnboardingStepPanel({ title, description, children }: OnboardingStepPanelProps) { + return ( + + + + {title} + + {description && ( + + {description} + + )} + + + + {children} + + + ); +} diff --git a/apps/website/components/onboarding/OnboardingStepper.tsx b/apps/website/components/onboarding/OnboardingStepper.tsx new file mode 100644 index 000000000..7f3ec6652 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingStepper.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { motion } from 'framer-motion'; + +interface OnboardingStepperProps { + currentStep: number; + totalSteps: number; + steps: string[]; +} + +/** + * OnboardingStepper + * + * A progress indicator with a "pit limiter" vibe. + * Uses a progress line and step labels. + */ +export function OnboardingStepper({ currentStep, totalSteps, steps }: OnboardingStepperProps) { + const progress = (currentStep / totalSteps) * 100; + + return ( + + + + + + + {steps.map((label, index) => { + const stepNumber = index + 1; + const isActive = stepNumber === currentStep; + const isCompleted = stepNumber < currentStep; + + return ( + + + + {stepNumber} + + + + {label} + + + ); + })} + + + ); +} diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx deleted file mode 100644 index 4631a8d96..000000000 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ /dev/null @@ -1,231 +0,0 @@ -'use client'; - -import { OnboardingCardAccent } from '@/ui/OnboardingCardAccent'; -import { OnboardingContainer } from '@/ui/OnboardingContainer'; -import { OnboardingError } from '@/ui/OnboardingError'; -import { OnboardingForm } from '@/ui/OnboardingForm'; -import { OnboardingHeader } from '@/ui/OnboardingHeader'; -import { OnboardingHelpText } from '@/ui/OnboardingHelpText'; -import { OnboardingNavigation } from '@/ui/OnboardingNavigation'; -import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep'; -import { Card } from '@/ui/Card'; -import { StepIndicator } from '@/ui/StepIndicator'; -import { FormEvent, useState } from 'react'; -import { AvatarInfo, AvatarStep } from './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; -} - -interface OnboardingWizardProps { - onCompleted: () => void; - onCompleteOnboarding: (data: { - firstName: string; - lastName: string; - displayName: string; - country: string; - timezone?: string; - }) => Promise<{ success: boolean; error?: string }>; - onGenerateAvatars: (params: { - facePhotoData: string; - suitColor: string; - }) => Promise<{ success: boolean; data?: { success: boolean; avatarUrls?: string[]; errorMessage?: string }; error?: string }>; -} - -export function OnboardingWizard({ onCompleted, onCompleteOnboarding, onGenerateAvatars }: OnboardingWizardProps) { - 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, - }); - - // Validation - const validateStep = (currentStep: OnboardingStep): boolean => { - const newErrors: FormErrors = {}; - - if (currentStep === 1) { - if (!personalInfo.firstName.trim()) { - newErrors.firstName = 'First name is required'; - } - if (!personalInfo.lastName.trim()) { - newErrors.lastName = 'Last name is required'; - } - if (!personalInfo.displayName.trim()) { - newErrors.displayName = 'Display name is required'; - } else if (personalInfo.displayName.length < 3) { - newErrors.displayName = 'Display name must be at least 3 characters'; - } - if (!personalInfo.country) { - newErrors.country = 'Please select your country'; - } - } - - if (currentStep === 2) { - if (!avatarInfo.facePhoto) { - newErrors.facePhoto = 'Please upload a photo of your face'; - } - if (avatarInfo.generatedAvatars.length > 0 && avatarInfo.selectedAvatarIndex === null) { - newErrors.avatar = 'Please select one of the generated avatars'; - } - } - - setErrors(newErrors); - return Object.keys(newErrors).length === 0; - }; - - const handleNext = () => { - const isValid = validateStep(step); - if (isValid && step < 2) { - setStep((step + 1) as OnboardingStep); - } - }; - - const handleBack = () => { - if (step > 1) { - setStep((step - 1) as OnboardingStep); - } - }; - - const generateAvatars = async () => { - if (!avatarInfo.facePhoto) { - setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); - return; - } - - setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); - const newErrors = { ...errors }; - delete newErrors.avatar; - setErrors(newErrors); - - try { - const result = await onGenerateAvatars({ - facePhotoData: avatarInfo.facePhoto, - suitColor: avatarInfo.suitColor, - }); - - if (result.success && result.data?.success && result.data.avatarUrls) { - setAvatarInfo(prev => ({ - ...prev, - generatedAvatars: result.data!.avatarUrls!, - isGenerating: false, - })); - } else { - setErrors(prev => ({ ...prev, avatar: result.data?.errorMessage || result.error || 'Failed to generate avatars' })); - setAvatarInfo(prev => ({ ...prev, isGenerating: false })); - } - } catch (error) { - setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' })); - setAvatarInfo(prev => ({ ...prev, isGenerating: false })); - } - }; - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - - // Validate step 2 - must have selected an avatar - if (!validateStep(2)) { - return; - } - - if (avatarInfo.selectedAvatarIndex === null) { - setErrors({ ...errors, avatar: 'Please select an avatar' }); - return; - } - - setErrors({}); - - try { - const result = await onCompleteOnboarding({ - firstName: personalInfo.firstName.trim(), - lastName: personalInfo.lastName.trim(), - displayName: personalInfo.displayName.trim(), - country: personalInfo.country, - timezone: personalInfo.timezone || undefined, - }); - - if (result.success) { - onCompleted(); - } else { - setErrors({ submit: result.error || 'Failed to create profile' }); - } - } catch (error) { - setErrors({ submit: 'Failed to create profile' }); - } - }; - - // Loading state comes from the mutations - const loading = false; // This would be managed by the parent component - - return ( - - - - - - - - - - {step === 1 && ( - - )} - - {step === 2 && ( - - )} - - {errors.submit && } - - - - - - - - ); -} \ No newline at end of file diff --git a/apps/website/components/onboarding/PersonalInfoStep.tsx b/apps/website/components/onboarding/PersonalInfoStep.tsx index 80b9ebb2d..036028fb5 100644 --- a/apps/website/components/onboarding/PersonalInfoStep.tsx +++ b/apps/website/components/onboarding/PersonalInfoStep.tsx @@ -1,6 +1,5 @@ -import { User, Clock, ChevronRight } from 'lucide-react'; +import { Clock, ChevronRight } from 'lucide-react'; import { Input } from '@/ui/Input'; -import { Heading } from '@/ui/Heading'; import { CountrySelect } from '@/components/shared/CountrySelect'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; @@ -44,15 +43,6 @@ const TIMEZONES = [ export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loading }: PersonalInfoStepProps) { return ( - - }> - Personal Information - - - Tell us a bit about yourself - - - diff --git a/apps/website/components/profile/ConnectedAccountsPanel.tsx b/apps/website/components/profile/ConnectedAccountsPanel.tsx new file mode 100644 index 000000000..f62e9e245 --- /dev/null +++ b/apps/website/components/profile/ConnectedAccountsPanel.tsx @@ -0,0 +1,71 @@ +'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 { Button } from '@/ui/Button'; +import { Globe, Link as LinkIcon } from 'lucide-react'; + +interface ConnectedAccountsPanelProps { + iracingId?: string | number; + onConnectIRacing?: () => void; +} + +export function ConnectedAccountsPanel({ iracingId, onConnectIRacing }: ConnectedAccountsPanelProps) { + return ( + + + + Connected Accounts + + + + + + + + + iRacing + + {iracingId ? `Connected ID: ${iracingId}` : 'Not connected'} + + + + + {!iracingId && ( + + Connect + + )} + {iracingId && ( + + Verified + + )} + + + + + + + + + Discord + Connect for notifications + + + + + Connect + + + + + + + ); +} + +import { Box } from '@/ui/Box'; diff --git a/apps/website/components/profile/LiveryGallery.tsx b/apps/website/components/profile/LiveryGallery.tsx new file mode 100644 index 000000000..d6cdfccfb --- /dev/null +++ b/apps/website/components/profile/LiveryGallery.tsx @@ -0,0 +1,28 @@ +'use client'; + +import React from 'react'; +import { Grid } from '@/ui/Grid'; +import { LiveryCard } from '@/ui/LiveryCard'; +import { ProfileSection } from './ProfileSection'; +import { ProfileLiveryViewData } from '@/lib/view-data/ProfileLiveriesViewData'; + +interface LiveryGalleryProps { + liveries: ProfileLiveryViewData[]; + action?: React.ReactNode; +} + +export function LiveryGallery({ liveries, action }: LiveryGalleryProps) { + return ( + + + {liveries.map((livery) => ( + + ))} + + + ); +} diff --git a/apps/website/components/profile/MembershipPanel.tsx b/apps/website/components/profile/MembershipPanel.tsx new file mode 100644 index 000000000..07ab6a307 --- /dev/null +++ b/apps/website/components/profile/MembershipPanel.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Card } from '@/ui/Card'; +import { Text } from '@/ui/Text'; +import { LeagueListItem } from '@/ui/LeagueListItem'; +import { ProfileSection } from './ProfileSection'; + +interface League { + leagueId: string; + name: string; + description: string; + logoUrl?: string; + memberCount: number; + roleLabel: string; +} + +interface MembershipPanelProps { + ownedLeagues: League[]; + memberLeagues: League[]; +} + +export function MembershipPanel({ ownedLeagues, memberLeagues }: MembershipPanelProps) { + return ( + + + {ownedLeagues.length === 0 ? ( + + + You don't own any leagues yet. + + + ) : ( + + {ownedLeagues.map((league) => ( + + ))} + + )} + + + + {memberLeagues.length === 0 ? ( + + + You're not a member of any other leagues yet. + + + ) : ( + + {memberLeagues.map((league) => ( + + ))} + + )} + + + ); +} diff --git a/apps/website/components/profile/PreferencesPanel.tsx b/apps/website/components/profile/PreferencesPanel.tsx new file mode 100644 index 000000000..ff29401b2 --- /dev/null +++ b/apps/website/components/profile/PreferencesPanel.tsx @@ -0,0 +1,97 @@ +'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 { Select } from '@/ui/Select'; +import { Toggle } from '@/ui/Toggle'; + +interface PreferencesPanelProps { + preferences: { + favoriteCarClass: string; + favoriteSeries: string; + competitiveLevel: string; + showProfile: boolean; + showHistory: boolean; + }; + isEditing?: boolean; + onUpdate?: (updates: Partial) => void; +} + +export function PreferencesPanel({ preferences, isEditing, onUpdate }: PreferencesPanelProps) { + if (isEditing) { + return ( + + + + Racing Preferences + + + onUpdate?.({ favoriteCarClass: e.target.value })} + options={[ + { value: 'GT3', label: 'GT3' }, + { value: 'GT4', label: 'GT4' }, + { value: 'Formula', label: 'Formula' }, + { value: 'LMP2', label: 'LMP2' }, + ]} + /> + onUpdate?.({ competitiveLevel: e.target.value })} + options={[ + { value: 'casual', label: 'Casual' }, + { value: 'competitive', label: 'Competitive' }, + { value: 'professional', label: 'Professional' }, + ]} + /> + + + onUpdate?.({ showProfile: checked })} + /> + onUpdate?.({ showHistory: checked })} + /> + + + + + + ); + } + + return ( + + + + Racing Preferences + + + + Car Class + {preferences.favoriteCarClass} + + + Level + {preferences.competitiveLevel} + + + Visibility + {preferences.showProfile ? 'Public' : 'Private'} + + + + + + ); +} diff --git a/apps/website/components/profile/ProfileDetailsPanel.tsx b/apps/website/components/profile/ProfileDetailsPanel.tsx new file mode 100644 index 000000000..ad5eaa9ba --- /dev/null +++ b/apps/website/components/profile/ProfileDetailsPanel.tsx @@ -0,0 +1,81 @@ +'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 { Input } from '@/ui/Input'; +import { TextArea } from '@/ui/TextArea'; +import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; + +interface ProfileDetailsPanelProps { + driver: { + name: string; + country: string; + bio?: string | null; + }; + isEditing?: boolean; + onUpdate?: (updates: { bio?: string; country?: string }) => void; +} + +export function ProfileDetailsPanel({ driver, isEditing, onUpdate }: ProfileDetailsPanelProps) { + if (isEditing) { + return ( + + + + Profile Details + + onUpdate?.({ country: e.target.value })} + placeholder="e.g. US, GB, DE" + maxLength={2} + /> + onUpdate?.({ bio: e.target.value })} + placeholder="Tell the community about your racing career..." + rows={4} + /> + + + + + ); + } + + return ( + + + + + Profile Details + + + + + Nationality + + + {CountryFlagDisplay.fromCountryCode(driver.country).toString()} + + {driver.country} + + + + + Bio + + {driver.bio || 'No bio provided.'} + + + + + + + ); +} diff --git a/apps/website/components/profile/ProfileHeader.tsx b/apps/website/components/profile/ProfileHeader.tsx new file mode 100644 index 000000000..e7329ecc3 --- /dev/null +++ b/apps/website/components/profile/ProfileHeader.tsx @@ -0,0 +1,140 @@ +'use client'; + +import React from 'react'; +import { mediaConfig } from '@/lib/config/mediaConfig'; +import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Heading } from '@/ui/Heading'; +import { Image } from '@/ui/Image'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; +import { Calendar, Globe, Star, Trophy, UserPlus } from 'lucide-react'; + +interface ProfileHeaderProps { + driver: { + name: string; + avatarUrl?: string; + country: string; + iracingId: number; + joinedAt: string | Date; + }; + stats: { + rating: number; + } | null; + globalRank: number; + onAddFriend?: () => void; + friendRequestSent?: boolean; + isOwnProfile?: boolean; +} + +export function ProfileHeader({ + driver, + stats, + globalRank, + onAddFriend, + friendRequestSent, + isOwnProfile, +}: ProfileHeaderProps) { + return ( + + + + {/* Avatar with telemetry-style border */} + + + + + + + + + {/* Driver Info */} + + + + {driver.name} + + + {CountryFlagDisplay.fromCountryCode(driver.country).toString()} + + + + + + + ID: {driver.iracingId} + + + + + + Joined {new Date(driver.joinedAt).toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + + + + + + {/* Stats Grid */} + + {stats && ( + <> + + + RATING + + + {stats.rating} + + + + + + + GLOBAL RANK + + + #{globalRank} + + + + > + )} + + + {/* Actions */} + {!isOwnProfile && onAddFriend && ( + + } + > + {friendRequestSent ? 'Request Sent' : 'Add Friend'} + + + )} + + + + ); +} diff --git a/apps/website/components/profile/ProfileNavTabs.tsx b/apps/website/components/profile/ProfileNavTabs.tsx new file mode 100644 index 000000000..1e6094a78 --- /dev/null +++ b/apps/website/components/profile/ProfileNavTabs.tsx @@ -0,0 +1,64 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; + +export type ProfileTab = 'overview' | 'history' | 'stats' | 'leagues' | 'liveries' | 'settings'; + +interface ProfileNavTabsProps { + activeTab: ProfileTab; + onTabChange: (tab: ProfileTab) => void; + tabs?: { id: ProfileTab; label: string }[]; +} + +const DEFAULT_TABS: { id: ProfileTab; label: string }[] = [ + { id: 'overview', label: 'Overview' }, + { id: 'history', label: 'History' }, + { id: 'stats', label: 'Stats' }, + { id: 'leagues', label: 'Leagues' }, + { id: 'liveries', label: 'Liveries' }, + { id: 'settings', label: 'Settings' }, +]; + +export function ProfileNavTabs({ activeTab, onTabChange, tabs = DEFAULT_TABS }: ProfileNavTabsProps) { + return ( + + + + {tabs.map((tab) => ( + onTabChange(tab.id)} + pb={4} + cursor="pointer" + borderBottom + borderColor={activeTab === tab.id ? '#198CFF' : 'transparent'} + color={activeTab === tab.id ? '#198CFF' : '#9ca3af'} + transition + fontSize="0.875rem" + fontWeight={activeTab === tab.id ? '600' : '400'} + mb="-1px" + position="relative" + className="group" + > + {tab.label} + {activeTab === tab.id && ( + + )} + + ))} + + + + ); +} diff --git a/apps/website/components/profile/ProfileSection.tsx b/apps/website/components/profile/ProfileSection.tsx new file mode 100644 index 000000000..92c54a3a4 --- /dev/null +++ b/apps/website/components/profile/ProfileSection.tsx @@ -0,0 +1,33 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; + +interface ProfileSectionProps { + title: string; + description?: string; + action?: React.ReactNode; + children: React.ReactNode; +} + +export function ProfileSection({ title, description, action, children }: ProfileSectionProps) { + return ( + + + + {title} + {description && ( + + {description} + + )} + + {action && {action}} + + {children} + + ); +} diff --git a/apps/website/components/profile/ProfileSettingsPanel.tsx b/apps/website/components/profile/ProfileSettingsPanel.tsx new file mode 100644 index 000000000..f8601f0c9 --- /dev/null +++ b/apps/website/components/profile/ProfileSettingsPanel.tsx @@ -0,0 +1,168 @@ +'use client'; + +import React, { useState } from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Input } from '@/ui/Input'; +import { Heading } from '@/ui/Heading'; +import { Stack } from '@/ui/Stack'; +import { Select } from '@/ui/Select'; +import { Toggle } from '@/ui/Toggle'; +import { TextArea } from '@/ui/TextArea'; +import { Checkbox } from '@/ui/Checkbox'; +import { ProfileSection } from './ProfileSection'; + +interface ProfileSettingsPanelProps { + driver: { + id: string; + name: string; + country: string; + bio?: string | null; + }; + onSave?: (updates: { bio?: string; country?: string }) => void; +} + +export function ProfileSettingsPanel({ driver, onSave }: ProfileSettingsPanelProps) { + const [bio, setBio] = useState(driver.bio || ''); + const [nationality, setNationality] = useState(driver.country); + const [favoriteCarClass, setFavoriteCarClass] = useState('GT3'); + const [favoriteSeries, setFavoriteSeries] = useState('Endurance'); + const [competitiveLevel, setCompetitiveLevel] = useState('competitive'); + const [preferredRegions, setPreferredRegions] = useState(['EU']); + + const handleSave = () => { + onSave?.({ + bio, + country: nationality + }); + }; + + return ( + + + + + ) => setBio(e.target.value)} + rows={4} + placeholder="Tell us about yourself..." + /> + + ) => setNationality(e.target.value)} + placeholder="e.g., US, GB, DE" + maxLength={2} + /> + + + + + + + + ) => setFavoriteCarClass(e.target.value)} + options={[ + { value: 'GT3', label: 'GT3' }, + { value: 'GT4', label: 'GT4' }, + { value: 'Formula', label: 'Formula' }, + { value: 'LMP2', label: 'LMP2' }, + { value: 'Touring', label: 'Touring Cars' }, + { value: 'NASCAR', label: 'NASCAR' }, + ]} + /> + + ) => setFavoriteSeries(e.target.value)} + options={[ + { value: 'Sprint', label: 'Sprint' }, + { value: 'Endurance', label: 'Endurance' }, + { value: 'Mixed', label: 'Mixed' }, + ]} + /> + + ) => setCompetitiveLevel(e.target.value)} + options={[ + { value: 'casual', label: 'Casual - Just for fun' }, + { value: 'competitive', label: 'Competitive - Aiming to win' }, + { value: 'professional', label: 'Professional - Esports focused' }, + ]} + /> + + + Preferred Regions + + {['NA', 'EU', 'ASIA', 'OCE'].map(region => ( + { + if (checked) { + setPreferredRegions([...preferredRegions, region]); + } else { + setPreferredRegions(preferredRegions.filter(r => r !== region)); + } + }} + /> + ))} + + + + + + + + + + {}} + label="Show profile to other drivers" + /> + {}} + label="Show race history" + /> + {}} + label="Allow friend requests" + /> + + + + + + + Cancel + + + Save Changes + + + + ); +} diff --git a/apps/website/components/profile/SessionHistoryTable.tsx b/apps/website/components/profile/SessionHistoryTable.tsx new file mode 100644 index 000000000..4b59ccfc5 --- /dev/null +++ b/apps/website/components/profile/SessionHistoryTable.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Text } from '@/ui/Text'; +import { Badge } from '@/ui/Badge'; +import { Stack } from '@/ui/Stack'; +import { Trophy, Calendar } from 'lucide-react'; + +interface SessionResult { + id: string; + date: string; + event: string; + car: string; + position: number; + fieldSize: number; + ratingChange: number; +} + +interface SessionHistoryTableProps { + results: SessionResult[]; +} + +export function SessionHistoryTable({ results }: SessionHistoryTableProps) { + if (results.length === 0) { + return ( + + + No race history found. + + ); + } + + return ( + + + + Date + Event + Car + Pos + Rating + + + + {results.map((result) => ( + + + + + + {new Date(result.date).toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + + + + {result.event} + + + {result.car} + + + + + P{result.position} + + / {result.fieldSize} + + + + = 0 ? '#10b981' : '#ef4444'} + > + {result.ratingChange >= 0 ? '+' : ''}{result.ratingChange} + + + + ))} + + + ); +} diff --git a/apps/website/components/profile/SponsorshipRequestsPanel.tsx b/apps/website/components/profile/SponsorshipRequestsPanel.tsx new file mode 100644 index 000000000..40e854e51 --- /dev/null +++ b/apps/website/components/profile/SponsorshipRequestsPanel.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React from 'react'; +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 { Surface } from '@/ui/Surface'; +import { DateDisplay } from '@/lib/display-objects/DateDisplay'; +import { ProfileSection } from './ProfileSection'; + +interface Request { + id: string; + sponsorName: string; + message?: string | null; + createdAtIso: string; +} + +interface Section { + entityId: string; + entityName: string; + entityType: string; + requests: Request[]; +} + +interface SponsorshipRequestsPanelProps { + sections: Section[]; + onAccept: (requestId: string) => Promise; + onReject: (requestId: string, reason?: string) => Promise; + processingId?: string | null; +} + +export function SponsorshipRequestsPanel({ + sections, + onAccept, + onReject, + processingId, +}: SponsorshipRequestsPanelProps) { + return ( + + {sections.map((section) => ( + + + {section.requests.length === 0 ? ( + No pending requests. + ) : ( + + {section.requests.map((request) => ( + + + + {request.sponsorName} + {request.message && ( + {request.message} + )} + + {DateDisplay.formatShort(request.createdAtIso)} + + + + onAccept(request.id)} + size="sm" + disabled={!!processingId} + > + {processingId === request.id ? 'Accepting...' : 'Accept'} + + onReject(request.id)} + size="sm" + disabled={!!processingId} + > + {processingId === request.id ? 'Rejecting...' : 'Reject'} + + + + + ))} + + )} + + + ))} + + ); +} diff --git a/apps/website/components/races/EntrantsTable.tsx b/apps/website/components/races/EntrantsTable.tsx new file mode 100644 index 000000000..89539cb55 --- /dev/null +++ b/apps/website/components/races/EntrantsTable.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React from 'react'; +import { Text } from '@/ui/Text'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Badge } from '@/ui/Badge'; + +interface Entrant { + id: string; + name: string; + teamName?: string; + carName: string; + rating: number; + status: 'confirmed' | 'withdrawn' | 'pending'; +} + +interface EntrantsTableProps { + entrants: Entrant[]; +} + +export function EntrantsTable({ entrants }: EntrantsTableProps) { + return ( + + + + Driver + Team + Car + Rating + Status + + + + {entrants.map((entrant) => ( + + + {entrant.name} + + + {entrant.teamName || '-'} + + + {entrant.carName} + + + {entrant.rating} + + + + {entrant.status.toUpperCase()} + + + + ))} + + + ); +} diff --git a/apps/website/components/races/RaceCard.tsx b/apps/website/components/races/RaceCard.tsx new file mode 100644 index 000000000..87a6847b6 --- /dev/null +++ b/apps/website/components/races/RaceCard.tsx @@ -0,0 +1,91 @@ +'use client'; + +import React from 'react'; +import { Clock, MapPin, Users } from 'lucide-react'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; +import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge'; + +interface RaceCardProps { + id: string; + title: string; + leagueName: string; + trackName: string; + scheduledAt: string; + entrantCount: number; + status: SessionStatus; + onClick: (id: string) => void; +} + +export function RaceCard({ + id, + title, + leagueName, + trackName, + scheduledAt, + entrantCount, + status, + onClick, +}: RaceCardProps) { + return ( + onClick(id)} + bg="bg-surface-charcoal" + border + borderColor="border-outline-steel" + p={4} + hoverBorderColor="border-primary-accent" + transition + cursor="pointer" + position="relative" + overflow="hidden" + group + > + {/* Hover Glow */} + + + + + + + {leagueName} + + + {title} + + + + + + + + + {trackName} + + + + {scheduledAt} + + + + + + + {entrantCount} ENTRANTS + + + + + ); +} diff --git a/apps/website/components/races/RaceDetailsHeader.tsx b/apps/website/components/races/RaceDetailsHeader.tsx new file mode 100644 index 000000000..beec5fce7 --- /dev/null +++ b/apps/website/components/races/RaceDetailsHeader.tsx @@ -0,0 +1,73 @@ +'use client'; + +import React from 'react'; +import { ChevronLeft, Calendar, MapPin } from 'lucide-react'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; +import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge'; + +interface RaceDetailsHeaderProps { + title: string; + leagueName: string; + trackName: string; + scheduledAt: string; + status: SessionStatus; + onBack: () => void; +} + +export function RaceDetailsHeader({ + title, + leagueName, + trackName, + scheduledAt, + status, + onBack, +}: RaceDetailsHeaderProps) { + return ( + + + + + Back to Schedule + + + + + + {leagueName} + + {title} + + + + + {trackName} + + + + {scheduledAt} + + + + + + + + + + + ); +} diff --git a/apps/website/components/races/RaceScheduleTable.tsx b/apps/website/components/races/RaceScheduleTable.tsx new file mode 100644 index 000000000..74c57f978 --- /dev/null +++ b/apps/website/components/races/RaceScheduleTable.tsx @@ -0,0 +1,63 @@ +'use client'; + +import React from 'react'; +import { Text } from '@/ui/Text'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { SessionStatusBadge, type SessionStatus } from './SessionStatusBadge'; + +interface RaceRow { + id: string; + track: string; + car: string; + leagueName: string | null; + time: string; + status: SessionStatus; +} + +interface RaceScheduleTableProps { + races: RaceRow[]; + onRaceClick: (id: string) => void; +} + +export function RaceScheduleTable({ races, onRaceClick }: RaceScheduleTableProps) { + return ( + + + + Time + Track + Car + League + Status + + + + {races.map((race) => ( + onRaceClick(race.id)} + clickable + > + + {race.time} + + + + {race.track} + + + + {race.car} + + + {race.leagueName || 'Official'} + + + + + + ))} + + + ); +} diff --git a/apps/website/components/races/RacesHeader.tsx b/apps/website/components/races/RacesHeader.tsx new file mode 100644 index 000000000..f555d84cb --- /dev/null +++ b/apps/website/components/races/RacesHeader.tsx @@ -0,0 +1,78 @@ +'use client'; + +import React from 'react'; +import { Flag, CalendarDays, Clock, Zap, Trophy, type LucideIcon } from 'lucide-react'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { Icon } from '@/ui/Icon'; +import { Grid } from '@/ui/Grid'; + +interface RacesHeaderProps { + totalCount: number; + scheduledCount: number; + runningCount: number; + completedCount: number; +} + +export function RacesHeader({ + totalCount, + scheduledCount, + runningCount, + completedCount, +}: RacesHeaderProps) { + return ( + + {/* Background Accent */} + + + + + + + RACE DASHBOARD + + + Precision tracking for upcoming sessions and live events. + + + + + + + + + + + + ); +} + +function StatItem({ + icon, + label, + value, + color = 'text-white' +}: { + icon: LucideIcon, + label: string, + value: number, + color?: string +}) { + return ( + + + + + {label} + + {value} + + + ); +} diff --git a/apps/website/components/races/SessionStatusBadge.tsx b/apps/website/components/races/SessionStatusBadge.tsx new file mode 100644 index 000000000..2b956d022 --- /dev/null +++ b/apps/website/components/races/SessionStatusBadge.tsx @@ -0,0 +1,43 @@ +'use client'; + +import React from 'react'; +import { Badge } from '@/ui/Badge'; + +export type SessionStatus = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'delayed'; + +interface SessionStatusBadgeProps { + status: SessionStatus; +} + +export function SessionStatusBadge({ status }: SessionStatusBadgeProps) { + const config: Record = { + scheduled: { + label: 'SCHEDULED', + variant: 'primary', + }, + running: { + label: 'LIVE', + variant: 'success', + }, + completed: { + label: 'COMPLETED', + variant: 'default', + }, + cancelled: { + label: 'CANCELLED', + variant: 'danger', + }, + delayed: { + label: 'DELAYED', + variant: 'warning', + }, + }; + + const { label, variant } = config[status] || config.scheduled; + + return ( + + {label} + + ); +} diff --git a/apps/website/components/races/TrackConditionsPanel.tsx b/apps/website/components/races/TrackConditionsPanel.tsx new file mode 100644 index 000000000..6841771a0 --- /dev/null +++ b/apps/website/components/races/TrackConditionsPanel.tsx @@ -0,0 +1,86 @@ +'use client'; + +import React from 'react'; +import { Thermometer, Wind, Droplets, Sun, type LucideIcon } from 'lucide-react'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; + +interface TrackConditionsPanelProps { + airTemp: string; + trackTemp: string; + humidity: string; + windSpeed: string; + weatherType: string; +} + +export function TrackConditionsPanel({ + airTemp, + trackTemp, + humidity, + windSpeed, + weatherType, +}: TrackConditionsPanelProps) { + return ( + + + Track Conditions + + + + + + + + + + + + {weatherType} + + + ); +} + +function ConditionItem({ + icon, + label, + value, + color +}: { + icon: LucideIcon, + label: string, + value: string, + color: string +}) { + return ( + + + + {label} + + {value} + + ); +} diff --git a/apps/website/components/shared/ux/ConfirmDialog.tsx b/apps/website/components/shared/ux/ConfirmDialog.tsx new file mode 100644 index 000000000..7d11c4455 --- /dev/null +++ b/apps/website/components/shared/ux/ConfirmDialog.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { Modal } from '@/ui/Modal'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Stack } from '@/ui/Stack'; +import { AlertCircle } from 'lucide-react'; + +interface ConfirmDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + title: string; + description: string; + confirmLabel?: string; + cancelLabel?: string; + variant?: 'danger' | 'primary'; + isLoading?: boolean; +} + +export function ConfirmDialog({ + isOpen, + onClose, + onConfirm, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'primary', + isLoading = false, +}: ConfirmDialogProps) { + return ( + !open && onClose()} title={title}> + + + {variant === 'danger' && ( + + + + )} + + + {title} + + + {description} + + + + + + + {cancelLabel} + + + {isLoading ? 'Processing...' : confirmLabel} + + + + + ); +} diff --git a/apps/website/components/shared/ux/InlineNotice.tsx b/apps/website/components/shared/ux/InlineNotice.tsx new file mode 100644 index 000000000..efe89cb78 --- /dev/null +++ b/apps/website/components/shared/ux/InlineNotice.tsx @@ -0,0 +1,76 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { AlertCircle, CheckCircle, Info, AlertTriangle } from 'lucide-react'; + +interface InlineNoticeProps { + variant?: 'info' | 'success' | 'warning' | 'error'; + title?: string; + message: string; + mb?: number; +} + +export function InlineNotice({ + variant = 'info', + title, + message, + mb, +}: InlineNoticeProps) { + const variants = { + info: { + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/30', + text: 'text-primary-blue', + icon: Info, + }, + success: { + bg: 'bg-performance-green/10', + border: 'border-performance-green/30', + text: 'text-performance-green', + icon: CheckCircle, + }, + warning: { + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/30', + text: 'text-warning-amber', + icon: AlertTriangle, + }, + error: { + bg: 'bg-racing-red/10', + border: 'border-racing-red/30', + text: 'text-racing-red', + icon: AlertCircle, + }, + }; + + const config = variants[variant]; + const Icon = config.icon; + + return ( + + + + + {title && ( + + {title} + + )} + + {message} + + + + + ); +} diff --git a/apps/website/components/shared/ux/ProgressLine.tsx b/apps/website/components/shared/ux/ProgressLine.tsx new file mode 100644 index 000000000..93fcbaceb --- /dev/null +++ b/apps/website/components/shared/ux/ProgressLine.tsx @@ -0,0 +1,38 @@ +'use client'; + +import React from 'react'; +import { motion } from 'framer-motion'; +import { Box } from '@/ui/Box'; + +interface ProgressLineProps { + isLoading: boolean; + className?: string; +} + +export function ProgressLine({ isLoading, className = '' }: ProgressLineProps) { + if (!isLoading) return null; + + return ( + + + + ); +} diff --git a/apps/website/components/shared/ux/Toast.tsx b/apps/website/components/shared/ux/Toast.tsx new file mode 100644 index 000000000..6c9ae299a --- /dev/null +++ b/apps/website/components/shared/ux/Toast.tsx @@ -0,0 +1,123 @@ +'use client'; + +import React, { useState, useEffect, createContext, useContext } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { CheckCircle, AlertCircle, Info, X } from 'lucide-react'; + +interface Toast { + id: string; + message: string; + variant: 'success' | 'error' | 'info'; +} + +interface ToastContextType { + showToast: (message: string, variant: 'success' | 'error' | 'info') => void; +} + +const ToastContext = createContext(undefined); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const showToast = (message: string, variant: 'success' | 'error' | 'info') => { + const id = Math.random().toString(36).substring(2, 9); + setToasts((prev) => [...prev, { id, message, variant }]); + setTimeout(() => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 5000); + }; + + return ( + + {children} + + + {toasts.map((toast) => ( + setToasts((prev) => prev.filter((t) => t.id !== toast.id))} + /> + ))} + + + + ); +} + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error('useToast must be used within a ToastProvider'); + } + return context; +} + +function ToastItem({ toast, onClose }: { toast: Toast; onClose: () => void }) { + const variants = { + success: { + bg: 'bg-deep-graphite', + border: 'border-performance-green/30', + icon: CheckCircle, + iconColor: 'text-performance-green', + }, + error: { + bg: 'bg-deep-graphite', + border: 'border-racing-red/30', + icon: AlertCircle, + iconColor: 'text-racing-red', + }, + info: { + bg: 'bg-deep-graphite', + border: 'border-primary-blue/30', + icon: Info, + iconColor: 'text-primary-blue', + }, + }; + + const config = variants[toast.variant]; + const Icon = config.icon; + + return ( + + + + + {toast.message} + + + + + + + ); +} diff --git a/apps/website/components/social/FriendsPreview.tsx b/apps/website/components/social/FriendsPreview.tsx index 93355c637..df62a861c 100644 --- a/apps/website/components/social/FriendsPreview.tsx +++ b/apps/website/components/social/FriendsPreview.tsx @@ -1,5 +1,6 @@ +'use client'; - +import React from 'react'; import { mediaConfig } from '@/lib/config/mediaConfig'; import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; import { Box } from '@/ui/Box'; @@ -12,6 +13,7 @@ import { Stack } from '@/ui/Stack'; import { Surface } from '@/ui/Surface'; import { Text } from '@/ui/Text'; import { Users } from 'lucide-react'; +import { routes } from '@/lib/routing/RouteConfig'; interface Friend { id: string; @@ -39,17 +41,33 @@ export function FriendsPreview({ friends }: FriendsPreviewProps) { {friends.slice(0, 8).map((friend) => ( - - + + {friend.name} diff --git a/apps/website/components/sponsors/BillingSummaryPanel.tsx b/apps/website/components/sponsors/BillingSummaryPanel.tsx new file mode 100644 index 000000000..9c0fd8216 --- /dev/null +++ b/apps/website/components/sponsors/BillingSummaryPanel.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Box, BoxProps } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { LucideIcon } from 'lucide-react'; + +interface BillingStatProps { + label: string; + value: string | number; + subValue?: string; + icon: LucideIcon; + variant?: 'default' | 'success' | 'warning' | 'danger' | 'info'; +} + +function BillingStat({ label, value, subValue, icon, variant = 'default' }: BillingStatProps) { + const colorMap = { + default: 'text-white', + success: 'text-performance-green', + warning: 'text-warning-amber', + danger: 'text-racing-red', + info: 'text-primary-blue', + }; + + const bgMap = { + default: 'bg-iron-gray/50', + success: 'bg-performance-green/10', + warning: 'bg-warning-amber/10', + danger: 'bg-racing-red/10', + info: 'bg-primary-blue/10', + }; + + return ( + + + ['bg']}> + + + + {label} + + + + + {value} + + {subValue && ( + + {subValue} + + )} + + + ); +} + +interface BillingSummaryPanelProps { + stats: BillingStatProps[]; +} + +/** + * BillingSummaryPanel + * + * A semantic panel for displaying billing metrics. + * Dense, grid-based layout for financial data. + */ +export function BillingSummaryPanel({ stats }: BillingSummaryPanelProps) { + return ( + + {stats.map((stat, index) => ( + + ))} + + ); +} diff --git a/apps/website/components/sponsors/PricingTableShell.tsx b/apps/website/components/sponsors/PricingTableShell.tsx new file mode 100644 index 000000000..e9924c759 --- /dev/null +++ b/apps/website/components/sponsors/PricingTableShell.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { Check, Info } from 'lucide-react'; + +export interface PricingTier { + id: string; + name: string; + price: number; + period: string; + description: string; + features: string[]; + isPopular?: boolean; + available?: boolean; +} + +interface PricingTableShellProps { + title: string; + tiers: PricingTier[]; + onSelect?: (id: string) => void; + selectedId?: string; +} + +/** + * PricingTableShell + * + * A semantic component for displaying sponsorship pricing tiers. + * Clean, comparison-focused layout. + */ +export function PricingTableShell({ title, tiers, onSelect, selectedId }: PricingTableShellProps) { + return ( + + + {title} + + + {tiers.map((tier) => ( + onSelect?.(tier.id)} + transition-all + hoverBorderColor={selectedId === tier.id ? 'border-primary-blue' : 'border-charcoal-outline'} + > + {tier.isPopular && ( + + Popular + + )} + + + + {tier.name} + + + ${tier.price} + /{tier.period} + + + {tier.description} + + + + + {tier.features.map((feature, i) => ( + + + + + {feature} + + ))} + + + {!tier.available && ( + + + Currently Unavailable + + )} + + ))} + + + ); +} diff --git a/apps/website/components/sponsors/SponsorActivityPanel.tsx b/apps/website/components/sponsors/SponsorActivityPanel.tsx new file mode 100644 index 000000000..eac2cffaf --- /dev/null +++ b/apps/website/components/sponsors/SponsorActivityPanel.tsx @@ -0,0 +1,78 @@ +import React from 'react'; +import { Box, BoxProps } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { LucideIcon, Clock } from 'lucide-react'; + +export interface Activity { + id: string; + type: 'sponsorship_approved' | 'payment_received' | 'new_opportunity' | 'contract_expiring'; + title: string; + description: string; + timestamp: string; + icon: LucideIcon; + color: string; +} + +interface SponsorActivityPanelProps { + activities: Activity[]; +} + +/** + * SponsorActivityPanel + * + * A semantic component for displaying a feed of sponsor activities. + * Dense, chronological list. + */ +export function SponsorActivityPanel({ activities }: SponsorActivityPanelProps) { + return ( + + + Recent Activity + + + {activities.length === 0 ? ( + + + No recent activity to show. + + ) : ( + + {activities.map((activity, index) => ( + + ['bg']} + > + + + + + {activity.title} + {activity.timestamp} + + {activity.description} + + + ))} + + )} + + + ); +} diff --git a/apps/website/components/sponsors/SponsorBrandingPreview.tsx b/apps/website/components/sponsors/SponsorBrandingPreview.tsx new file mode 100644 index 000000000..afa087e81 --- /dev/null +++ b/apps/website/components/sponsors/SponsorBrandingPreview.tsx @@ -0,0 +1,85 @@ +'use client'; + +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Surface } from '@/ui/Surface'; +import { SponsorLogo } from '@/ui/SponsorLogo'; + +interface SponsorBrandingPreviewProps { + name: string; + logoUrl?: string; + primaryColor?: string; + secondaryColor?: string; +} + +/** + * SponsorBrandingPreview + * + * Visualizes how a sponsor's branding (logo and colors) will appear on the platform. + */ +export function SponsorBrandingPreview({ + name, + logoUrl, + primaryColor = '#198CFF', + secondaryColor = '#141619' +}: SponsorBrandingPreviewProps) { + return ( + + + + Branding Preview + + + + + + {/* Logo Preview */} + + + + + Primary Logo Asset + + + {/* Color Palette */} + + + Color Palette + + + + + {primaryColor} + + + + {secondaryColor} + + + + + {/* Mockup Hint */} + + + These assets will be used for broadcast overlays and car liveries. + + + + + + ); +} diff --git a/apps/website/components/sponsors/SponsorContractCard.tsx b/apps/website/components/sponsors/SponsorContractCard.tsx new file mode 100644 index 000000000..c67286cea --- /dev/null +++ b/apps/website/components/sponsors/SponsorContractCard.tsx @@ -0,0 +1,144 @@ +'use client'; + +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; +import { + Trophy, + Users, + Car, + Flag, + Megaphone, + ChevronRight, + ExternalLink, + Calendar, + BarChart3 +} from 'lucide-react'; +import { SponsorStatusChip, SponsorStatus } from './SponsorStatusChip'; + +export type SponsorshipType = 'league' | 'team' | 'driver' | 'race' | 'platform'; + +interface SponsorContractCardProps { + id: string; + type: SponsorshipType; + status: string; // Accept raw status string + title: string; + subtitle?: string; + tier: string; + investment: string; + impressions: string; + startDate?: string; + endDate?: string; + onViewDetails?: (id: string) => void; +} + +const TYPE_CONFIG = { + league: { icon: Trophy, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', label: 'League' }, + team: { icon: Users, color: 'text-purple-400', bgColor: 'bg-purple-400/10', label: 'Team' }, + driver: { icon: Car, color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Driver' }, + race: { 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' }, +}; + +function mapStatus(status: string): SponsorStatus { + if (status === 'pending_approval' || status === 'pending') return 'pending'; + if (status === 'rejected' || status === 'declined') return 'declined'; + if (status === 'expired') return 'expired'; + if (status === 'approved') return 'approved'; + if (status === 'active') return 'active'; + return 'pending'; +} + +/** + * SponsorContractCard + * + * Semantic component for displaying a sponsorship contract/campaign. + * Provides a high-density overview of the sponsorship status and performance. + */ +export function SponsorContractCard({ + id, + type, + status, + title, + subtitle, + tier, + investment, + impressions, + startDate, + endDate, + onViewDetails +}: SponsorContractCardProps) { + const typeConfig = TYPE_CONFIG[type]; + const mappedStatus = mapStatus(status); + + return ( + + + + + + + + + {typeConfig.label} • {tier} + + + {title} + + {subtitle && {subtitle}} + + + + + + + + Impressions + + + {impressions} + + + + Investment + {investment} + + + Term + + + {endDate || 'N/A'} + + + + + + + {startDate ? `Started ${startDate}` : 'Contract pending'} + + + } + onClick={() => onViewDetails?.(id)} + > + View + + } + > + Details + + + + + ); +} diff --git a/apps/website/components/sponsors/SponsorDashboardHeader.tsx b/apps/website/components/sponsors/SponsorDashboardHeader.tsx new file mode 100644 index 000000000..a0ab5dc62 --- /dev/null +++ b/apps/website/components/sponsors/SponsorDashboardHeader.tsx @@ -0,0 +1,100 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { + LayoutDashboard, + RefreshCw, + Settings, + Bell +} from 'lucide-react'; +import { Link } from '@/ui/Link'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface SponsorDashboardHeaderProps { + sponsorName: string; + onRefresh?: () => void; +} + +/** + * SponsorDashboardHeader + * + * Semantic header for the sponsor dashboard. + * Orchestrates dashboard-level actions and identity. + */ +export function SponsorDashboardHeader({ sponsorName, onRefresh }: SponsorDashboardHeaderProps) { + return ( + + + + + + + + + Sponsor Dashboard + + + Welcome back, {sponsorName} + + + + + + + + {(['7d', '30d', '90d'] as const).map((range) => ( + + {range} + + ))} + + + + }> + Refresh + + + + }> + Settings + + + + + + + + + + + ); +} diff --git a/apps/website/components/sponsors/SponsorHeaderPanel.tsx b/apps/website/components/sponsors/SponsorHeaderPanel.tsx new file mode 100644 index 000000000..a8cda86a5 --- /dev/null +++ b/apps/website/components/sponsors/SponsorHeaderPanel.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { LucideIcon } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Stack } from '@/ui/Stack'; +import { Surface } from '@/ui/Surface'; +import { Text } from '@/ui/Text'; + +interface SponsorHeaderPanelProps { + icon: LucideIcon; + title: string; + description?: string; + actions?: React.ReactNode; + stats?: React.ReactNode; +} + +/** + * SponsorHeaderPanel + * + * A semantic header panel for sponsor-related pages. + * Follows the finance/ops panel aesthetic with dense information. + */ +export function SponsorHeaderPanel({ + icon, + title, + description, + actions, + stats, +}: SponsorHeaderPanelProps) { + return ( + + + + + + + + {title} + {description && ( + {description} + )} + + + + + {stats && ( + + {stats} + + )} + {actions && {actions}} + + + + ); +} diff --git a/apps/website/components/sponsors/SponsorPayoutQueueTable.tsx b/apps/website/components/sponsors/SponsorPayoutQueueTable.tsx new file mode 100644 index 000000000..46b524d0c --- /dev/null +++ b/apps/website/components/sponsors/SponsorPayoutQueueTable.tsx @@ -0,0 +1,98 @@ +'use client'; + +import React from 'react'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Badge } from '@/ui/Badge'; +import { Stack } from '@/ui/Stack'; +import { + Clock, + CheckCircle2, + AlertCircle, + ArrowUpRight, + DollarSign +} from 'lucide-react'; + +export interface PayoutItem { + id: string; + date: string; + amount: string; + status: 'pending' | 'processing' | 'completed' | 'failed'; + recipient: string; + description: string; +} + +interface SponsorPayoutQueueTableProps { + payouts: PayoutItem[]; +} + +const STATUS_CONFIG = { + pending: { icon: Clock, color: 'text-warning-amber', label: 'Pending' }, + processing: { icon: ArrowUpRight, color: 'text-primary-blue', label: 'Processing' }, + completed: { icon: CheckCircle2, color: 'text-performance-green', label: 'Completed' }, + failed: { icon: AlertCircle, color: 'text-racing-red', label: 'Failed' }, +}; + +/** + * SponsorPayoutQueueTable + * + * High-density table for tracking sponsorship payouts and financial transactions. + */ +export function SponsorPayoutQueueTable({ payouts }: SponsorPayoutQueueTableProps) { + return ( + + + + Date + Recipient + Description + Amount + Status + + + + {payouts.map((payout) => { + const status = STATUS_CONFIG[payout.status]; + return ( + + + {payout.date} + + + {payout.recipient} + + + {payout.description} + + + + + {payout.amount} + + + + + + + + {status.label} + + + + + + ); + })} + {payouts.length === 0 && ( + + + No payouts in queue + + + )} + + + ); +} diff --git a/apps/website/components/sponsors/SponsorStatusChip.tsx b/apps/website/components/sponsors/SponsorStatusChip.tsx new file mode 100644 index 000000000..437ea4ad1 --- /dev/null +++ b/apps/website/components/sponsors/SponsorStatusChip.tsx @@ -0,0 +1,66 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { + Check, + Clock, + ThumbsUp, + ThumbsDown, + XCircle, + AlertTriangle +} from 'lucide-react'; + +export type SponsorStatus = 'active' | 'pending' | 'approved' | 'declined' | 'expired' | 'warning'; + +interface SponsorStatusChipProps { + status: SponsorStatus; + label?: string; +} + +const STATUS_CONFIG = { + active: { icon: Check, color: 'text-performance-green', bgColor: 'bg-performance-green/10', label: 'Active' }, + pending: { icon: Clock, color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Pending' }, + approved: { icon: ThumbsUp, color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', label: 'Approved' }, + declined: { icon: ThumbsDown, color: 'text-racing-red', bgColor: 'bg-racing-red/10', label: 'Declined' }, + expired: { icon: XCircle, color: 'text-gray-400', bgColor: 'bg-gray-400/10', label: 'Expired' }, + warning: { icon: AlertTriangle, color: 'text-warning-amber', bgColor: 'bg-warning-amber/10', label: 'Action Required' }, +}; + +/** + * SponsorStatusChip + * + * Semantic status indicator for sponsorship states. + * Follows the "Precision Racing Minimal" theme with instrument-grade feedback. + */ +export function SponsorStatusChip({ status, label }: SponsorStatusChipProps) { + const config = STATUS_CONFIG[status]; + + return ( + + + + + {label || config.label} + + + + ); +} diff --git a/apps/website/components/sponsors/TransactionTable.tsx b/apps/website/components/sponsors/TransactionTable.tsx new file mode 100644 index 000000000..5d978f075 --- /dev/null +++ b/apps/website/components/sponsors/TransactionTable.tsx @@ -0,0 +1,138 @@ +import React from 'react'; +import { Box, BoxProps } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Button } from '@/ui/Button'; +import { Download, Receipt, Clock, Check, AlertTriangle } from 'lucide-react'; + +export interface Transaction { + id: string; + date: string; + description: string; + amount: number; + status: 'paid' | 'pending' | 'overdue' | 'failed'; + invoiceNumber: string; + type: string; +} + +interface TransactionTableProps { + transactions: Transaction[]; + onDownload?: (id: string) => void; +} + +const STATUS_CONFIG = { + 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' + }, +}; + +/** + * TransactionTable + * + * A semantic table for displaying financial transactions. + * Dense layout with thin dividers, optimized for ops panels. + */ +export function TransactionTable({ transactions, onDownload }: TransactionTableProps) { + return ( + + + + Description + + + Date + + + Amount + + + Status + + + Action + + + + + {transactions.map((tx, index) => { + const status = STATUS_CONFIG[tx.status]; + return ( + + + + + + + {tx.description} + {tx.invoiceNumber} • {tx.type} + + + + + {new Date(tx.date).toLocaleDateString()} + + + + ${tx.amount.toFixed(2)} + + + + ['bg']} border borderColor={status.border as BoxProps<'div'>['borderColor']}> + + {status.label} + + + + + onDownload?.(tx.id)} + icon={} + > + PDF + + + + ); + })} + + + ); +} diff --git a/apps/website/components/teams/TeamCard.tsx b/apps/website/components/teams/TeamCard.tsx new file mode 100644 index 000000000..d9db8a9e8 --- /dev/null +++ b/apps/website/components/teams/TeamCard.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React from 'react'; +import { Users, ChevronRight } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Icon } from '@/ui/Icon'; +import { Image } from '@/ui/Image'; + +interface TeamCardProps { + id: string; + name: string; + logoUrl?: string; + memberCount: number; + isRecruiting?: boolean; + onClick?: () => void; +} + +export function TeamCard({ name, logoUrl, memberCount, isRecruiting, onClick }: TeamCardProps) { + return ( + + {/* Accent line */} + + + + + {logoUrl ? ( + + ) : ( + {name.substring(0, 2).toUpperCase()} + )} + + + + {name} + + + + {memberCount} + + {isRecruiting && ( + + Recruiting + + )} + + + + + + + ); +} diff --git a/apps/website/components/teams/TeamDetailsHeader.tsx b/apps/website/components/teams/TeamDetailsHeader.tsx new file mode 100644 index 000000000..7dd344333 --- /dev/null +++ b/apps/website/components/teams/TeamDetailsHeader.tsx @@ -0,0 +1,114 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Image } from '@/ui/Image'; + +interface TeamDetailsHeaderProps { + teamId: string; + name: string; + tag?: string; + description?: string; + logoUrl?: string; + memberCount: number; + foundedDate?: string; + isAdmin?: boolean; + onAdminClick?: () => void; +} + +export function TeamDetailsHeader({ + name, + tag, + description, + logoUrl, + memberCount, + foundedDate, + isAdmin, + onAdminClick, +}: TeamDetailsHeaderProps) { + return ( + + {/* Background accent */} + + + + + {logoUrl ? ( + + ) : ( + {name.substring(0, 2).toUpperCase()} + )} + + + + + {name} + {tag && ( + + [{tag}] + + )} + + + + {description || 'No mission statement provided.'} + + + + + Personnel + {memberCount} Units + + + Established + + {foundedDate ? new Date(foundedDate).toLocaleDateString() : 'Unknown'} + + + + + + + {isAdmin && ( + + Configure + + )} + + Join Request + + + + + ); +} diff --git a/apps/website/components/teams/TeamGrid.tsx b/apps/website/components/teams/TeamGrid.tsx new file mode 100644 index 000000000..298d456a8 --- /dev/null +++ b/apps/website/components/teams/TeamGrid.tsx @@ -0,0 +1,29 @@ +'use client'; + +import React from 'react'; +import { Grid } from '@/ui/Grid'; +import { TeamCard } from './TeamCard'; +import type { TeamSummaryData } from '@/lib/view-data/TeamsViewData'; + +interface TeamGridProps { + teams: TeamSummaryData[]; + onTeamClick?: (teamId: string) => void; +} + +export function TeamGrid({ teams, onTeamClick }: TeamGridProps) { + return ( + + {teams.map((team) => ( + onTeamClick?.(team.teamId)} + /> + ))} + + ); +} diff --git a/apps/website/components/teams/TeamHeroSection.tsx b/apps/website/components/teams/TeamHeroSection.tsx index 4715a7a72..de59d80e7 100644 --- a/apps/website/components/teams/TeamHeroSection.tsx +++ b/apps/website/components/teams/TeamHeroSection.tsx @@ -31,10 +31,8 @@ export function TeamHeroSection({ py={12} px={8} rounded="2xl" - style={{ - background: 'linear-gradient(to bottom right, rgba(147, 51, 234, 0.3), rgba(38, 38, 38, 0.8), #0f1115)', - borderColor: 'rgba(147, 51, 234, 0.2)', - }} + bg="surface-charcoal" + borderColor="outline-steel" border > {/* Background decorations */} diff --git a/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx b/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx index 3c2d4d5a6..7e37ef9ef 100644 --- a/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx +++ b/apps/website/components/teams/TeamLeaderboardPreviewWrapper.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { getMediaUrl } from '@/lib/utilities/media'; -import { TeamLeaderboardItem } from '@/ui/TeamLeaderboardItem'; -import { TeamLeaderboardPreview as UiTeamLeaderboardPreview } from '@/ui/TeamLeaderboardPreview'; +import { TeamLeaderboardPreview as SemanticTeamLeaderboardPreview } from '@/components/leaderboards/TeamLeaderboardPreview'; interface TeamLeaderboardPreviewProps { topTeams: Array<{ @@ -24,58 +23,22 @@ export function TeamLeaderboardPreview({ onTeamClick, onViewFullLeaderboard }: TeamLeaderboardPreviewProps) { - const getMedalColor = (position: number) => { - switch (position) { - case 0: return '#facc15'; - case 1: return '#d1d5db'; - case 2: return '#d97706'; - default: return '#6b7280'; - } - }; - - const getMedalBg = (position: number) => { - switch (position) { - case 0: return 'rgba(250, 204, 21, 0.1)'; - case 1: return 'rgba(209, 213, 219, 0.1)'; - case 2: return 'rgba(217, 119, 6, 0.1)'; - default: return 'rgba(38, 38, 38, 0.5)'; - } - }; - - const getMedalBorder = (position: number) => { - switch (position) { - case 0: return 'rgba(250, 204, 21, 0.3)'; - case 1: return 'rgba(209, 213, 219, 0.3)'; - case 2: return 'rgba(217, 119, 6, 0.3)'; - default: return 'rgba(38, 38, 38, 1)'; - } - }; - if (topTeams.length === 0) return null; return ( - - {topTeams.map((team, index) => ( - onTeamClick(team.id)} - medalColor={getMedalColor(index)} - medalBg={getMedalBg(index)} - medalBorder={getMedalBorder(index)} - /> - ))} - + ({ + id: team.id, + name: team.name, + tag: '', // Not available in this view data + memberCount: team.memberCount, + category: team.category, + totalWins: team.totalWins, + logoUrl: team.logoUrl || getMediaUrl('team-logo', team.id), + position: index + 1 + }))} + onTeamClick={onTeamClick} + onNavigateToTeams={onViewFullLeaderboard} + /> ); } diff --git a/apps/website/components/teams/TeamMembersTable.tsx b/apps/website/components/teams/TeamMembersTable.tsx new file mode 100644 index 000000000..2650893df --- /dev/null +++ b/apps/website/components/teams/TeamMembersTable.tsx @@ -0,0 +1,79 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Table, TableBody, TableHead, TableHeader, TableRow, TableCell } from '@/ui/Table'; + +interface Member { + driverId: string; + driverName: string; + role: string; + joinedAt: string; +} + +interface TeamMembersTableProps { + members: Member[]; + isAdmin?: boolean; + onRemoveMember?: (driverId: string) => void; +} + +export function TeamMembersTable({ members, isAdmin, onRemoveMember }: TeamMembersTableProps) { + return ( + + + + + Personnel + Role + Joined + Rating + {isAdmin && Actions} + + + + {members.map((member) => ( + + + + + {member.driverName.substring(0, 2).toUpperCase()} + + {member.driverName} + + + + + {member.role} + + + + + {new Date(member.joinedAt).toLocaleDateString()} + + + + 1450 + + {isAdmin && ( + + {member.role !== 'owner' && ( + onRemoveMember?.(member.driverId)} + > + DECOMMISSION + + )} + + )} + + ))} + + + + ); +} diff --git a/apps/website/components/teams/TeamPodium.tsx b/apps/website/components/teams/TeamPodium.tsx index 8185d5807..974fcfb5c 100644 --- a/apps/website/components/teams/TeamPodium.tsx +++ b/apps/website/components/teams/TeamPodium.tsx @@ -73,7 +73,7 @@ export function TeamPodium({ teams, onClick }: TeamPodiumProps) { h="auto" mb={4} p={0} - className="transition-all" + transition > + + + Active Campaign Standings + + + + {standings.length > 0 ? ( + + + + League + Pos + Races + Points + + + + {standings.map((s) => ( + + + {s.leagueName} + + + + + {s.position} + + + + + {s.races} + + + {s.points} + + + ))} + + + ) : ( + + + No active campaign telemetry + + + )} + + ); +} diff --git a/apps/website/components/teams/TeamsDirectoryHeader.tsx b/apps/website/components/teams/TeamsDirectoryHeader.tsx new file mode 100644 index 000000000..50ab8aff8 --- /dev/null +++ b/apps/website/components/teams/TeamsDirectoryHeader.tsx @@ -0,0 +1,52 @@ +'use client'; + +import React from 'react'; +import { Plus } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; + +interface TeamsDirectoryHeaderProps { + onCreateTeam: () => void; +} + +export function TeamsDirectoryHeader({ onCreateTeam }: TeamsDirectoryHeaderProps) { + return ( + + + Teams + + Operational Units & Racing Collectives + + + + } + > + Initialize Team + + + + ); +} diff --git a/apps/website/hooks/onboarding/useCompleteOnboarding.ts b/apps/website/hooks/onboarding/useCompleteOnboarding.ts index 5c208e179..af38f0fd8 100644 --- a/apps/website/hooks/onboarding/useCompleteOnboarding.ts +++ b/apps/website/hooks/onboarding/useCompleteOnboarding.ts @@ -1,7 +1,7 @@ 'use client'; import { useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { completeOnboardingAction } from '@/app/onboarding/completeOnboardingAction'; +import { completeOnboardingAction } from '@/app/actions/completeOnboardingAction'; import { Result } from '@/lib/contracts/Result'; import { CompleteOnboardingInputDTO } from '@/lib/types/generated/CompleteOnboardingInputDTO'; diff --git a/apps/website/hooks/onboarding/useGenerateAvatars.ts b/apps/website/hooks/onboarding/useGenerateAvatars.ts index 80b506a0a..d55ec27e7 100644 --- a/apps/website/hooks/onboarding/useGenerateAvatars.ts +++ b/apps/website/hooks/onboarding/useGenerateAvatars.ts @@ -1,7 +1,7 @@ 'use client'; import { useMutation, UseMutationOptions } from '@tanstack/react-query'; -import { generateAvatarsAction } from '@/app/onboarding/generateAvatarsAction'; +import { generateAvatarsAction } from '@/app/actions/generateAvatarsAction'; import { Result } from '@/lib/contracts/Result'; interface GenerateAvatarsParams { diff --git a/apps/website/lib/display-objects/ProfileDisplay.ts b/apps/website/lib/display-objects/ProfileDisplay.ts index 1c2209203..5df288015 100644 --- a/apps/website/lib/display-objects/ProfileDisplay.ts +++ b/apps/website/lib/display-objects/ProfileDisplay.ts @@ -5,257 +5,181 @@ * NO Intl.*, NO Date.toLocale*, NO dynamic formatting. */ -// ============================================================================ -// COUNTRY FLAG DISPLAY -// ============================================================================ - export interface CountryFlagDisplayData { flag: string; label: string; } -export const countryFlagDisplay: Record = { - // Common country codes - add as needed - US: { flag: '🇺🇸', label: 'United States' }, - GB: { flag: '🇬🇧', label: 'United Kingdom' }, - DE: { flag: '🇩🇪', label: 'Germany' }, - FR: { flag: '🇫🇷', label: 'France' }, - IT: { flag: '🇮🇹', label: 'Italy' }, - ES: { flag: '🇪🇸', label: 'Spain' }, - JP: { flag: '🇯🇵', label: 'Japan' }, - AU: { flag: '🇦🇺', label: 'Australia' }, - CA: { flag: '🇨🇦', label: 'Canada' }, - BR: { flag: '🇧🇷', label: 'Brazil' }, - // Fallback for unknown codes - DEFAULT: { flag: '🏁', label: 'Unknown' }, -} as const; - -export function getCountryFlagDisplay(countryCode: string): CountryFlagDisplayData { - const code = countryCode.toUpperCase(); - return countryFlagDisplay[code] || countryFlagDisplay.DEFAULT; -} - -// ============================================================================ -// ACHIEVEMENT RARITY DISPLAY -// ============================================================================ - export interface AchievementRarityDisplayData { text: string; badgeClasses: string; borderClasses: string; } -export const achievementRarityDisplay: Record = { - common: { - text: 'Common', - badgeClasses: 'bg-gray-400/10 text-gray-400', - borderClasses: 'border-gray-400/30', - }, - rare: { - text: 'Rare', - badgeClasses: 'bg-primary-blue/10 text-primary-blue', - borderClasses: 'border-primary-blue/30', - }, - epic: { - text: 'Epic', - badgeClasses: 'bg-purple-400/10 text-purple-400', - borderClasses: 'border-purple-400/30', - }, - legendary: { - text: 'Legendary', - badgeClasses: 'bg-yellow-400/10 text-yellow-400', - borderClasses: 'border-yellow-400/30', - }, -} as const; - -export function getAchievementRarityDisplay(rarity: string): AchievementRarityDisplayData { - return achievementRarityDisplay[rarity] || achievementRarityDisplay.common; -} - -// ============================================================================ -// ACHIEVEMENT ICON DISPLAY -// ============================================================================ - -export type AchievementIconType = 'trophy' | 'medal' | 'star' | 'crown' | 'target' | 'zap'; - export interface AchievementIconDisplayData { name: string; - // Icon component will be resolved in UI layer } -export const achievementIconDisplay: Record = { - trophy: { name: 'Trophy' }, - medal: { name: 'Medal' }, - star: { name: 'Star' }, - crown: { name: 'Crown' }, - target: { name: 'Target' }, - zap: { name: 'Zap' }, -} as const; - -export function getAchievementIconDisplay(icon: string): AchievementIconDisplayData { - return achievementIconDisplay[icon as AchievementIconType] || achievementIconDisplay.trophy; -} - -// ============================================================================ -// SOCIAL PLATFORM DISPLAY -// ============================================================================ - export interface SocialPlatformDisplayData { name: string; hoverClasses: string; } -export const socialPlatformDisplay: Record = { - twitter: { - name: 'Twitter', - hoverClasses: 'hover:text-sky-400 hover:bg-sky-400/10', - }, - youtube: { - name: 'YouTube', - hoverClasses: 'hover:text-red-500 hover:bg-red-500/10', - }, - twitch: { - name: 'Twitch', - hoverClasses: 'hover:text-purple-400 hover:bg-purple-400/10', - }, - discord: { - name: 'Discord', - hoverClasses: 'hover:text-indigo-400 hover:bg-indigo-400/10', - }, -} as const; - -export function getSocialPlatformDisplay(platform: string): SocialPlatformDisplayData { - return socialPlatformDisplay[platform] || socialPlatformDisplay.discord; -} - -// ============================================================================ -// DATE FORMATTING (DETERMINISTIC) -// ============================================================================ - -/** - * Format date string to "Month Year" format - * Input: ISO date string (e.g., "2024-01-15T10:30:00Z") - * Output: "Jan 2024" - */ -export function formatMonthYear(dateString: string): string { - const date = new Date(dateString); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const month = months[date.getUTCMonth()]; - const year = date.getUTCFullYear(); - return `${month} ${year}`; -} - -/** - * Format date string to "Month Day, Year" format - * Input: ISO date string - * Output: "Jan 15, 2024" - */ -export function formatMonthDayYear(dateString: string): string { - const date = new Date(dateString); - const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - const month = months[date.getUTCMonth()]; - const day = date.getUTCDate(); - const year = date.getUTCFullYear(); - return `${month} ${day}, ${year}`; -} - -// ============================================================================ -// STATISTICS FORMATTING -// ============================================================================ - -export interface StatDisplayData { - value: string; - label: string; -} - -/** - * Format percentage with 1 decimal place - * Input: 0.1234 - * Output: "12.3%" - */ -export function formatPercentage(value: number | null): string { - if (value === null || value === undefined) return '0.0%'; - return `${(value * 100).toFixed(1)}%`; -} - -/** - * Format finish position - * Input: 1 - * Output: "P1" - */ -export function formatFinishPosition(position: number | null): string { - if (position === null || position === undefined) return 'P-'; - return `P${position}`; -} - -/** - * Format average finish with 1 decimal place - * Input: 3.456 - * Output: "P3.5" - */ -export function formatAvgFinish(avg: number | null): string { - if (avg === null || avg === undefined) return 'P-'; - return `P${avg.toFixed(1)}`; -} - -/** - * Format rating (whole number) - * Input: 1234.56 - * Output: "1235" - */ -export function formatRating(rating: number | null): string { - if (rating === null || rating === undefined) return '0'; - return Math.round(rating).toString(); -} - -/** - * Format consistency percentage - * Input: 87.5 - * Output: "88%" - */ -export function formatConsistency(consistency: number | null): string { - if (consistency === null || consistency === undefined) return '0%'; - return `${Math.round(consistency)}%`; -} - -/** - * Format percentile - * Input: 15.5 - * Output: "Top 16%" - */ -export function formatPercentile(percentile: number | null): string { - if (percentile === null || percentile === undefined) return 'Top -%'; - return `Top ${Math.round(percentile)}%`; -} - -// ============================================================================ -// TEAM ROLE DISPLAY -// ============================================================================ - export interface TeamRoleDisplayData { text: string; badgeClasses: string; } -export const teamRoleDisplay: Record = { - owner: { - text: 'Owner', - badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', - }, - admin: { - text: 'Admin', - badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', - }, - steward: { - text: 'Steward', - badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30', - }, - member: { - text: 'Member', - badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30', - }, -} as const; +export class ProfileDisplay { + private static readonly countryFlagDisplay: Record = { + US: { flag: '🇺🇸', label: 'United States' }, + GB: { flag: '🇬🇧', label: 'United Kingdom' }, + DE: { flag: '🇩🇪', label: 'Germany' }, + FR: { flag: '🇫🇷', label: 'France' }, + IT: { flag: '🇮🇹', label: 'Italy' }, + ES: { flag: '🇪🇸', label: 'Spain' }, + JP: { flag: '🇯🇵', label: 'Japan' }, + AU: { flag: '🇦🇺', label: 'Australia' }, + CA: { flag: '🇨🇦', label: 'Canada' }, + BR: { flag: '🇧🇷', label: 'Brazil' }, + DEFAULT: { flag: '🏁', label: 'Unknown' }, + }; -export function getTeamRoleDisplay(role: string): TeamRoleDisplayData { - return teamRoleDisplay[role] || teamRoleDisplay.member; -} \ No newline at end of file + static getCountryFlag(countryCode: string): CountryFlagDisplayData { + const code = countryCode.toUpperCase(); + return ProfileDisplay.countryFlagDisplay[code] || ProfileDisplay.countryFlagDisplay.DEFAULT; + } + + private static readonly achievementRarityDisplay: Record = { + common: { + text: 'Common', + badgeClasses: 'bg-gray-400/10 text-gray-400', + borderClasses: 'border-gray-400/30', + }, + rare: { + text: 'Rare', + badgeClasses: 'bg-primary-blue/10 text-primary-blue', + borderClasses: 'border-primary-blue/30', + }, + epic: { + text: 'Epic', + badgeClasses: 'bg-purple-400/10 text-purple-400', + borderClasses: 'border-purple-400/30', + }, + legendary: { + text: 'Legendary', + badgeClasses: 'bg-yellow-400/10 text-yellow-400', + borderClasses: 'border-yellow-400/30', + }, + }; + + static getAchievementRarity(rarity: string): AchievementRarityDisplayData { + return ProfileDisplay.achievementRarityDisplay[rarity] || ProfileDisplay.achievementRarityDisplay.common; + } + + private static readonly achievementIconDisplay: Record = { + trophy: { name: 'Trophy' }, + medal: { name: 'Medal' }, + star: { name: 'Star' }, + crown: { name: 'Crown' }, + target: { name: 'Target' }, + zap: { name: 'Zap' }, + }; + + static getAchievementIcon(icon: string): AchievementIconDisplayData { + return ProfileDisplay.achievementIconDisplay[icon] || ProfileDisplay.achievementIconDisplay.trophy; + } + + private static readonly socialPlatformDisplay: Record = { + twitter: { + name: 'Twitter', + hoverClasses: 'hover:text-sky-400 hover:bg-sky-400/10', + }, + youtube: { + name: 'YouTube', + hoverClasses: 'hover:text-red-500 hover:bg-red-500/10', + }, + twitch: { + name: 'Twitch', + hoverClasses: 'hover:text-purple-400 hover:bg-purple-400/10', + }, + discord: { + name: 'Discord', + hoverClasses: 'hover:text-indigo-400 hover:bg-indigo-400/10', + }, + }; + + static getSocialPlatform(platform: string): SocialPlatformDisplayData { + return ProfileDisplay.socialPlatformDisplay[platform] || ProfileDisplay.socialPlatformDisplay.discord; + } + + static formatMonthYear(dateString: string): string { + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getUTCMonth()]; + const year = date.getUTCFullYear(); + return `${month} ${year}`; + } + + static formatMonthDayYear(dateString: string): string { + const date = new Date(dateString); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; + const month = months[date.getUTCMonth()]; + const day = date.getUTCDate(); + const year = date.getUTCFullYear(); + return `${month} ${day}, ${year}`; + } + + static formatPercentage(value: number | null | undefined): string { + if (value === null || value === undefined) return '0.0%'; + return `${(value * 100).toFixed(1)}%`; + } + + static formatFinishPosition(position: number | null | undefined): string { + if (position === null || position === undefined) return 'P-'; + return `P${position}`; + } + + static formatAvgFinish(avg: number | null | undefined): string { + if (avg === null || avg === undefined) return 'P-'; + return `P${avg.toFixed(1)}`; + } + + static formatRating(rating: number | null | undefined): string { + if (rating === null || rating === undefined) return '0'; + return Math.round(rating).toString(); + } + + static formatConsistency(consistency: number | null | undefined): string { + if (consistency === null || consistency === undefined) return '0%'; + return `${Math.round(consistency)}%`; + } + + static formatPercentile(percentile: number | null | undefined): string { + if (percentile === null || percentile === undefined) return 'Top -%'; + return `Top ${Math.round(percentile)}%`; + } + + private static readonly teamRoleDisplay: Record = { + owner: { + text: 'Owner', + badgeClasses: 'bg-yellow-500/10 text-yellow-500 border-yellow-500/30', + }, + admin: { + text: 'Admin', + badgeClasses: 'bg-purple-500/10 text-purple-400 border-purple-500/30', + }, + steward: { + text: 'Steward', + badgeClasses: 'bg-blue-500/10 text-blue-400 border-blue-500/30', + }, + member: { + text: 'Member', + badgeClasses: 'bg-primary-blue/10 text-primary-blue border-primary-blue/30', + }, + }; + + static getTeamRole(role: string): TeamRoleDisplayData { + return ProfileDisplay.teamRoleDisplay[role] || ProfileDisplay.teamRoleDisplay.member; + } +} diff --git a/apps/website/lib/gateways/SessionGateway.test.ts b/apps/website/lib/gateways/SessionGateway.test.ts index cfe153dd5..e28b53b29 100644 --- a/apps/website/lib/gateways/SessionGateway.test.ts +++ b/apps/website/lib/gateways/SessionGateway.test.ts @@ -70,7 +70,7 @@ describe('SessionGateway', () => { // Assert expect(result).toEqual(mockSession); - expect(mockFetch).toHaveBeenCalledWith('http://localhost:3101/auth/session', { + expect(mockFetch).toHaveBeenCalledWith(expect.stringContaining('/auth/session'), { headers: { cookie: 'gp_session=valid-token; other=value' }, cache: 'no-store', credentials: 'include', diff --git a/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts b/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts new file mode 100644 index 000000000..2de010057 --- /dev/null +++ b/apps/website/lib/page-queries/TeamLeaderboardPageQuery.ts @@ -0,0 +1,36 @@ +import { Result } from '@/lib/contracts/Result'; +import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; +import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError'; +import { TeamService } from '@/lib/services/teams/TeamService'; +import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; + +export interface TeamLeaderboardPageData { + teams: TeamSummaryViewModel[]; +} + +export class TeamLeaderboardPageQuery implements PageQuery { + async execute(): Promise> { + try { + const service = new TeamService(); + const result = await service.getAllTeams(); + + if (result.isErr()) { + return Result.err(mapToPresentationError(result.getError())); + } + + const teams = result.unwrap().map(t => ({ + id: t.id, + name: t.name, + logoUrl: t.logoUrl, + memberCount: t.memberCount, + totalWins: t.totalWins, + totalRaces: t.totalRaces, + rating: 1450, // Mocked as in original + } as TeamSummaryViewModel)); + + return Result.ok({ teams }); + } catch (error) { + return Result.err('unknown'); + } + } +} diff --git a/apps/website/lib/queries/ActionsPageQuery.ts b/apps/website/lib/queries/ActionsPageQuery.ts new file mode 100644 index 000000000..f39728b86 --- /dev/null +++ b/apps/website/lib/queries/ActionsPageQuery.ts @@ -0,0 +1,36 @@ +import { Result } from '@/lib/contracts/Result'; + +export interface ActionItem { + id: string; + type: 'USER_UPDATE' | 'ONBOARDING' | 'AVATAR_GEN' | 'PROFILE_UPDATE' | 'LEAGUE_SCHEDULE' | 'SPONSORSHIP'; + status: 'PENDING' | 'COMPLETED' | 'FAILED' | 'IN_PROGRESS'; + timestamp: string; + initiator: string; + details: string; +} + +export class ActionsPageQuery { + async execute(): Promise> { + // Mock data for now + return Result.ok({ + actions: [ + { + id: '1', + type: 'USER_UPDATE', + status: 'COMPLETED', + timestamp: new Date().toISOString(), + initiator: 'Admin', + details: 'Updated status for user 123' + }, + { + id: '2', + type: 'AVATAR_GEN', + status: 'IN_PROGRESS', + timestamp: new Date().toISOString(), + initiator: 'User 456', + details: 'Generating AI avatars' + } + ] + }); + } +} diff --git a/apps/website/lib/services/auth/AuthService.ts b/apps/website/lib/services/auth/AuthService.ts index 530e6f5c9..c86b31b7e 100644 --- a/apps/website/lib/services/auth/AuthService.ts +++ b/apps/website/lib/services/auth/AuthService.ts @@ -10,6 +10,7 @@ import type { SignupParamsDTO } from '@/lib/types/generated/SignupParamsDTO'; import type { ForgotPasswordDTO } from '@/lib/types/generated/ForgotPasswordDTO'; import type { ResetPasswordDTO } from '@/lib/types/generated/ResetPasswordDTO'; import { isProductionEnvironment } from '@/lib/config/env'; +import { SessionViewModel } from '@/lib/view-models/SessionViewModel'; /** * Auth Service @@ -20,41 +21,45 @@ import { isProductionEnvironment } from '@/lib/config/env'; export class AuthService implements Service { private apiClient: AuthApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: false, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - this.apiClient = new AuthApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: AuthApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + this.apiClient = new AuthApiClient(baseUrl, errorReporter, logger); + } } - async login(params: LoginParamsDTO): Promise> { + async login(params: LoginParamsDTO): Promise { try { const dto = await this.apiClient.login(params); - return Result.ok(dto); + return new SessionViewModel(dto.user); } catch (error: unknown) { - return Result.err({ type: 'unauthorized', message: (error as Error).message || 'Login failed' }); + throw error; } } - async signup(params: SignupParamsDTO): Promise> { + async signup(params: SignupParamsDTO): Promise { try { const dto = await this.apiClient.signup(params); - return Result.ok(dto); + return new SessionViewModel(dto.user); } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Signup failed' }); + throw error; } } - async logout(): Promise> { + async logout(): Promise { try { await this.apiClient.logout(); return Result.ok(undefined); } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Logout failed' }); + throw error; } } @@ -76,12 +81,12 @@ export class AuthService implements Service { } } - async getSession(): Promise> { + async getSession(): Promise { try { const dto = await this.apiClient.getSession(); - return Result.ok(dto); + return dto; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch session' }); + throw error; } } } diff --git a/apps/website/lib/services/auth/SessionService.ts b/apps/website/lib/services/auth/SessionService.ts index 275a95f65..3ad401347 100644 --- a/apps/website/lib/services/auth/SessionService.ts +++ b/apps/website/lib/services/auth/SessionService.ts @@ -2,6 +2,7 @@ import { Result } from '@/lib/contracts/Result'; import { DomainError, Service } from '@/lib/contracts/services/Service'; import { AuthService } from './AuthService'; import type { AuthSessionDTO } from '@/lib/types/generated/AuthSessionDTO'; +import { SessionViewModel } from '@/lib/view-models/SessionViewModel'; /** * Session Service @@ -12,14 +13,22 @@ import type { AuthSessionDTO } from '@/lib/types/generated/AuthSessionDTO'; export class SessionService implements Service { private authService: AuthService; - constructor() { - this.authService = new AuthService(); + constructor(apiClient?: any) { + this.authService = new AuthService(apiClient); } /** * Get current user session */ - async getSession(): Promise> { - return this.authService.getSession(); + async getSession(): Promise { + try { + const res = await this.authService.getSession(); + if (!res) return null; + const data = (res as any).value || res; + if (!data || !data.user) return null; + return new SessionViewModel(data.user); + } catch (error: unknown) { + throw error; + } } } diff --git a/apps/website/lib/services/drivers/DriverService.ts b/apps/website/lib/services/drivers/DriverService.ts index b7a09dac0..2b8193c2d 100644 --- a/apps/website/lib/services/drivers/DriverService.ts +++ b/apps/website/lib/services/drivers/DriverService.ts @@ -9,6 +9,9 @@ import { DomainError, Service } from '@/lib/contracts/services/Service'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { DriverLeaderboardViewModel } from '@/lib/view-models/DriverLeaderboardViewModel'; +import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; +import { CompleteOnboardingViewModel } from '@/lib/view-models/CompleteOnboardingViewModel'; /** * Driver Service - DTO Only @@ -19,46 +22,59 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte export class DriverService implements Service { private readonly apiClient: DriversApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger); - this.apiClient = new DriversApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: DriversApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger); + this.apiClient = new DriversApiClient(baseUrl, errorReporter, logger); + } + } + + async getDriver(id: string): Promise { + return this.apiClient.getDriver(id); } /** * Get driver leaderboard (returns DTO) */ - async getDriverLeaderboard(): Promise> { + async getDriverLeaderboard(): Promise { try { - const data = await this.apiClient.getLeaderboard(); - return Result.ok(data); + const res = await this.apiClient.getLeaderboard(); + const data = (res as any).value || res; + return new DriverLeaderboardViewModel(data); } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to get leaderboard' }); + throw error; } } /** * Complete driver onboarding (returns DTO) */ - async completeDriverOnboarding(input: CompleteOnboardingInputDTO): Promise> { + async completeDriverOnboarding(input: CompleteOnboardingInputDTO): Promise { try { - const data = await this.apiClient.completeOnboarding(input); - return Result.ok(data); + const res = await this.apiClient.completeOnboarding(input); + const data = (res as any).value || res; + return new CompleteOnboardingViewModel(data); } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to complete onboarding' }); + throw error; } } /** * Get current driver (returns DTO) */ - async getCurrentDriver(): Promise> { + async getCurrentDriver(): Promise { try { - const data = await this.apiClient.getCurrent(); - return Result.ok(data); + const res = await this.apiClient.getCurrent(); + if (!res) return null; + const data = (res as any).value || res; + if (!data) return null; + return new DriverViewModel(data); } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to get current driver' }); + throw error; } } diff --git a/apps/website/lib/services/home/HomeService.ts b/apps/website/lib/services/home/HomeService.ts index ba5c2b6a3..9810c5057 100644 --- a/apps/website/lib/services/home/HomeService.ts +++ b/apps/website/lib/services/home/HomeService.ts @@ -75,7 +75,7 @@ export class HomeService implements Service { async shouldRedirectToDashboard(): Promise { const sessionService = new SessionService(); - const sessionResult = await sessionService.getSession(); - return sessionResult.isOk() && !!sessionResult.unwrap(); + const session = await sessionService.getSession(); + return !!session; } } diff --git a/apps/website/lib/services/landing/LandingService.ts b/apps/website/lib/services/landing/LandingService.ts index e850dbbea..5d03a8b16 100644 --- a/apps/website/lib/services/landing/LandingService.ts +++ b/apps/website/lib/services/landing/LandingService.ts @@ -8,6 +8,7 @@ import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { isProductionEnvironment } from '@/lib/config/env'; +import { HomeDiscoveryViewModel } from '@/lib/view-models/HomeDiscoveryViewModel'; /** * Landing Service - DTO Only @@ -21,7 +22,7 @@ export class LandingService implements Service { private teamsApi: TeamsApiClient; private authApi: AuthApiClient; - constructor() { + constructor(racesApi?: RacesApiClient, leaguesApi?: LeaguesApiClient, teamsApi?: TeamsApiClient) { const baseUrl = getWebsiteApiBaseUrl(); const logger = new ConsoleLogger(); const errorReporter = new EnhancedErrorReporter(logger, { @@ -30,12 +31,34 @@ export class LandingService implements Service { reportToExternal: isProductionEnvironment(), }); - this.racesApi = new RacesApiClient(baseUrl, errorReporter, logger); - this.leaguesApi = new LeaguesApiClient(baseUrl, errorReporter, logger); - this.teamsApi = new TeamsApiClient(baseUrl, errorReporter, logger); + this.racesApi = racesApi || new RacesApiClient(baseUrl, errorReporter, logger); + this.leaguesApi = leaguesApi || new LeaguesApiClient(baseUrl, errorReporter, logger); + this.teamsApi = teamsApi || new TeamsApiClient(baseUrl, errorReporter, logger); this.authApi = new AuthApiClient(baseUrl, errorReporter, logger); } + async getHomeDiscovery(): Promise { + try { + const [racesRes, leaguesRes, teamsRes] = await Promise.all([ + this.racesApi.getPageData(), + this.leaguesApi.getAllWithCapacity(), + this.teamsApi.getAll(), + ]); + + const racesData = (racesRes as any).value || racesRes; + const leaguesData = (leaguesRes as any).value || leaguesRes; + const teamsData = (teamsRes as any).value || teamsRes; + + return new HomeDiscoveryViewModel({ + topLeagues: leaguesData.leagues.slice(0, 4), + teams: teamsData.teams.slice(0, 4), + upcomingRaces: racesData.races.slice(0, 4), + } as any); + } catch (error: unknown) { + throw error; + } + } + async getLandingData(): Promise }, DomainError>> { return Result.ok({ featuredLeagues: [], stats: {} }); } diff --git a/apps/website/lib/services/leagues/LeagueMembershipService.ts b/apps/website/lib/services/leagues/LeagueMembershipService.ts index 76cf3c0b7..f44bcba6b 100644 --- a/apps/website/lib/services/leagues/LeagueMembershipService.ts +++ b/apps/website/lib/services/leagues/LeagueMembershipService.ts @@ -5,9 +5,9 @@ import { Service, type DomainError } from '@/lib/contracts/services/Service'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; import { isProductionEnvironment } from '@/lib/config/env'; +import { LeagueMemberViewModel } from '@/lib/view-models/LeagueMemberViewModel'; import type { LeagueRosterMemberDTO } from '@/lib/types/generated/LeagueRosterMemberDTO'; import type { LeagueRosterJoinRequestDTO } from '@/lib/types/generated/LeagueRosterJoinRequestDTO'; -import type { LeagueMembershipDTO } from '@/lib/types/generated/LeagueMembershipDTO'; export interface LeagueRosterAdminData { leagueId: string; @@ -20,15 +20,40 @@ export class LeagueMembershipService implements Service { // eslint-disable-next-line @typescript-eslint/no-explicit-any private static cachedMemberships = new Map(); - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: false, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: LeaguesApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); + } + } + + async getLeagueMemberships(leagueId: string, currentUserId: string): Promise { + const res = await this.apiClient.getMemberships(leagueId); + const members = (res as any).members || res; + return members.map((m: any) => new LeagueMemberViewModel({ ...m, currentUserId }, currentUserId as any)); + } + + async removeMember(leagueId: string, performerDriverId: string, targetDriverId: string): Promise { + const res = await this.apiClient.removeMember(leagueId, performerDriverId, targetDriverId); + return (res as any).value || res; + } + + async removeRosterMember(leagueId: string, targetDriverId: string): Promise> { + try { + const res = await this.apiClient.removeRosterMember(leagueId, targetDriverId); + const dto = (res as any).value || res; + return Result.ok({ success: dto.success }); + } catch (error: unknown) { + return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to remove member' }); + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -101,18 +126,10 @@ export class LeagueMembershipService implements Service { } } - async removeMember(leagueId: string, targetDriverId: string): Promise> { - try { - const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId); - return Result.ok({ success: dto.success }); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to remove member' }); - } - } - async approveJoinRequest(leagueId: string, joinRequestId: string): Promise> { try { - const dto = await this.apiClient.approveRosterJoinRequest(leagueId, joinRequestId); + const res = await this.apiClient.approveRosterJoinRequest(leagueId, joinRequestId); + const dto = (res as any).value || res; return Result.ok({ success: dto.success }); } catch (error: unknown) { return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to approve join request' }); @@ -121,7 +138,8 @@ export class LeagueMembershipService implements Service { async rejectJoinRequest(leagueId: string, joinRequestId: string): Promise> { try { - const dto = await this.apiClient.rejectRosterJoinRequest(leagueId, joinRequestId); + const res = await this.apiClient.rejectRosterJoinRequest(leagueId, joinRequestId); + const dto = (res as any).value || res; return Result.ok({ success: dto.success }); } catch (error: unknown) { return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to reject join request' }); diff --git a/apps/website/lib/services/leagues/LeagueService.ts b/apps/website/lib/services/leagues/LeagueService.ts index 4d5da49f1..8e0d4eb0d 100644 --- a/apps/website/lib/services/leagues/LeagueService.ts +++ b/apps/website/lib/services/leagues/LeagueService.ts @@ -56,25 +56,82 @@ export class LeagueService implements Service { private sponsorsApiClient?: SponsorsApiClient; private racesApiClient?: RacesApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: false, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: LeaguesApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: false, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + this.apiClient = new LeaguesApiClient(baseUrl, errorReporter, logger); + } // Optional clients can be initialized if needed } - async getAllLeagues(): Promise> { + async getLeagueStandings(leagueId: string): Promise { + try { + const data = await this.apiClient.getStandings(leagueId); + return (data as any).value || data; + } catch (error: unknown) { + throw error; + } + } + + async getLeagueStats(): Promise { + try { + const data = await this.apiClient.getTotal(); + return (data as any).value || data; + } catch (error: unknown) { + throw error; + } + } + + async getLeagueSchedule(leagueId: string): Promise { + try { + const data = await this.apiClient.getSchedule(leagueId); + return (data as any).value || data; + } catch (error: unknown) { + throw error; + } + } + + async getLeagueMemberships(leagueId: string): Promise { + try { + const data = await this.apiClient.getMemberships(leagueId); + return (data as any).value || data; + } catch (error: unknown) { + throw error; + } + } + + async createLeague(input: CreateLeagueInputDTO): Promise { + try { + const data = await this.apiClient.create(input); + return (data as any).value || data; + } catch (error: unknown) { + throw error; + } + } + + async removeMember(leagueId: string, targetDriverId: string): Promise { + try { + const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId); + return { success: dto.success }; + } catch (error: unknown) { + throw error; + } + } + + async getAllLeagues(): Promise { try { const dto = await this.apiClient.getAllWithCapacityAndScoring(); - return Result.ok(dto); + return (dto as any).value || dto; } catch (error: unknown) { - console.error('LeagueService.getAllLeagues failed:', error); - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch leagues' }); + throw error; } } @@ -142,28 +199,6 @@ export class LeagueService implements Service { } } - async getLeagueStandings(): Promise> { - return Result.err({ type: 'notImplemented', message: 'League standings endpoint not implemented' }); - } - - async getLeagueStats(): Promise> { - try { - const data = await this.apiClient.getTotal(); - return Result.ok(data); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch league stats' }); - } - } - - async getLeagueSchedule(leagueId: string): Promise> { - try { - const data = await this.apiClient.getSchedule(leagueId); - return Result.ok(data); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch league schedule' }); - } - } - async getLeagueSeasons(leagueId: string): Promise> { try { const data = await this.apiClient.getSeasons(leagueId); @@ -289,33 +324,6 @@ export class LeagueService implements Service { return this.deleteAdminScheduleRace(leagueId, seasonId, raceId); } - async getLeagueMemberships(leagueId: string): Promise> { - try { - const data = await this.apiClient.getMemberships(leagueId); - return Result.ok(data); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to fetch memberships' }); - } - } - - async createLeague(input: CreateLeagueInputDTO): Promise> { - try { - const data = await this.apiClient.create(input); - return Result.ok(data); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to create league' }); - } - } - - async removeMember(leagueId: string, targetDriverId: string): Promise> { - try { - const dto = await this.apiClient.removeRosterMember(leagueId, targetDriverId); - return Result.ok({ success: dto.success }); - } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to remove member' }); - } - } - async updateMemberRole(leagueId: string, targetDriverId: string, newRole: MembershipRole): Promise> { try { const dto = await this.apiClient.updateRosterMemberRole(leagueId, targetDriverId, newRole); diff --git a/apps/website/lib/services/leagues/LeagueSettingsService.ts b/apps/website/lib/services/leagues/LeagueSettingsService.ts index 6ed743c2c..59351816f 100644 --- a/apps/website/lib/services/leagues/LeagueSettingsService.ts +++ b/apps/website/lib/services/leagues/LeagueSettingsService.ts @@ -1,10 +1,64 @@ import { Result } from '@/lib/contracts/Result'; import { Service, type DomainError } from '@/lib/contracts/services/Service'; import { type LeagueSettingsApiDto } from '@/lib/types/tbd/LeagueSettingsApiDto'; +import { LeaguesApiClient } from '@/lib/api/leagues/LeaguesApiClient'; +import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; +import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; export class LeagueSettingsService implements Service { private static cachedMemberships = new Map(); + constructor( + private readonly leaguesApiClient?: LeaguesApiClient, + private readonly driversApiClient?: DriversApiClient, + ) {} + + async getLeagueSettings(leagueId: string): Promise { + if (!this.leaguesApiClient || !this.driversApiClient) { + return null; + } + + try { + const [leaguesRes, configRes, presetsRes, leaderboardRes, membershipsRes] = await Promise.all([ + this.leaguesApiClient.getAllWithCapacity(), + this.leaguesApiClient.getLeagueConfig(leagueId), + this.leaguesApiClient.getScoringPresets(), + this.driversApiClient.getLeaderboard(), + this.leaguesApiClient.getMemberships(leagueId), + ]); + + const leaguesData = (leaguesRes as any).value || leaguesRes; + const configData = (configRes as any).value || configRes; + const presetsData = (presetsRes as any).value || presetsRes; + const leaderboardData = (leaderboardRes as any).value || leaderboardRes; + const membershipsData = (membershipsRes as any).value || membershipsRes; + + const league = leaguesData.leagues.find((l: any) => l.id === leagueId); + if (!league) return null; + + const ownerRes = await this.driversApiClient.getDriver(league.ownerId); + const owner = (ownerRes as any).value || ownerRes; + + return new LeagueSettingsViewModel({ + league, + config: configData.config || configData, + presets: presetsData.presets, + owner, + members: membershipsData.members, + drivers: leaderboardData.drivers, + } as any); + } catch (error) { + return null; + } + } + + async transferOwnership(leagueId: string, currentOwnerId: string, newOwnerId: string): Promise { + if (!this.leaguesApiClient) throw new Error('API client not initialized'); + const res = await this.leaguesApiClient.transferOwnership(leagueId, currentOwnerId, newOwnerId); + const data = (res as any).value || res; + return data.success; + } + async getSettingsData(leagueId: string): Promise> { // Mock data since backend not implemented const mockData: LeagueSettingsApiDto = { diff --git a/apps/website/lib/services/leagues/LeagueStewardingService.ts b/apps/website/lib/services/leagues/LeagueStewardingService.ts index 32f8fbe43..0e645f507 100644 --- a/apps/website/lib/services/leagues/LeagueStewardingService.ts +++ b/apps/website/lib/services/leagues/LeagueStewardingService.ts @@ -1,8 +1,86 @@ import { Result } from '@/lib/contracts/Result'; import { Service, type DomainError } from '@/lib/contracts/services/Service'; import { type StewardingApiDto } from '@/lib/types/tbd/StewardingApiDto'; +import { RaceService } from '../races/RaceService'; +import { ProtestService } from '../protests/ProtestService'; +import { PenaltyService } from '../penalties/PenaltyService'; +import { DriverService } from '../drivers/DriverService'; +import { LeagueMembershipService } from './LeagueMembershipService'; +import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel'; export class LeagueStewardingService implements Service { + constructor( + private readonly raceService?: RaceService, + private readonly protestService?: ProtestService, + private readonly penaltyService?: PenaltyService, + private readonly driverService?: DriverService, + private readonly leagueMembershipService?: LeagueMembershipService, + ) {} + + async getLeagueStewardingData(leagueId: string): Promise { + if (!this.raceService || !this.protestService || !this.penaltyService || !this.driverService) { + return new LeagueStewardingViewModel([], {}); + } + + const racesRes = await this.raceService.findByLeagueId(leagueId); + const races = (racesRes as any).value || racesRes; + const racesWithData = await Promise.all( + races.map(async (race: any) => { + const [protestsRes, penaltiesRes] = await Promise.all([ + this.protestService!.findByRaceId(race.id), + this.penaltyService!.findByRaceId(race.id), + ]); + const protests = (protestsRes as any).value || protestsRes; + const penalties = (penaltiesRes as any).value || penaltiesRes; + return { + race: { + id: race.id, + track: race.track, + scheduledAt: new Date(race.scheduledAt), + }, + pendingProtests: protests.filter((p: any) => p.status === 'pending' || p.status === 'under_review'), + resolvedProtests: protests.filter((p: any) => p.status !== 'pending' && p.status !== 'under_review'), + penalties: penalties, + }; + }), + ); + + const driverIds = new Set(); + racesWithData.forEach((r: any) => { + r.pendingProtests.forEach((p: any) => { + driverIds.add(p.protestingDriverId); + driverIds.add(p.accusedDriverId); + }); + r.resolvedProtests.forEach((p: any) => { + driverIds.add(p.protestingDriverId); + driverIds.add(p.accusedDriverId); + }); + r.penalties.forEach((p: any) => driverIds.add(p.driverId)); + }); + + const driversRes = await this.driverService.findByIds(Array.from(driverIds)); + const drivers = (driversRes as any).value || driversRes; + + const driverMap: Record = {}; + drivers.forEach((d: any) => { + driverMap[d.id] = d; + }); + + return new LeagueStewardingViewModel(racesWithData as any, driverMap); + } + + async reviewProtest(input: any): Promise { + if (this.protestService) { + await this.protestService.reviewProtest(input); + } + } + + async applyPenalty(input: any): Promise { + if (this.penaltyService) { + await this.penaltyService.applyPenalty(input); + } + } + async getStewardingData(leagueId: string): Promise> { // Mock data since backend not implemented const mockData: StewardingApiDto = { @@ -16,7 +94,22 @@ export class LeagueStewardingService implements Service { return Result.ok(mockData); } - async getProtestDetailViewModel(_: string, __: string): Promise> { - return Result.err({ type: 'notImplemented', message: 'getProtestDetailViewModel' }); + async getProtestDetailViewModel(leagueId: string, protestId: string): Promise { + if (!this.protestService || !this.penaltyService) return null; + + const [protestRes, penaltyTypesRes] = await Promise.all([ + this.protestService.getProtestById(leagueId, protestId), + this.penaltyService.getPenaltyTypesReference(), + ]); + + const protestData = (protestRes as any).value || protestRes; + const penaltyTypesData = (penaltyTypesRes as any).value || penaltyTypesRes; + + return { + ...protestData, + penaltyTypes: penaltyTypesData.penaltyTypes, + defaultReasons: penaltyTypesData.defaultReasons, + initialPenaltyType: penaltyTypesData.penaltyTypes[0]?.type, + }; } } diff --git a/apps/website/lib/services/leagues/LeagueWalletService.ts b/apps/website/lib/services/leagues/LeagueWalletService.ts index 15973aaf5..8df320a0b 100644 --- a/apps/website/lib/services/leagues/LeagueWalletService.ts +++ b/apps/website/lib/services/leagues/LeagueWalletService.ts @@ -1,9 +1,16 @@ import { Result } from '@/lib/contracts/Result'; import { Service, DomainError } from '@/lib/contracts/services/Service'; import { LeagueWalletApiDto } from '@/lib/types/tbd/LeagueWalletApiDto'; +import { WalletsApiClient } from '@/lib/api/wallets/WalletsApiClient'; export class LeagueWalletService implements Service { + constructor(private readonly apiClient?: WalletsApiClient) {} + async getWalletForLeague(leagueId: string): Promise { + if (this.apiClient) { + const res = await this.apiClient.getLeagueWallet(leagueId); + return ((res as any).value || res) as any; + } const result = await this.getWalletData(leagueId); if (result.isErr()) throw new Error(result.getError().message); return result.unwrap(); @@ -14,8 +21,17 @@ export class LeagueWalletService implements Service { amount: number, currency: string, seasonId: string, - destinationId: string + destinationAccount: string ): Promise<{ success: boolean; message?: string }> { + if (this.apiClient) { + const res = await this.apiClient.withdrawFromLeagueWallet(leagueId, { + amount, + currency, + seasonId, + destinationAccount, + }); + return (res as any).value || res; + } // Mock implementation return { success: true }; } diff --git a/apps/website/lib/services/media/MediaService.test.ts b/apps/website/lib/services/media/MediaService.test.ts index b4c59ee17..dc4b647f8 100644 --- a/apps/website/lib/services/media/MediaService.test.ts +++ b/apps/website/lib/services/media/MediaService.test.ts @@ -1,9 +1,12 @@ -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; import { MediaService } from './MediaService'; -import { Result } from '@/lib/contracts/Result'; // Simple test that verifies the service structure describe('MediaService', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_API_BASE_URL', 'http://localhost:3001'); + }); + it('should be defined', () => { expect(MediaService).toBeDefined(); }); diff --git a/apps/website/lib/services/penalties/PenaltyService.ts b/apps/website/lib/services/penalties/PenaltyService.ts index ea25fd16e..cc35552fa 100644 --- a/apps/website/lib/services/penalties/PenaltyService.ts +++ b/apps/website/lib/services/penalties/PenaltyService.ts @@ -15,22 +15,27 @@ import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporte export class PenaltyService implements Service { private readonly apiClient: PenaltiesApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger); - this.apiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: PenaltiesApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger); + this.apiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + } } /** * Find penalties by race ID */ - async findByRaceId(raceId: string): Promise> { + async findByRaceId(raceId: string): Promise { try { - const dto = await this.apiClient.getRacePenalties(raceId); - return Result.ok(dto.penalties); + const res = await this.apiClient.getRacePenalties(raceId); + const data = (res as any).value || res; + return data.penalties; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to find penalties' }); + throw error; } } @@ -49,13 +54,13 @@ export class PenaltyService implements Service { /** * Apply a penalty */ - async applyPenalty(input: unknown): Promise> { + async applyPenalty(input: unknown): Promise { try { // eslint-disable-next-line @typescript-eslint/no-explicit-any - await this.apiClient.applyPenalty(input as any); - return Result.ok(undefined); + const res = await this.apiClient.applyPenalty(input as any); + return (res as any)?.value || res; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to apply penalty' }); + throw error; } } } diff --git a/apps/website/lib/services/protests/ProtestService.ts b/apps/website/lib/services/protests/ProtestService.ts index 0170338e6..37dee1524 100644 --- a/apps/website/lib/services/protests/ProtestService.ts +++ b/apps/website/lib/services/protests/ProtestService.ts @@ -7,74 +7,100 @@ import { DomainError, Service } from '@/lib/contracts/services/Service'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ProtestViewModel } from '@/lib/view-models/ProtestViewModel'; +import { RaceViewModel } from '@/lib/view-models/RaceViewModel'; +import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel'; /** * Protest Service - DTO Only * * Returns raw API DTOs. No ViewModels or UX logic. * All client-side presentation logic must be handled by hooks/components. + * @server-safe */ export class ProtestService implements Service { private readonly apiClient: ProtestsApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger); - this.apiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: ProtestsApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger); + this.apiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); + } } - async getLeagueProtests(leagueId: string): Promise> { + async getLeagueProtests(leagueId: string): Promise { try { const data = await this.apiClient.getLeagueProtests(leagueId); - return Result.ok(data); + const protests = (data as any).protests || []; + return { + ...data, + protests: protests.map((p: any) => new ProtestViewModel(p)), + }; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to get league protests' }); + throw error; } } - async getProtestById(leagueId: string, protestId: string): Promise> { + async getProtestById(leagueId: string, protestId: string): Promise { try { const data = await this.apiClient.getLeagueProtest(leagueId, protestId); - return Result.ok(data); + const protests = (data as any).protests || []; + const protest = protests.find((p: any) => p.id === protestId); + if (!protest) return null; + + const raceData = (data as any).racesById?.[protest.raceId] || (data as any).races?.[0] || Object.values((data as any).racesById || {})[0]; + const protestingDriverData = (data as any).driversById?.[protest.protestingDriverId] || (data as any).drivers?.[0] || Object.values((data as any).driversById || {})[0]; + const accusedDriverData = (data as any).driversById?.[protest.accusedDriverId] || (data as any).drivers?.[1] || Object.values((data as any).driversById || {})[1]; + + return { + protest: new ProtestViewModel(protest), + race: raceData ? new RaceViewModel(raceData) : null, + protestingDriver: protestingDriverData ? new ProtestDriverViewModel(protestingDriverData) : null, + accusedDriver: accusedDriverData ? new ProtestDriverViewModel(accusedDriverData) : null, + }; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to get protest' }); + throw error; } } - async applyPenalty(input: ApplyPenaltyCommandDTO): Promise> { + async applyPenalty(input: ApplyPenaltyCommandDTO): Promise { try { - await this.apiClient.applyPenalty(input); - return Result.ok(undefined); + const res = await this.apiClient.applyPenalty(input); + return (res as any)?.value || res; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to apply penalty' }); + throw error; } } - async requestDefense(input: RequestProtestDefenseCommandDTO): Promise> { + async requestDefense(input: RequestProtestDefenseCommandDTO): Promise { try { - await this.apiClient.requestDefense(input); - return Result.ok(undefined); + const res = await this.apiClient.requestDefense(input); + return (res as any)?.value || res; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to request defense' }); + throw error; } } - async reviewProtest(input: ReviewProtestCommandDTO): Promise> { + async reviewProtest(input: ReviewProtestCommandDTO): Promise { try { - await this.apiClient.reviewProtest(input); - return Result.ok(undefined); + const res = await this.apiClient.reviewProtest(input); + return (res as any)?.value || res; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to review protest' }); + throw error; } } - async findByRaceId(raceId: string): Promise> { + async findByRaceId(raceId: string): Promise { try { - const data = await this.apiClient.getRaceProtests(raceId); - return Result.ok(data); + const res = await this.apiClient.getRaceProtests(raceId); + const data = (res as any).value || res; + return data.protests; } catch (error: unknown) { - return Result.err({ type: 'serverError', message: (error as Error).message || 'Failed to find protests' }); + throw error; } } } diff --git a/apps/website/lib/services/races/RaceResultsService.ts b/apps/website/lib/services/races/RaceResultsService.ts index a464d9aeb..b922fb0be 100644 --- a/apps/website/lib/services/races/RaceResultsService.ts +++ b/apps/website/lib/services/races/RaceResultsService.ts @@ -5,6 +5,9 @@ import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { ApiError } from '@/lib/api/base/ApiError'; +import { RaceResultsDetailViewModel } from '@/lib/view-models/RaceResultsDetailViewModel'; +import { RaceWithSOFViewModel } from '@/lib/view-models/RaceWithSOFViewModel'; +import { ImportRaceResultsSummaryViewModel } from '@/lib/view-models/ImportRaceResultsSummaryViewModel'; /** * Race Results Service @@ -15,13 +18,29 @@ import { ApiError } from '@/lib/api/base/ApiError'; export class RaceResultsService implements Service { private apiClient: RacesApiClient; - constructor() { - // Service creates its own dependencies - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new ConsoleErrorReporter(); - - this.apiClient = new RacesApiClient(baseUrl, errorReporter, logger); + constructor(apiClient?: RacesApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + // Service creates its own dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new ConsoleErrorReporter(); + + this.apiClient = new RacesApiClient(baseUrl, errorReporter, logger); + } + } + + async getResultsDetail(raceId: string, currentUserId?: string): Promise { + const res = await this.getRaceResultsDetail(raceId); + if (res.isErr()) throw new Error((res as any).error.message); + const data = (res as any).value; + return new RaceResultsDetailViewModel({ ...data, currentUserId: (currentUserId === undefined || currentUserId === null) ? '' : currentUserId }, {} as any); + } + + async importResults(raceId: string, input: any): Promise { + const res = await this.apiClient.importResults(raceId, input); + return new ImportRaceResultsSummaryViewModel(res); } /** @@ -50,21 +69,12 @@ export class RaceResultsService implements Service { * Get race with strength of field * Returns race data with SOF calculation */ - async getWithSOF(raceId: string): Promise> { + async getWithSOF(raceId: string): Promise { try { const data = await this.apiClient.getWithSOF(raceId); - return Result.ok(data); + return new RaceWithSOFViewModel(data); } catch (error: unknown) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: (error as Error).message || 'Failed to fetch race SOF' - }); + throw error; } } diff --git a/apps/website/lib/services/races/RaceStewardingService.ts b/apps/website/lib/services/races/RaceStewardingService.ts index e7373092a..6e53be6a4 100644 --- a/apps/website/lib/services/races/RaceStewardingService.ts +++ b/apps/website/lib/services/races/RaceStewardingService.ts @@ -7,6 +7,7 @@ import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; import { ApiError } from '@/lib/api/base/ApiError'; +import { RaceStewardingViewModel } from '@/lib/view-models/RaceStewardingViewModel'; /** * Race Stewarding Service @@ -19,26 +20,52 @@ export class RaceStewardingService implements Service { private protestsApiClient: ProtestsApiClient; private penaltiesApiClient: PenaltiesApiClient; - constructor() { - // Service creates its own dependencies - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new ConsoleErrorReporter(); - - this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); - this.protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); - this.penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + constructor(racesApiClient?: RacesApiClient, protestsApiClient?: ProtestsApiClient, penaltiesApiClient?: PenaltiesApiClient) { + if (racesApiClient && protestsApiClient && penaltiesApiClient) { + this.racesApiClient = racesApiClient; + this.protestsApiClient = protestsApiClient; + this.penaltiesApiClient = penaltiesApiClient; + } else { + // Service creates its own dependencies + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new ConsoleErrorReporter(); + + this.racesApiClient = new RacesApiClient(baseUrl, errorReporter, logger); + this.protestsApiClient = new ProtestsApiClient(baseUrl, errorReporter, logger); + this.penaltiesApiClient = new PenaltiesApiClient(baseUrl, errorReporter, logger); + } + } + + async getRaceStewardingData(raceId: string, driverId: string): Promise { + const res = await this.getRaceStewarding(raceId, driverId); + if (res.isErr()) throw new Error((res as any).error.message); + const data = (res as any).value; + return new RaceStewardingViewModel({ + raceDetail: { + race: data.race, + league: data.league, + }, + protests: { + protests: data.protests, + driverMap: data.driverMap, + }, + penalties: { + penalties: data.penalties, + driverMap: data.driverMap, + }, + } as any); } /** * Get race stewarding data * Returns protests and penalties for a race */ - async getRaceStewarding(raceId: string): Promise> { + async getRaceStewarding(raceId: string, driverId: string = ''): Promise> { try { // Fetch data in parallel const [raceDetail, protests, penalties] = await Promise.all([ - this.racesApiClient.getDetail(raceId, ''), + this.racesApiClient.getDetail(raceId, driverId), this.protestsApiClient.getRaceProtests(raceId), this.penaltiesApiClient.getRacePenalties(raceId), ]); diff --git a/apps/website/lib/services/teams/TeamJoinService.ts b/apps/website/lib/services/teams/TeamJoinService.ts index 57767d781..0888e3f4d 100644 --- a/apps/website/lib/services/teams/TeamJoinService.ts +++ b/apps/website/lib/services/teams/TeamJoinService.ts @@ -16,33 +16,38 @@ import { isProductionEnvironment } from '@/lib/config/env'; export class TeamJoinService implements Service { private apiClient: TeamsApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger); - } - - async getJoinRequests(teamId: string, currentDriverId: string, isOwner: boolean): Promise> { - try { - const result = await this.apiClient.getJoinRequests(teamId); - return Result.ok(result.requests.map(request => - new TeamJoinRequestViewModel(request, currentDriverId, isOwner) - )); - } catch (error: any) { - return Result.err({ type: 'serverError', message: error.message || 'Failed to fetch join requests' }); + constructor(apiClient?: TeamsApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger); } } - async approveJoinRequest(): Promise> { - return Result.err({ type: 'notImplemented', message: 'Not implemented: API endpoint for approving join requests' }); + async getJoinRequests(teamId: string, currentDriverId: string, isOwner: boolean): Promise { + try { + const result = await this.apiClient.getJoinRequests(teamId); + const requests = (result as any).requests || result; + return requests.map((request: any) => + new TeamJoinRequestViewModel(request, currentDriverId, isOwner) + ); + } catch (error: any) { + throw error; + } } - async rejectJoinRequest(): Promise> { - return Result.err({ type: 'notImplemented', message: 'Not implemented: API endpoint for rejecting join requests' }); + async approveJoinRequest(): Promise { + throw new Error('Not implemented: API endpoint for approving join requests'); + } + + async rejectJoinRequest(): Promise { + throw new Error('Not implemented: API endpoint for rejecting join requests'); } } diff --git a/apps/website/lib/services/teams/TeamService.ts b/apps/website/lib/services/teams/TeamService.ts index b110be229..d4b45888a 100644 --- a/apps/website/lib/services/teams/TeamService.ts +++ b/apps/website/lib/services/teams/TeamService.ts @@ -8,6 +8,8 @@ import type { GetDriverTeamOutputDTO } from '@/lib/types/generated/GetDriverTeam import type { GetTeamMembershipOutputDTO } from '@/lib/types/generated/GetTeamMembershipOutputDTO'; import type { GetTeamJoinRequestsOutputDTO } from '@/lib/types/generated/GetTeamJoinRequestsOutputDTO'; import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel'; +import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; +import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel'; import { Result } from '@/lib/contracts/Result'; import { DomainError, Service } from '@/lib/contracts/services/Service'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; @@ -24,80 +26,111 @@ import { isProductionEnvironment } from '@/lib/config/env'; export class TeamService implements Service { private apiClient: TeamsApiClient; - constructor() { - const baseUrl = getWebsiteApiBaseUrl(); - const logger = new ConsoleLogger(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger); - } - - async getAllTeams(): Promise> { - try { - const result = await this.apiClient.getAll(); - return Result.ok(result.teams); - } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to fetch teams' }); + constructor(apiClient?: TeamsApiClient) { + if (apiClient) { + this.apiClient = apiClient; + } else { + const baseUrl = getWebsiteApiBaseUrl(); + const logger = new ConsoleLogger(); + const errorReporter = new EnhancedErrorReporter(logger, { + showUserNotifications: true, + logToConsole: true, + reportToExternal: isProductionEnvironment(), + }); + this.apiClient = new TeamsApiClient(baseUrl, errorReporter, logger); } } - async getTeamDetails(teamId: string, _: string): Promise> { - try { - const result = await this.apiClient.getDetails(teamId); - if (!result) { - return Result.err({ type: 'notFound', message: 'Team not found' }); - } - return Result.ok(result); - } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to fetch team details' }); - } - } - - async getTeamMembers(teamId: string, currentDriverId: string, ownerId: string): Promise> { + async getMembers(teamId: string): Promise> { try { const result = await this.apiClient.getMembers(teamId); - return Result.ok(result.members.map(member => new TeamMemberViewModel(member, currentDriverId, ownerId))); + return Result.ok(result); } catch (error: unknown) { return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to fetch team members' }); } } - async getTeamJoinRequests(teamId: string): Promise> { + async update(teamId: string, input: UpdateTeamInputDTO): Promise> { + return this.updateTeam(teamId, input); + } + + async create(input: CreateTeamInputDTO): Promise> { + return this.createTeam(input); + } + + async getTeamDetails(teamId: string, _: string): Promise { + try { + const result = await this.apiClient.getDetails(teamId); + if (!result) { + return null; + } + const data = (result as any).value || result; + return new TeamDetailsViewModel(data, {} as any); + } catch (error: unknown) { + throw error; + } + } + + async getTeamMembers(teamId: string, currentDriverId: string, ownerId: string): Promise { + try { + const result = await this.apiClient.getMembers(teamId); + const members = (result as any).members || result; + return members.map((member: any) => new TeamMemberViewModel(member, currentDriverId, ownerId)); + } catch (error: unknown) { + throw error; + } + } + + async getTeamJoinRequests(teamId: string): Promise { try { const result = await this.apiClient.getJoinRequests(teamId); - return Result.ok(result); + return (result as any).value || result; } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to fetch team join requests' }); + throw error; } } - async createTeam(input: CreateTeamInputDTO): Promise> { + async createTeam(input: CreateTeamInputDTO): Promise { try { const result = await this.apiClient.create(input); - return Result.ok(result); + return (result as any).value || result; } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to create team' }); + throw error; } } - async updateTeam(teamId: string, input: UpdateTeamInputDTO): Promise> { + async updateTeam(teamId: string, input: UpdateTeamInputDTO): Promise { try { const result = await this.apiClient.update(teamId, input); - return Result.ok(result); + return (result as any).value || result; } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to update team' }); + throw error; } } - async getDriverTeam(driverId: string): Promise> { + async getDriverTeam(driverId: string): Promise { try { const result = await this.apiClient.getDriverTeam(driverId); - return Result.ok(result); + if (!result) return null; + const data = (result as any).value || result; + if (!data.team) return null; + return { + teamId: data.team.id, + teamName: data.team.name, + role: data.membership?.role, + }; } catch (error: unknown) { - return Result.err({ type: 'unknown', message: (error as Error).message || 'Failed to fetch driver team' }); + throw error; + } + } + + async getAllTeams(): Promise { + try { + const result = await this.apiClient.getAll(); + const teams = (result as any).teams || result; + return teams.map((t: any) => new TeamSummaryViewModel(t)); + } catch (error: unknown) { + throw error; } } diff --git a/apps/website/lib/view-data/ActionsViewData.ts b/apps/website/lib/view-data/ActionsViewData.ts new file mode 100644 index 000000000..582a26415 --- /dev/null +++ b/apps/website/lib/view-data/ActionsViewData.ts @@ -0,0 +1,5 @@ +import { ActionItem } from '@/lib/queries/ActionsPageQuery'; + +export interface ActionsViewData { + actions: ActionItem[]; +} diff --git a/apps/website/lib/view-data/MediaViewData.ts b/apps/website/lib/view-data/MediaViewData.ts new file mode 100644 index 000000000..cf22679df --- /dev/null +++ b/apps/website/lib/view-data/MediaViewData.ts @@ -0,0 +1,8 @@ +import { MediaAsset } from '@/components/media/MediaGallery'; + +export interface MediaViewData { + assets: MediaAsset[]; + categories: { label: string; value: string }[]; + title: string; + description?: string; +} diff --git a/apps/website/lib/view-data/ProfileLayoutViewData.ts b/apps/website/lib/view-data/ProfileLayoutViewData.ts new file mode 100644 index 000000000..6a40bf0b2 --- /dev/null +++ b/apps/website/lib/view-data/ProfileLayoutViewData.ts @@ -0,0 +1,3 @@ +export interface ProfileLayoutViewData { + // Empty for now +} diff --git a/apps/website/lib/view-data/ProfileLiveriesViewData.ts b/apps/website/lib/view-data/ProfileLiveriesViewData.ts new file mode 100644 index 000000000..3babc39af --- /dev/null +++ b/apps/website/lib/view-data/ProfileLiveriesViewData.ts @@ -0,0 +1,12 @@ +export interface ProfileLiveryViewData { + id: string; + carId: string; + carName: string; + thumbnailUrl: string; + uploadedAt: Date; + isValidated: boolean; +} + +export interface ProfileLiveriesViewData { + liveries: ProfileLiveryViewData[]; +} diff --git a/apps/website/tailwind.config.js b/apps/website/tailwind.config.js index 867f437bf..4814f6092 100644 --- a/apps/website/tailwind.config.js +++ b/apps/website/tailwind.config.js @@ -10,37 +10,38 @@ module.exports = { ], theme: { extend: { - backgroundImage: { - 'radial-gradient': 'radial-gradient(var(--tw-gradient-stops))', - }, colors: { - 'graphite-black': '#0C0D0F', - 'panel-gray': '#141619', - 'border-gray': '#23272B', - 'primary-accent': '#198CFF', - 'telemetry-aqua': '#4ED4E0', - 'warning-amber': '#FFBE4D', - 'success-green': '#6FE37A', - 'critical-red': '#E35C5C', - // Legacy mappings for compatibility during transition - 'deep-graphite': '#0C0D0F', - 'iron-gray': '#141619', - 'charcoal-outline': '#23272B', - 'primary-blue': '#198CFF', - 'performance-green': '#6FE37A', - 'racing-red': '#E35C5C', + // Theme-aligned colors using CSS variables + 'base-black': 'var(--color-base)', + 'surface-charcoal': 'var(--color-surface)', + 'outline-steel': 'var(--color-outline)', + 'primary-accent': 'var(--color-primary)', + 'telemetry-aqua': 'var(--color-telemetry)', + 'warning-amber': 'var(--color-warning)', + 'success-green': 'var(--color-success)', + 'critical-red': 'var(--color-critical)', + + // Legacy mappings for compatibility + 'deep-graphite': 'var(--color-base)', + 'iron-gray': 'var(--color-surface)', + 'charcoal-outline': 'var(--color-outline)', + 'primary-blue': 'var(--color-primary)', + 'performance-green': 'var(--color-success)', + 'racing-red': 'var(--color-critical)', }, fontFamily: { sans: ['Inter', 'system-ui', 'sans-serif'], }, boxShadow: { 'card': '0 4px 12px rgba(0, 0, 0, 0.2)', + 'focus': '0 0 0 4px var(--color-focus-ring)', }, transitionDuration: { 'smooth': '150ms', }, transitionTimingFunction: { 'smooth': 'cubic-bezier(0.4, 0, 0.2, 1)', + 'racing': 'cubic-bezier(0.16, 1, 0.3, 1)', }, }, }, diff --git a/apps/website/templates/AdminDashboardTemplate.tsx b/apps/website/templates/AdminDashboardTemplate.tsx index e2e4a6f0e..8eb5a3ff5 100644 --- a/apps/website/templates/AdminDashboardTemplate.tsx +++ b/apps/website/templates/AdminDashboardTemplate.tsx @@ -6,7 +6,6 @@ import { routes } from '@/lib/routing/RouteConfig'; import { Card } from '@/ui/Card'; import { Text } from '@/ui/Text'; import { Button } from '@/ui/Button'; -import { StatCard } from '@/ui/StatCard'; import { QuickActionLink } from '@/ui/QuickActionLink'; import { StatusBadge } from '@/ui/StatusBadge'; import { Box } from '@/ui/Box'; @@ -14,20 +13,24 @@ import { Stack } from '@/ui/Stack'; import { Container } from '@/ui/Container'; import { Grid } from '@/ui/Grid'; import { Icon } from '@/ui/Icon'; -import { Heading } from '@/ui/Heading'; +import { AdminHeaderPanel } from '@/components/admin/AdminHeaderPanel'; +import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel'; +import { AdminSectionHeader } from '@/components/admin/AdminSectionHeader'; +import { AdminDangerZonePanel } from '@/components/admin/AdminDangerZonePanel'; import { Users, Shield, Activity, Clock, - RefreshCw + RefreshCw, + ArrowRight } from 'lucide-react'; /** * AdminDashboardTemplate * * Pure template for admin dashboard. - * Accepts ViewData only, no business logic. + * Redesigned for "Precision Racing Minimal" theme. */ export function AdminDashboardTemplate({ viewData, @@ -38,113 +41,130 @@ export function AdminDashboardTemplate({ onRefresh: () => void; isLoading: boolean; }) { + const stats = [ + { + label: 'Total Users', + value: viewData.stats.totalUsers, + icon: Users, + variant: 'blue' as const + }, + { + label: 'System Admins', + value: viewData.stats.systemAdmins, + icon: Shield, + variant: 'purple' as const + }, + { + label: 'Active Users', + value: viewData.stats.activeUsers, + icon: Activity, + variant: 'green' as const + }, + { + label: 'Recent Logins', + value: viewData.stats.recentLogins, + icon: Clock, + variant: 'orange' as const + } + ]; + return ( - - - {/* Header */} - - - Admin Dashboard - - System overview and statistics - - - } - > - Refresh - - + + + } + > + Refresh Telemetry + + } + /> - {/* Stats Cards */} - - - - - - + - {/* System Status */} - - - System Status - - - - System Health - - - Healthy - - - - - Suspended Users - - - {viewData.stats.suspendedUsers} - - - - - Deleted Users - - - {viewData.stats.deletedUsers} - - - - - New Users Today - - - {viewData.stats.newUsersToday} - + + {/* System Health & Status */} + + + + Operational + + } + /> + + + + + + Suspended Users + {viewData.stats.suspendedUsers} + + + + + + Deleted Users + {viewData.stats.deletedUsers} + + + + + + New Registrations (24h) + {viewData.stats.newUsersToday} + + - - + - {/* Quick Actions */} - - - Quick Actions - - - View All Users - - - Manage Admins - - - View Audit Log - - - - + {/* Quick Operations */} + + + + + + + User Management + + + + + + Security & Roles + + + + + + System Audit Logs + + + + + + + + + + + Enter Maintenance Mode + + ); diff --git a/apps/website/templates/AdminUsersTemplate.tsx b/apps/website/templates/AdminUsersTemplate.tsx index 79b5aae77..2caf4d4f8 100644 --- a/apps/website/templates/AdminUsersTemplate.tsx +++ b/apps/website/templates/AdminUsersTemplate.tsx @@ -1,28 +1,20 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; -import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; -import { Button } from '@/ui/Button'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Container } from '@/ui/Container'; -import { Icon } from '@/ui/Icon'; -import { StatusBadge } from '@/ui/StatusBadge'; -import { InfoBox } from '@/ui/InfoBox'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; -import { - RefreshCw, - Shield, - Trash2, - Users -} from 'lucide-react'; import { AdminUsersViewData } from '@/lib/view-data/AdminUsersViewData'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { RefreshCw, Users, ShieldAlert } from 'lucide-react'; +import { AdminHeaderPanel } from '@/components/admin/AdminHeaderPanel'; +import { AdminStatsPanel } from '@/components/admin/AdminStatsPanel'; import { UserFilters } from '@/components/admin/UserFilters'; -import { UserStatsSummary } from '@/components/admin/UserStatsSummary'; -import { Surface } from '@/ui/Surface'; +import { AdminUsersTable } from '@/components/admin/AdminUsersTable'; +import { BulkActionBar } from '@/components/admin/BulkActionBar'; +import { InlineNotice } from '@/components/shared/ux/InlineNotice'; +import { AdminDataTable } from '@/components/admin/AdminDataTable'; +import { AdminEmptyState } from '@/components/admin/AdminEmptyState'; interface AdminUsersTemplateProps { viewData: AdminUsersViewData; @@ -39,8 +31,20 @@ interface AdminUsersTemplateProps { loading: boolean; error: string | null; deletingUser: string | null; + // Selection state passed from wrapper + selectedUserIds: string[]; + onSelectUser: (userId: string) => void; + onSelectAll: () => void; + onClearSelection: () => void; } +/** + * AdminUsersTemplate + * + * Redesigned user management page. + * Uses semantic admin UI blocks and follows "Precision Racing Minimal" theme. + * Stateless template. + */ export function AdminUsersTemplate({ viewData, onRefresh, @@ -55,56 +59,81 @@ export function AdminUsersTemplate({ statusFilter, loading, error, - deletingUser + deletingUser, + selectedUserIds, + onSelectUser, + onSelectAll, + onClearSelection }: AdminUsersTemplateProps) { - const getStatusBadgeVariant = (status: string): 'success' | 'warning' | 'error' | 'info' => { - switch (status) { - case 'active': return 'success'; - case 'suspended': return 'warning'; - case 'deleted': return 'error'; - default: return 'info'; + const stats = [ + { + label: 'Total Users', + value: viewData.total, + icon: Users, + variant: 'blue' as const + }, + { + label: 'Active Users', + value: viewData.activeUserCount, + icon: RefreshCw, + variant: 'green' as const + }, + { + label: 'System Admins', + value: viewData.adminCount, + icon: ShieldAlert, + variant: 'purple' as const } - }; + ]; - const getRoleBadgeProps = (role: string): { bg: string; color: string; borderColor: string } => { - switch (role) { - case 'owner': return { bg: 'bg-purple-500/20', color: '#d8b4fe', borderColor: 'border-purple-500/30' }; - case 'admin': return { bg: 'bg-blue-500/20', color: '#93c5fd', borderColor: 'border-blue-500/30' }; - default: return { bg: 'bg-neutral-500/20', color: '#d1d5db', borderColor: 'border-neutral-500/30' }; + const bulkActions = [ + { + label: 'Suspend Selected', + onClick: () => { + console.log('Bulk suspend', selectedUserIds); + }, + variant: 'secondary' as const + }, + { + label: 'Delete Selected', + onClick: () => { + console.log('Bulk delete', selectedUserIds); + }, + variant: 'danger' as const } - }; + ]; return ( - - - {/* Header */} - - - User Management - Manage and monitor all system users - - } - > - Refresh - - + + + } + > + Refresh Data + + } + /> - {/* Error Banner */} {error && ( - )} - {/* Filters Card */} - + + - {/* Users Table */} - - {loading ? ( - - - Loading users... - - ) : !viewData.users || viewData.users.length === 0 ? ( - - - No users found - - Clear filters - - + + {viewData.users.length === 0 && !loading ? ( + + Clear All Filters + + } + /> ) : ( - - - - User - Email - Roles - Status - Last Login - Actions - - - - {viewData.users.map((user) => ( - - - - - - - - {user.displayName} - ID: {user.id} - {user.primaryDriverId && ( - Driver: {user.primaryDriverId} - )} - - - - - {user.email} - - - - {user.roles.map((role, idx) => { - const badgeProps = getRoleBadgeProps(role); - return ( - - {role.charAt(0).toUpperCase() + role.slice(1)} - - ); - })} - - - - - {user.status.charAt(0).toUpperCase() + user.status.slice(1)} - - - - - {user.lastLoginAt ? DateDisplay.formatShort(user.lastLoginAt) : 'Never'} - - - - - {user.status === 'active' && ( - onUpdateStatus(user.id, 'suspended')} - variant="secondary" - size="sm" - > - Suspend - - )} - {user.status === 'suspended' && ( - onUpdateStatus(user.id, 'active')} - variant="secondary" - size="sm" - > - Activate - - )} - {user.status !== 'deleted' && ( - onDeleteUser(user.id)} - disabled={deletingUser === user.id} - variant="secondary" - size="sm" - icon={} - > - {deletingUser === user.id ? 'Deleting...' : 'Delete'} - - )} - - - - ))} - - + )} - + - {/* Stats Summary */} - {viewData.users.length > 0 && ( - - )} + ); diff --git a/apps/website/templates/DashboardTemplate.tsx b/apps/website/templates/DashboardTemplate.tsx index ed49d2126..59a63093d 100644 --- a/apps/website/templates/DashboardTemplate.tsx +++ b/apps/website/templates/DashboardTemplate.tsx @@ -2,64 +2,211 @@ import React from 'react'; import type { DashboardViewData } from '@/lib/view-data/DashboardViewData'; +import { DashboardShell } from '@/components/dashboard/DashboardShell'; +import { DashboardRail } from '@/components/dashboard/DashboardRail'; +import { DashboardControlBar } from '@/components/dashboard/DashboardControlBar'; +import { TelemetryPanel } from '@/components/dashboard/TelemetryPanel'; +import { DashboardKpiRow } from '@/components/dashboard/DashboardKpiRow'; +import { RecentActivityTable, type ActivityItem } from '@/components/dashboard/RecentActivityTable'; +import { LayoutDashboard, Trophy, Calendar, Users, Settings, Bell, Search } from 'lucide-react'; +import { useRouter } from 'next/navigation'; +import { routes } from '@/lib/routing/RouteConfig'; import { Box } from '@/ui/Box'; -import { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; -import { GridItem } from '@/ui/GridItem'; -import { DashboardHero } from '@/components/dashboard/DashboardHeroWrapper'; -import { NextRaceCard } from '@/components/races/NextRaceCardWrapper'; -import { ChampionshipStandings } from '@/components/leagues/ChampionshipStandings'; -import { ActivityFeed } from '@/components/feed/ActivityFeed'; -import { UpcomingRaces } from '@/components/races/UpcomingRaces'; -import { FriendsSidebar } from '@/components/social/FriendsSidebar'; import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { IconButton } from '@/ui/IconButton'; +import { Avatar } from '@/ui/Avatar'; +import { Grid } from '@/ui/Grid'; +import { Button } from '@/ui/Button'; interface DashboardTemplateProps { viewData: DashboardViewData; } +/** + * DashboardTemplate + * + * Redesigned as a "Telemetry Workspace" following the Precision Racing Minimal theme. + * Composes semantic dashboard components into a high-density data environment. + * Complies with architectural constraints by using UI primitives. + */ export function DashboardTemplate({ viewData }: DashboardTemplateProps) { + const router = useRouter(); const { currentDriver, nextRace, upcomingRaces, leagueStandings, feedItems, - friends, activeLeaguesCount, - hasUpcomingRaces, hasLeagueStandings, hasFeedItems, - hasFriends, } = viewData; - return ( - - ({ + id: item.id, + type: item.type.toUpperCase(), + description: item.headline, + timestamp: item.formattedTime, + status: item.type === 'race_result' ? 'success' : 'info' + })); + + const railContent = ( + + + + GP + + router.push(routes.protected.dashboard)} + variant="ghost" + color="primary-accent" + /> + router.push(routes.public.leagues)} + variant="ghost" + color="var(--color-text-low)" + /> + router.push(routes.public.races)} + variant="ghost" + color="var(--color-text-low)" + /> + router.push(routes.public.teams)} + variant="ghost" + color="var(--color-text-low)" + /> + + + router.push(routes.protected.profile)} + variant="ghost" + color="var(--color-text-low)" + /> + + + ); + + const controlBarActions = ( + + + + + + + + + ); - - - {/* Left Column - Main Content */} - - - {nextRace && } - {hasLeagueStandings && } - - - + return ( + } + > + {/* KPI Overview */} + - {/* Right Column - Sidebar */} - - - - - - - - - + + {/* Main Content Column */} + + + {nextRace && ( + + + + Next Event + {nextRace.track} + {nextRace.car} + + + Starts In + {nextRace.timeUntil} + {nextRace.formattedDate} @ {nextRace.formattedTime} + + + + )} + + + {hasFeedItems ? ( + + ) : ( + + No recent activity recorded. + + )} + + + + + {/* Sidebar Column */} + + + + {hasLeagueStandings ? ( + + {leagueStandings.map((standing) => ( + + + {standing.leagueName} + Pos: {standing.position} / {standing.totalDrivers} + + {standing.points} PTS + + ))} + + ) : ( + + No active championships. + + )} + + + + + {upcomingRaces.slice(0, 3).map((race) => ( + + + {race.track} + {race.timeUntil} + + + {race.car} + {race.formattedDate} + + + ))} + router.push(routes.public.races)} + > + View Full Schedule + + + + + + + ); } diff --git a/apps/website/templates/DriverProfileTemplate.tsx b/apps/website/templates/DriverProfileTemplate.tsx index 9ec66839c..993b6bd42 100644 --- a/apps/website/templates/DriverProfileTemplate.tsx +++ b/apps/website/templates/DriverProfileTemplate.tsx @@ -1,26 +1,26 @@ 'use client'; -import { RatingBreakdown } from '@/ui/RatingBreakdown'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { AchievementGrid } from '@/components/achievements/AchievementGrid'; -import { CareerStats } from '@/ui/CareerStats'; -import { FriendsPreview } from '@/components/social/FriendsPreview'; -import { PerformanceOverview } from '@/ui/PerformanceOverview'; -import { ProfileBio } from '@/ui/ProfileBio'; -import { ProfileHero } from '@/components/drivers/ProfileHero'; -import { ProfileTabs, type ProfileTab } from '@/ui/ProfileTabs'; -import { RacingProfile } from '@/ui/RacingProfile'; -import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid'; +import React from 'react'; +import { ArrowLeft } from 'lucide-react'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; import { Box } from '@/ui/Box'; import { Button } from '@/ui/Button'; -import { Container } from '@/ui/Container'; +import { Breadcrumbs } from '@/ui/Breadcrumbs'; import { LoadingSpinner } from '@/ui/LoadingSpinner'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { - ArrowLeft, -} from 'lucide-react'; -import React from 'react'; +import { AchievementGrid } from '@/components/achievements/AchievementGrid'; +import { FriendsPreview } from '@/components/social/FriendsPreview'; +import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid'; +import { RatingBreakdown } from '@/ui/RatingBreakdown'; + +import { DriverProfileHeader } from '@/components/drivers/DriverProfileHeader'; +import { DriverStatsPanel } from '@/components/drivers/DriverStatsPanel'; +import { DriverProfileTabs, type ProfileTab } from '@/components/drivers/DriverProfileTabs'; +import { DriverPerformanceOverview } from '@/components/drivers/DriverPerformanceOverview'; +import { DriverRacingProfile } from '@/components/drivers/DriverRacingProfile'; +import { CareerStats } from '@/ui/CareerStats'; + import type { DriverProfileViewData } from '@/lib/types/view-data/DriverProfileViewData'; interface DriverProfileTemplateProps { @@ -74,48 +74,57 @@ export function DriverProfileTemplate({ const { currentDriver, stats, teamMemberships, socialSummary, extendedProfile } = viewData; + const careerStats = stats ? [ + { label: 'Rating', value: stats.rating || 0, color: 'text-primary-blue' }, + { label: 'Wins', value: stats.wins, color: 'text-performance-green' }, + { label: 'Podiums', value: stats.podiums, color: 'text-warning-amber' }, + { label: 'Total Races', value: stats.totalRaces }, + { label: 'Avg Finish', value: stats.avgFinish?.toFixed(1) || '-', subValue: 'POS' }, + { label: 'Consistency', value: stats.consistency ? `${stats.consistency}%` : '-' }, + ] : []; + return ( - {/* Back Navigation */} - - } - > - Back to Drivers - - - - {/* Breadcrumb */} - + {/* Back Navigation & Breadcrumbs */} + + + } + > + Back to Drivers + + + + {/* Sponsor Insights Card */} {isSponsorMode && sponsorInsights} {/* Hero Header Section */} - - {/* Bio Section */} - {currentDriver.bio && } + {/* Stats Grid */} + {careerStats.length > 0 && ( + + )} {/* Team Memberships */} {teamMemberships.length > 0 && ( @@ -128,31 +137,28 @@ export function DriverProfileTemplate({ /> )} - {/* Performance Overview */} - {stats && ( - - )} - {/* Tab Navigation */} - + {/* Tab Content */} {activeTab === 'overview' && ( - + {stats && ( + + )} {extendedProfile && ( - )} - {activeTab === 'stats' && !stats && ( - - No statistics available yet - This driver hasn't completed any races yet + {activeTab === 'stats' && ( + + {stats ? ( + + ) : ( + + No statistics available yet + This driver hasn't completed any races yet + + )} )} diff --git a/apps/website/templates/DriverRankingsTemplate.tsx b/apps/website/templates/DriverRankingsTemplate.tsx index 298185cac..cad95d52a 100644 --- a/apps/website/templates/DriverRankingsTemplate.tsx +++ b/apps/website/templates/DriverRankingsTemplate.tsx @@ -1,81 +1,67 @@ 'use client'; import React from 'react'; -import { Trophy, ArrowLeft } from 'lucide-react'; -import { Button } from '@/ui/Button'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; +import { Trophy } from 'lucide-react'; import { Container } from '@/ui/Container'; -import { Icon } from '@/ui/Icon'; -import { Surface } from '@/ui/Surface'; import type { DriverRankingsViewData } from '@/lib/view-data/DriverRankingsViewData'; -import { RankingsPodium } from '@/ui/RankingsPodium'; -import { RankingsTable } from '@/ui/RankingsTable'; +import { LeaderboardHeader } from '@/components/leaderboards/LeaderboardHeader'; +import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar'; +import { LeaderboardPodium } from '@/components/leaderboards/LeaderboardPodium'; +import { LeaderboardTable } from '@/components/leaderboards/LeaderboardTable'; interface DriverRankingsTemplateProps { viewData: DriverRankingsViewData; + searchQuery: string; + onSearchChange: (query: string) => void; onDriverClick?: (id: string) => void; onBackToLeaderboards?: () => void; } export function DriverRankingsTemplate({ viewData, + searchQuery, + onSearchChange, onDriverClick, onBackToLeaderboards, }: DriverRankingsTemplateProps): React.ReactElement { return ( - - {/* Header */} - - {onBackToLeaderboards && ( - - } - > - Back to Leaderboards - - - )} + - - - - - - Driver Leaderboard - Full rankings of all drivers by performance metrics - - - - - {/* Top 3 Podium */} - {viewData.podium.length > 0 && ( - ({ - ...d, - rating: Number(d.rating), - wins: Number(d.wins), - podiums: Number(d.podiums) - }))} - onDriverClick={onDriverClick} - /> - )} - - {/* Leaderboard Table */} - ({ + {/* Top 3 Podium */} + {viewData.podium.length > 0 && !searchQuery && ( + ({ ...d, rating: Number(d.rating), - wins: Number(d.wins) + wins: Number(d.wins), + podiums: Number(d.podiums) }))} onDriverClick={onDriverClick} /> - + )} + + + + {/* Leaderboard Table */} + ({ + ...d, + rating: Number(d.rating), + wins: Number(d.wins) + }))} + onDriverClick={onDriverClick} + /> ); } diff --git a/apps/website/templates/DriversTemplate.tsx b/apps/website/templates/DriversTemplate.tsx index ed089c019..bee49146d 100644 --- a/apps/website/templates/DriversTemplate.tsx +++ b/apps/website/templates/DriversTemplate.tsx @@ -1,30 +1,14 @@ 'use client'; import React from 'react'; -import { - Search, - Crown, - Users, - Trophy, -} from 'lucide-react'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; +import { Search } from 'lucide-react'; import { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; -import { GridItem } from '@/ui/GridItem'; -import { Surface } from '@/ui/Surface'; -import { Icon } from '@/ui/Icon'; -import { FeaturedDriverCard } from '@/components/drivers/FeaturedDriverCard'; -import { SkillDistribution } from '@/ui/SkillDistribution'; -import { CategoryDistribution } from '@/ui/CategoryDistribution'; -import { LeaderboardPreview } from '@/components/leaderboards/LeaderboardPreview'; -import { RecentActivity } from '@/components/feed/RecentActivity'; -import { PageHero } from '@/ui/PageHero'; -import { DriversSearch } from '@/ui/DriversSearch'; +import { Stack } from '@/ui/Stack'; +import { DriversDirectoryHeader } from '@/components/drivers/DriversDirectoryHeader'; +import { DriverSearchBar } from '@/components/drivers/DriverSearchBar'; +import { DriverTable } from '@/components/drivers/DriverTable'; +import { DriverTableRow } from '@/components/drivers/DriverTableRow'; import { EmptyState } from '@/components/shared/state/EmptyState'; -import { NumberDisplay } from '@/lib/display-objects/NumberDisplay'; import type { DriversViewData } from '@/lib/types/view-data/DriversViewData'; interface DriversTemplateProps { @@ -49,80 +33,34 @@ export function DriversTemplate({ const totalWins = viewData?.totalWins || 0; const activeCount = viewData?.activeCount || 0; - // Featured drivers (top 4) - const featuredDrivers = filteredDrivers.slice(0, 4); - return ( - {/* Hero Section */} - - {/* Search */} - + - {/* Featured Drivers */} - {!searchQuery && ( - - - - - - - Featured Drivers - Top performers on the grid - - + + {filteredDrivers.map((driver, index) => ( + onDriverClick(driver.id)} + /> + ))} + - - {featuredDrivers.map((driver, index) => ( - - onDriverClick(driver.id)} - /> - - ))} - - - )} - - {/* Active Drivers */} - {!searchQuery && } - - {/* Skill Distribution */} - {!searchQuery && } - - {/* Category Distribution */} - {!searchQuery && } - - {/* Leaderboard Preview */} - onViewLeaderboard()} - /> - - {/* Empty State */} {filteredDrivers.length === 0 && ( { + const mockError = new Error('Fatal system error'); + const mockViewData: FatalErrorViewData = { + error: mockError + }; + + const mockReset = vi.fn(); + const mockOnHome = vi.fn(); + + it('renders the error message via ErrorScreen', () => { + render(); + + expect(screen.getByText('Fatal system error')).toBeDefined(); + expect(screen.getByText('System Malfunction')).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); + }); +}); diff --git a/apps/website/templates/FatalErrorTemplate.tsx b/apps/website/templates/FatalErrorTemplate.tsx new file mode 100644 index 000000000..fa9517c11 --- /dev/null +++ b/apps/website/templates/FatalErrorTemplate.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { ErrorScreen } from '@/components/errors/ErrorScreen'; + +export interface FatalErrorViewData { + error: Error & { digest?: string }; +} + +interface FatalErrorTemplateProps { + viewData: FatalErrorViewData; + reset: () => void; + onHome: () => void; +} + +/** + * FatalErrorTemplate + * + * The top-most error template for the global error boundary. + * Follows "Precision Racing Minimal" theme via ErrorScreen. + */ +export function FatalErrorTemplate({ viewData, reset, onHome }: FatalErrorTemplateProps) { + return ( + + ); +} diff --git a/apps/website/templates/HomeTemplate.tsx b/apps/website/templates/HomeTemplate.tsx index 804db90ee..638a857f5 100644 --- a/apps/website/templates/HomeTemplate.tsx +++ b/apps/website/templates/HomeTemplate.tsx @@ -1,23 +1,28 @@ 'use client'; -import { AlternatingSection } from '@/components/landing/AlternatingSection'; +import React from 'react'; +import { HomeHeader } from '@/components/home/HomeHeader'; +import { HomeStatsStrip } from '@/components/home/HomeStatsStrip'; +import { QuickLinksPanel } from '@/components/home/QuickLinksPanel'; +import { HomeFeatureSection } from '@/components/home/HomeFeatureSection'; +import { RecentRacesPanel } from '@/components/home/RecentRacesPanel'; +import { LeagueSummaryPanel } from '@/components/home/LeagueSummaryPanel'; +import { TeamSummaryPanel } from '@/components/home/TeamSummaryPanel'; +import { HomeFeatureDescription } from '@/components/home/HomeFeatureDescription'; import { FAQ } from '@/components/landing/FAQ'; -import { FeatureGrid } from '@/components/landing/FeatureGrid'; -import { LandingHero } from '@/components/landing/LandingHero'; -import { DiscoverySection } from '@/components/landing/DiscoverySection'; -import { FeatureItem, ResultItem, StepItem } from '@/ui/LandingItems'; -import { CareerProgressionMockup } from '@/components/mockups/CareerProgressionMockup'; -import { CompanionAutomationMockup } from '@/components/mockups/CompanionAutomationMockup'; -import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup'; -import { SimPlatformMockup } from '@/components/mockups/SimPlatformMockup'; -import { ModeGuard } from '@/components/shared/ModeGuard'; -import { Box } from '@/ui/Box'; -import { DiscordCTA } from '@/ui/DiscordCTA'; +import { HomeFooterCTA } from '@/components/home/HomeFooterCTA'; import { Footer } from '@/ui/Footer'; -import { Stack } from '@/ui/Stack'; +import { ModeGuard } from '@/components/shared/ModeGuard'; +import { CareerProgressionMockup } from '@/components/mockups/CareerProgressionMockup'; +import { RaceHistoryMockup } from '@/components/mockups/RaceHistoryMockup'; +import { CompanionAutomationMockup } from '@/components/mockups/CompanionAutomationMockup'; +import { SimPlatformMockup } from '@/components/mockups/SimPlatformMockup'; +import { Box } from '@/ui/Box'; +import { Container } from '@/ui/Container'; +import { Heading } from '@/ui/Heading'; import { Text } from '@/ui/Text'; -import { TelemetryLine } from '@/ui/TelemetryLine'; -import { Glow } from '@/ui/Glow'; +import { Grid } from '@/ui/Grid'; +import { Section } from '@/ui/Section'; export interface HomeViewData { isAlpha: boolean; @@ -44,138 +49,135 @@ interface HomeTemplateProps { viewData: HomeViewData; } +/** + * HomeTemplate - Redesigned for "Precision Racing Minimal" theme. + * Composes semantic components instead of generic layout primitives. + */ export function HomeTemplate({ viewData }: HomeTemplateProps) { return ( - - - - - - + + {/* Hero Section */} + - {/* Section 1: A Persistent Identity */} - - - - - Your races, your seasons, your progress — finally in one place. - - - - - - - - - iRacing gives you physics. GridPilot gives you a career. - - - - } - mockup={} - layout="text-left" - /> - + {/* Telemetry Status Strip */} + - + {/* Quick Actions Bar */} + - {/* Section 2: Results That Actually Stay */} - - - - - Every race you run stays with you. - - - - - - - - - Your racing career, finally in one place. - - - - } - mockup={} - layout="text-right" - /> - + {/* Feature Sections */} + + } + mockup={} + /> - + + } + mockup={} + /> - {/* Section 3: Automatic Session Creation */} - - - - - Setting up league races used to mean clicking through iRacing's wizard 20 times. - - - - - - - - - Automation instead of repetition. - - - - } - mockup={} - layout="text-left" - /> - + + } + mockup={} + /> - {/* Section 4: Game-Agnostic Platform */} - - - - - Right now, we're focused on making iRacing league racing better. - - - But sims come and go. Your leagues, your teams, your rating — those stay. - - - - GridPilot is built to outlast any single platform. When the next sim arrives, your competitive identity moves with you. - - - - } - mockup={} - layout="text-right" - /> - + + } + mockup={} + /> - {/* Alpha-only discovery section */} + {/* Discovery Grid */} - - - - + + + + + + Live Ecosystem + + + + DISCOVER THE GRID + + + Explore leagues, teams, and races that make up the GridPilot ecosystem. + + + + + + + + + + - + {/* CTA & FAQ */} + + + {/* Footer */} ); diff --git a/apps/website/templates/LeaderboardsTemplate.tsx b/apps/website/templates/LeaderboardsTemplate.tsx index cddcd0c1f..4fe886f0a 100644 --- a/apps/website/templates/LeaderboardsTemplate.tsx +++ b/apps/website/templates/LeaderboardsTemplate.tsx @@ -1,7 +1,6 @@ 'use client'; import React from 'react'; -import { Box } from '@/ui/Box'; import { Container } from '@/ui/Container'; import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; @@ -27,14 +26,12 @@ export function LeaderboardsTemplate({ }: LeaderboardsTemplateProps) { return ( - - - + - + void; @@ -54,6 +56,7 @@ export function LeagueAdminScheduleTemplate({ isPublishing, isSaving, isDeleting, + error, setTrack, setCar, setScheduledAtIso, @@ -67,6 +70,13 @@ export function LeagueAdminScheduleTemplate({ return ( + {error && ( + + )} diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx index d8eec7bf2..37ed9bf07 100644 --- a/apps/website/templates/LeagueDetailTemplate.tsx +++ b/apps/website/templates/LeagueDetailTemplate.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { Container } from '@/ui/Container'; -import { Heading } from '@/ui/Heading'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { LeagueTabs } from '@/components/leagues/LeagueTabs'; +import { LeagueHeader } from '@/components/leagues/LeagueHeader'; +import { LeagueNavTabs } from '@/components/leagues/LeagueNavTabs'; import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; +import { Link } from '@/ui/Link'; +import { ChevronRight } from 'lucide-react'; +import { usePathname } from 'next/navigation'; interface Tab { label: string; @@ -27,30 +27,38 @@ export function LeagueDetailTemplate({ tabs, children, }: LeagueDetailTemplateProps) { + const pathname = usePathname(); + return ( - - - + + {/* Breadcrumbs */} + + + Home + + + + Leagues + + + {viewData.name} + + + - - {viewData.name} - - {viewData.description} - - + - - - + {children} - - + + ); } diff --git a/apps/website/templates/LeagueOverviewTemplate.tsx b/apps/website/templates/LeagueOverviewTemplate.tsx new file mode 100644 index 000000000..bd7599de8 --- /dev/null +++ b/apps/website/templates/LeagueOverviewTemplate.tsx @@ -0,0 +1,111 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import type { LeagueDetailViewData } from '@/lib/view-data/LeagueDetailViewData'; +import { Trophy, Users, Calendar, Shield, type LucideIcon } from 'lucide-react'; + +interface LeagueOverviewTemplateProps { + viewData: LeagueDetailViewData; +} + +export function LeagueOverviewTemplate({ viewData }: LeagueOverviewTemplateProps) { + return ( + + {/* Main Content */} + + + + About the League + + + {viewData.description || 'No description provided for this league.'} + + + + + + Quick Stats + + + + + + + + + + + {/* Sidebar */} + + + + Management + + + {viewData.ownerSummary && ( + + + + + + Owner + {viewData.ownerSummary.driverName} + + + )} + + Admins + + {viewData.adminSummaries.map(admin => ( + + {admin.driverName} + + ))} + {viewData.adminSummaries.length === 0 && No admins assigned} + + + + + + + + Sponsors + + + {viewData.sponsors.length > 0 ? ( + viewData.sponsors.map(sponsor => ( + + + + + {sponsor.name} + + )) + ) : ( + No active sponsors + )} + + + + + + + ); +} + +function StatCard({ icon: Icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) { + return ( + + + + + + {label} + {value} + + + ); +} diff --git a/apps/website/templates/LeagueRulebookTemplate.tsx b/apps/website/templates/LeagueRulebookTemplate.tsx index 852802831..f9fe0176c 100644 --- a/apps/website/templates/LeagueRulebookTemplate.tsx +++ b/apps/website/templates/LeagueRulebookTemplate.tsx @@ -1,19 +1,18 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Heading } from '@/ui/Heading'; -import { Badge } from '@/ui/Badge'; import { Grid } from '@/ui/Grid'; -import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; import { PointsTable } from '@/ui/PointsTable'; import { RulebookTabs, type RulebookSection } from '@/ui/RulebookTabs'; import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData'; import { Surface } from '@/ui/Surface'; -import { Clock } from 'lucide-react'; +import { Book, Shield, Scale, AlertTriangle, Info, Clock, type LucideIcon } from 'lucide-react'; +import { Icon } from '@/ui/Icon'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; interface LeagueRulebookTemplateProps { viewData: LeagueRulebookViewData; @@ -30,21 +29,20 @@ export function LeagueRulebookTemplate({ }: LeagueRulebookTemplateProps) { if (loading) { return ( - - - Loading rulebook... - - + + Loading rulebook... + ); } if (!viewData || !viewData.scoringConfig) { return ( - - + + + Unable to load rulebook - + ); } @@ -54,17 +52,6 @@ export function LeagueRulebookTemplate({ return ( - {/* Header */} - - - Rulebook - Official rules and regulations - - - {scoringConfig.scoringPresetName || 'Custom Rules'} - - - {/* Navigation Tabs */} @@ -72,177 +59,158 @@ export function LeagueRulebookTemplate({ {activeSection === 'scoring' && ( {/* Quick Stats */} - - - - - + + + + + {/* Weekend Structure */} - + - - Weekend Structure & Timings + + WEEKEND STRUCTURE - - - PRACTICE - 20 min - - - QUALIFYING - 30 min - - - SPRINT - — - - - MAIN RACE - 40 min - + + + + + - + {/* Points Table */} {/* Bonus Points */} {primaryChampionship?.bonusSummary && primaryChampionship.bonusSummary.length > 0 && ( - + - Bonus Points + BONUS POINTS {primaryChampionship.bonusSummary.map((bonus, idx) => ( - - - - + - + + + + + + {bonus} - + ))} - - )} - - {/* Drop Policy */} - {!scoringConfig.dropPolicySummary.includes('All results count') && ( - - - Drop Policy - {scoringConfig.dropPolicySummary} - - - Drop rules are applied automatically when calculating championship standings. - - - - + )} )} {activeSection === 'conduct' && ( - - - Driver Conduct + + + + + DRIVER CONDUCT + - - + )} {activeSection === 'protests' && ( - - - Protest Process + + + + + PROTEST PROCESS + - - + )} {activeSection === 'penalties' && ( - - - Penalty Guidelines - - - - - Infraction - Typical Penalty - - - - - - - - - - - - - - Penalties are applied at steward discretion based on incident severity and driver history. - - + + + + + PENALTY GUIDELINES + + + + Infraction + Typical Penalty + + + + + + + + + - + )} ); } -function StatItem({ label, value }: { label: string, value: string | number }) { +function StatItem({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string | number, color: string }) { return ( - - {label.toUpperCase()} - {value} + + + + + + + {label.toUpperCase()} + {value} + + ); } +function TimingItem({ label, value }: { label: string, value: string }) { + return ( + + {label} + {value} + + ); +} + function ConductItem({ number, title, text }: { number: number, title: string, text: string }) { return ( - - {number}. {title} - {text} + + {number}. {title} + {text} ); } -function PenaltyRow({ infraction, penalty, color }: { infraction: string, penalty: string, color?: string }) { +function PenaltyRow({ infraction, penalty, isSevere }: { infraction: string, penalty: string, isSevere?: boolean }) { return ( {infraction} - {penalty} + {penalty} ); diff --git a/apps/website/templates/LeagueScheduleTemplate.tsx b/apps/website/templates/LeagueScheduleTemplate.tsx index 14ecb65c5..2c4e07499 100644 --- a/apps/website/templates/LeagueScheduleTemplate.tsx +++ b/apps/website/templates/LeagueScheduleTemplate.tsx @@ -2,27 +2,32 @@ import React from 'react'; import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; +import { LeagueSchedulePanel } from '@/components/leagues/LeagueSchedulePanel'; import type { LeagueScheduleViewData } from '@/lib/view-data/leagues/LeagueScheduleViewData'; -import { LeagueSchedule } from '@/components/leagues/LeagueSchedule'; interface LeagueScheduleTemplateProps { viewData: LeagueScheduleViewData; } export function LeagueScheduleTemplate({ viewData }: LeagueScheduleTemplateProps) { + const events = viewData.races.map(race => ({ + id: race.id, + title: race.name || `Race ${race.id.substring(0, 4)}`, + trackName: race.track || 'TBA', + date: race.scheduledAt, + time: new Date(race.scheduledAt).toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }), + status: (race.status as 'upcoming' | 'live' | 'completed') || 'upcoming' + })); + return ( - - - Race Schedule - - Upcoming and completed races for this season - + + + Race Schedule + Upcoming and past events for this season. - - + + ); } diff --git a/apps/website/templates/LeagueSettingsTemplate.tsx b/apps/website/templates/LeagueSettingsTemplate.tsx index 9421dc3d4..6355471c0 100644 --- a/apps/website/templates/LeagueSettingsTemplate.tsx +++ b/apps/website/templates/LeagueSettingsTemplate.tsx @@ -1,7 +1,6 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; @@ -10,8 +9,7 @@ import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; import { Icon } from '@/ui/Icon'; import { Surface } from '@/ui/Surface'; -import { Settings, Users, Trophy, Shield, Clock, LucideIcon } from 'lucide-react'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; +import { Settings, Users, Trophy, Shield, Clock, type LucideIcon } from 'lucide-react'; import type { LeagueSettingsViewData } from '@/lib/view-data/leagues/LeagueSettingsViewData'; interface LeagueSettingsTemplateProps { @@ -20,76 +18,69 @@ interface LeagueSettingsTemplateProps { export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps) { return ( - - - League Settings - - Manage your league configuration and preferences - - - + {/* League Information */} - - + + - - - + + + - League Information - Basic league details + LEAGUE INFORMATION + Basic league details and identification - + - + - + {/* Configuration */} - - + + - - - + + + - Configuration - League rules and limits + CONFIGURATION + League rules and participation limits - + - + {/* Note about forms */} - - - - - + + + + + Settings Management - + Form-based editing and ownership transfer functionality will be implemented in future updates. - + ); @@ -98,19 +89,21 @@ export function LeagueSettingsTemplate({ viewData }: LeagueSettingsTemplateProps function InfoItem({ label, value, capitalize }: { label: string, value: string, capitalize?: boolean }) { return ( - {label} - {capitalize ? value.toUpperCase() : value} + {label.toUpperCase()} + {capitalize ? value.toUpperCase() : value} ); } function ConfigItem({ icon, label, value }: { icon: LucideIcon, label: string, value: string | number }) { return ( - - + + + + - {label} - {value} + {label.toUpperCase()} + {value} ); diff --git a/apps/website/templates/LeagueStandingsTemplate.tsx b/apps/website/templates/LeagueStandingsTemplate.tsx index 941a457b2..39bd86c98 100644 --- a/apps/website/templates/LeagueStandingsTemplate.tsx +++ b/apps/website/templates/LeagueStandingsTemplate.tsx @@ -1,12 +1,9 @@ 'use client'; import React from 'react'; -import { LeagueChampionshipStats } from '@/components/leagues/LeagueChampionshipStats'; -import { StandingsTable } from '@/components/leagues/StandingsTable'; -import { Card } from '@/ui/Card'; -import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; +import { LeagueStandingsTable } from '@/components/leagues/LeagueStandingsTable'; import type { LeagueStandingsViewData } from '@/lib/view-data/LeagueStandingsViewData'; interface LeagueStandingsTemplateProps { @@ -18,37 +15,38 @@ interface LeagueStandingsTemplateProps { export function LeagueStandingsTemplate({ viewData, - onRemoveMember, - onUpdateRole, loading = false, }: LeagueStandingsTemplateProps) { if (loading) { return ( - - Loading standings... - + + + Loading Standings... + + ); } - return ( - - {/* Championship Stats */} - + const standings = viewData.standings.map((entry) => { + const driver = viewData.drivers.find(d => d.id === entry.driverId); + return { + position: entry.position, + driverName: driver?.name || 'Unknown Driver', + points: entry.totalPoints, + wins: 0, + podiums: 0, + gap: entry.position === 1 ? '—' : `-${viewData.standings[0].totalPoints - entry.totalPoints}` + }; + }); - - - Championship Standings - - - - + return ( + + + Championship Standings + Official points classification for the current season. + + + + ); } diff --git a/apps/website/templates/MediaTemplate.tsx b/apps/website/templates/MediaTemplate.tsx new file mode 100644 index 000000000..91ea90709 --- /dev/null +++ b/apps/website/templates/MediaTemplate.tsx @@ -0,0 +1,24 @@ +'use client'; + +import React from 'react'; +import { MediaGallery } from '@/components/media/MediaGallery'; +import { Container } from '@/ui/Container'; +import { Box } from '@/ui/Box'; +import { MediaViewData } from '@/lib/view-data/MediaViewData'; + +export function MediaTemplate(viewData: MediaViewData) { + const { assets, categories, title, description } = viewData; + + return ( + + + + + + ); +} diff --git a/apps/website/templates/NotFoundTemplate.test.tsx b/apps/website/templates/NotFoundTemplate.test.tsx new file mode 100644 index 000000000..d1de039fc --- /dev/null +++ b/apps/website/templates/NotFoundTemplate.test.tsx @@ -0,0 +1,31 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { NotFoundTemplate, type NotFoundViewData } from './NotFoundTemplate'; + +describe('NotFoundTemplate', () => { + const mockViewData: NotFoundViewData = { + errorCode: 'Error 404', + title: 'OFF TRACK', + message: 'The requested sector does not exist.', + actionLabel: 'Return to Pits' + }; + + const mockOnHomeClick = vi.fn(); + + it('renders the error code, title and message', () => { + render(); + + expect(screen.getByText('Error 404')).toBeDefined(); + expect(screen.getByText('OFF TRACK')).toBeDefined(); + expect(screen.getByText('The requested sector does not exist.')).toBeDefined(); + }); + + it('calls onHomeClick when the button is clicked', () => { + render(); + + const button = screen.getByText('Return to Pits'); + fireEvent.click(button); + + expect(mockOnHomeClick).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/website/templates/NotFoundTemplate.tsx b/apps/website/templates/NotFoundTemplate.tsx new file mode 100644 index 000000000..0b3eaf517 --- /dev/null +++ b/apps/website/templates/NotFoundTemplate.tsx @@ -0,0 +1,34 @@ +'use client'; + +import React from 'react'; +import { NotFoundScreen } from '@/components/errors/NotFoundScreen'; + +export interface NotFoundViewData { + errorCode: string; + title: string; + message: string; + actionLabel: string; +} + +interface NotFoundTemplateProps { + viewData: NotFoundViewData; + onHomeClick: () => void; +} + +/** + * NotFoundTemplate + * + * Template for the 404 page. + * Composes semantic components to build the page layout. + */ +export function NotFoundTemplate({ viewData, onHomeClick }: NotFoundTemplateProps) { + return ( + + ); +} diff --git a/apps/website/templates/ProfileLayoutShellTemplate.tsx b/apps/website/templates/ProfileLayoutShellTemplate.tsx new file mode 100644 index 000000000..e764ce1bb --- /dev/null +++ b/apps/website/templates/ProfileLayoutShellTemplate.tsx @@ -0,0 +1,28 @@ +import type { ReactNode } from 'react'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { ProfileSidebarTemplate } from '@/templates/ProfileSidebarTemplate'; +import { ProfileLayoutViewData } from '@/lib/view-data/ProfileLayoutViewData'; + +interface ProfileLayoutShellTemplateProps { + viewData: ProfileLayoutViewData; + children: ReactNode; +} + +export function ProfileLayoutShellTemplate({ viewData, children }: ProfileLayoutShellTemplateProps) { + return ( + + + + + + + + {children} + + + + + ); +} diff --git a/apps/website/templates/ProfileLeaguesTemplate.tsx b/apps/website/templates/ProfileLeaguesTemplate.tsx index ebad7940b..799b02543 100644 --- a/apps/website/templates/ProfileLeaguesTemplate.tsx +++ b/apps/website/templates/ProfileLeaguesTemplate.tsx @@ -1,14 +1,11 @@ 'use client'; import React from 'react'; -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 { Surface } from '@/ui/Surface'; +import { Box } from '@/ui/Box'; import type { ProfileLeaguesViewData } from '@/lib/view-data/ProfileLeaguesViewData'; -import { LeagueListItem } from '@/ui/LeagueListItem'; +import { MembershipPanel } from '@/components/profile/MembershipPanel'; interface ProfileLeaguesTemplateProps { viewData: ProfileLeaguesViewData; @@ -16,67 +13,27 @@ interface ProfileLeaguesTemplateProps { export function ProfileLeaguesTemplate({ viewData }: ProfileLeaguesTemplateProps) { return ( - - - - Manage leagues - - View leagues you own and participate in, and jump into league admin tools. - - - - {/* Leagues You Own */} - - - - Leagues you own - {viewData.ownedLeagues.length > 0 && ( - - {viewData.ownedLeagues.length} {viewData.ownedLeagues.length === 1 ? 'league' : 'leagues'} - - )} - - - {viewData.ownedLeagues.length === 0 ? ( - - You don't own any leagues yet in this session. - - ) : ( - - {viewData.ownedLeagues.map((league) => ( - - ))} - - )} - - - - {/* Leagues You're In */} - - - - Leagues you're in - {viewData.memberLeagues.length > 0 && ( - - {viewData.memberLeagues.length} {viewData.memberLeagues.length === 1 ? 'league' : 'leagues'} - - )} - - - {viewData.memberLeagues.length === 0 ? ( - - You're not a member of any other leagues yet. - - ) : ( - - {viewData.memberLeagues.map((league) => ( - - ))} - - )} - - - - + + + My Leagues + + + + ({ + ...l, + description: '', // ViewData doesn't have description, but LeagueListItem needs it + memberCount: 0, // ViewData doesn't have memberCount + roleLabel: 'Owner' + }))} + memberLeagues={viewData.memberLeagues.map(l => ({ + ...l, + description: '', + memberCount: 0, + roleLabel: 'Member' + }))} + /> + + ); } diff --git a/apps/website/templates/ProfileLiveriesTemplate.tsx b/apps/website/templates/ProfileLiveriesTemplate.tsx new file mode 100644 index 000000000..84194a326 --- /dev/null +++ b/apps/website/templates/ProfileLiveriesTemplate.tsx @@ -0,0 +1,39 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { Button } from '@/ui/Button'; +import { Plus } from 'lucide-react'; +import { routes } from '@/lib/routing/RouteConfig'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { LiveryGallery } from '@/components/profile/LiveryGallery'; +import type { ProfileLiveriesViewData } from '@/lib/view-data/ProfileLiveriesViewData'; + +interface ProfileLiveriesTemplateProps { + viewData: ProfileLiveriesViewData; +} + +export function ProfileLiveriesTemplate({ viewData }: ProfileLiveriesTemplateProps) { + return ( + + + + My Liveries + + }> + Upload Livery + + + + + + + + + + ); +} diff --git a/apps/website/templates/ProfileSettingsTemplate.tsx b/apps/website/templates/ProfileSettingsTemplate.tsx new file mode 100644 index 000000000..89b1e1b5d --- /dev/null +++ b/apps/website/templates/ProfileSettingsTemplate.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Button } from '@/ui/Button'; +import { Box } from '@/ui/Box'; +import { ProfileDetailsPanel } from '@/components/profile/ProfileDetailsPanel'; +import { ConnectedAccountsPanel } from '@/components/profile/ConnectedAccountsPanel'; +import { PreferencesPanel } from '@/components/profile/PreferencesPanel'; +import type { ProfileViewData } from '@/lib/view-data/ProfileViewData'; + +interface ProfileSettingsTemplateProps { + viewData: ProfileViewData; + bio: string; + country: string; + onBioChange: (bio: string) => void; + onCountryChange: (country: string) => void; + onSave: () => void; +} + +export function ProfileSettingsTemplate({ + viewData, + bio, + country, + onBioChange, + onCountryChange, + onSave +}: ProfileSettingsTemplateProps) { + return ( + + + + Settings + Save Changes + + + + { + if (updates.bio !== undefined) onBioChange(updates.bio); + if (updates.country !== undefined) onCountryChange(updates.country); + }} + /> + + + + + + ); +} diff --git a/apps/website/templates/ProfileSidebarTemplate.tsx b/apps/website/templates/ProfileSidebarTemplate.tsx new file mode 100644 index 000000000..0a8e83852 --- /dev/null +++ b/apps/website/templates/ProfileSidebarTemplate.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { + User, + Settings, + Trophy, + Palette, + Handshake +} from 'lucide-react'; +import { routes } from '@/lib/routing/RouteConfig'; + +import { ProfileLayoutViewData } from '@/lib/view-data/ProfileLayoutViewData'; + +export function ProfileSidebarTemplate({ viewData: _viewData }: { viewData: ProfileLayoutViewData }) { + const pathname = usePathname(); + + const navItems = [ + { label: 'Overview', href: routes.protected.profile, icon: User }, + { label: 'Leagues', href: routes.protected.profileLeagues, icon: Trophy }, + { label: 'Liveries', href: routes.protected.profileLiveries, icon: Palette }, + { label: 'Sponsorships', href: routes.protected.profileSponsorshipRequests, icon: Handshake }, + { label: 'Settings', href: routes.protected.profileSettings, icon: Settings }, + ]; + + return ( + + + {navItems.map((item) => { + const isActive = pathname === item.href; + return ( + + + + + {item.label} + + {isActive && ( + + )} + + + ); + })} + + + ); +} diff --git a/apps/website/templates/ProfileTemplate.tsx b/apps/website/templates/ProfileTemplate.tsx index b61ab3ecd..3750b5c1d 100644 --- a/apps/website/templates/ProfileTemplate.tsx +++ b/apps/website/templates/ProfileTemplate.tsx @@ -2,43 +2,30 @@ import React from 'react'; import { CreateDriverForm } from '@/components/drivers/CreateDriverForm'; -import { ProfileRaceHistory } from '@/components/drivers/ProfileRaceHistory'; -import { ProfileSettings } from '@/components/drivers/ProfileSettings'; -import { AchievementGrid } from '@/components/achievements/AchievementGrid'; -import { ProfileHero } from '@/components/drivers/ProfileHero'; +import { ProfileHeader } from '@/components/profile/ProfileHeader'; +import { ProfileNavTabs, type ProfileTab } from '@/components/profile/ProfileNavTabs'; +import { ProfileDetailsPanel } from '@/components/profile/ProfileDetailsPanel'; +import { SessionHistoryTable } from '@/components/profile/SessionHistoryTable'; import { ProfileStatGrid } from '@/ui/ProfileStatGrid'; -import { ProfileTabs, type ProfileTab as ProfileTabsType } from '@/ui/ProfileTabs'; import { TeamMembershipGrid } from '@/ui/TeamMembershipGrid'; +import { AchievementGrid } from '@/components/achievements/AchievementGrid'; import type { ProfileViewData } from '@/lib/view-data/ProfileViewData'; import { Box } from '@/ui/Box'; -import { Button } from '@/ui/Button'; import { Card } from '@/ui/Card'; -import { Container } from '@/ui/Container'; import { Heading } from '@/ui/Heading'; import { Icon } from '@/ui/Icon'; import { Stack } from '@/ui/Stack'; import { Surface } from '@/ui/Surface'; import { Text } from '@/ui/Text'; -import { - Activity, - Award, - History, - User, -} from 'lucide-react'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; - -export type ProfileTab = 'overview' | 'history' | 'stats'; +import { User } from 'lucide-react'; interface ProfileTemplateProps { viewData: ProfileViewData; mode: 'profile-exists' | 'needs-profile'; activeTab: ProfileTab; onTabChange: (tab: ProfileTab) => void; - editMode: boolean; - onEditModeChange: (edit: boolean) => void; friendRequestSent: boolean; onFriendRequestSend: () => void; - onSaveSettings: (updates: { bio?: string; country?: string }) => Promise; } export function ProfileTemplate({ @@ -46,26 +33,21 @@ export function ProfileTemplate({ mode, activeTab, onTabChange, - editMode, - onEditModeChange, friendRequestSent, onFriendRequestSend, - onSaveSettings, }: ProfileTemplateProps) { if (mode === 'needs-profile') { return ( - - - - - - - Create Your Driver Profile - Join the GridPilot community and start your racing journey - - + + + + + + Create Your Driver Profile + Join the GridPilot community and start your racing journey + - + @@ -78,123 +60,89 @@ export function ProfileTemplate({ - - ); - } - - if (editMode) { - return ( - - - - Edit Profile - onEditModeChange(false)}> - Cancel - - - - { - await onSaveSettings(updates); - onEditModeChange(false); - }} - /> - - + ); } return ( - - - {/* Back Navigation */} - - {}} - icon={} - > - Back to Drivers - - + + - {/* Breadcrumb */} - + - ({ ...s, platform: s.platformLabel })) || []} - onAddFriend={onFriendRequestSend} - friendRequestSent={friendRequestSent} - /> - - {viewData.driver.bio && ( - - - }> - About - - {viewData.driver.bio} - - - )} - - {viewData.teamMemberships.length > 0 && ( - ({ - team: { id: m.teamId, name: m.teamName }, - role: m.roleLabel, - joinedAt: new Date() // Placeholder - }))} + {activeTab === 'overview' && ( + + - )} - void} /> + {viewData.teamMemberships.length > 0 && ( + + + Teams + ({ + team: { id: m.teamId, name: m.teamName }, + role: m.roleLabel, + joinedAt: new Date() // Placeholder + }))} + /> + + + )} - {activeTab === 'history' && ( - - - }> - Race History - - - - - )} + {viewData.extendedProfile && ( + + + + Achievements + {viewData.extendedProfile.achievements.length} earned + + ({ + ...a, + rarity: a.rarityLabel, + earnedAt: new Date() // Placeholder + }))} + /> + + + )} + + )} - {activeTab === 'stats' && viewData.stats && ( - - - }> - Performance Overview - + {activeTab === 'history' && ( + + + Race History + + + + + + )} + + {activeTab === 'stats' && viewData.stats && ( + + + Performance Overview + - - - )} - - {activeTab === 'overview' && viewData.extendedProfile && ( - - - - }> - Achievements - - {viewData.extendedProfile.achievements.length} earned - - ({ - ...a, - rarity: a.rarityLabel, - earnedAt: new Date() // Placeholder - }))} - /> - - - )} - - + + + + )} + ); } diff --git a/apps/website/templates/RaceDetailTemplate.tsx b/apps/website/templates/RaceDetailTemplate.tsx index 3254b3f17..f02ff2d7d 100644 --- a/apps/website/templates/RaceDetailTemplate.tsx +++ b/apps/website/templates/RaceDetailTemplate.tsx @@ -1,35 +1,20 @@ 'use client'; import React from 'react'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { Button } from '@/ui/Button'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; import { Container } from '@/ui/Container'; -import { Icon } from '@/ui/Icon'; +import { Stack } from '@/ui/Stack'; import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; import { Skeleton } from '@/ui/Skeleton'; -import { InfoBox } from '@/ui/InfoBox'; -import { RaceJoinButton } from '@/ui/RaceJoinButton'; -import { RaceHero } from '@/ui/RaceHeroWrapper'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; import { RaceUserResult } from '@/ui/RaceUserResultWrapper'; -import { RaceEntryList } from '@/components/races/RaceEntryList'; -import { RaceDetailCard } from '@/ui/RaceDetailCard'; import { LeagueSummaryCard } from '@/components/leagues/LeagueSummaryCardWrapper'; -import { - AlertTriangle, - ArrowLeft, - CheckCircle2, - Clock, - PlayCircle, - Trophy, - XCircle, - Scale, -} from 'lucide-react'; -import { Surface } from '@/ui/Surface'; -import { Card } from '@/ui/Card'; +import { RaceActionBar } from '@/ui/RaceActionBar'; +import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader'; +import { TrackConditionsPanel } from '@/components/races/TrackConditionsPanel'; +import { EntrantsTable } from '@/components/races/EntrantsTable'; +import type { SessionStatus } from '@/components/races/SessionStatusBadge'; export interface RaceDetailEntryViewModel { id: string; @@ -127,7 +112,6 @@ export function RaceDetailTemplate({ onFileProtest, onResultsClick, onStewardingClick, - onDriverClick, isOwnerOrAdmin = false, animatedRatingChange, mutationLoading = {}, @@ -152,174 +136,122 @@ export function RaceDetailTemplate({ if (error || !viewData || !viewData.race) { return ( - - - - - - - - - - {error instanceof Error ? error.message : error || 'Race not found'} - - The race you're looking for doesn't exist or has been removed. - - - - Back to Races - - - - + + + Race Not Found + {`The race you're looking for doesn't exist or has been removed.`} + + Back to Schedule + + + ); } const { race, league, entryList, userResult } = viewData; - const statusConfig = { - scheduled: { - icon: Clock, - variant: 'primary' as const, - label: 'Scheduled', - description: 'This race is scheduled and waiting to start', - }, - running: { - icon: PlayCircle, - variant: 'success' as const, - label: 'LIVE NOW', - description: 'This race is currently in progress', - }, - completed: { - icon: CheckCircle2, - variant: 'default' as const, - label: 'Completed', - description: 'This race has finished', - }, - cancelled: { - icon: XCircle, - variant: 'warning' as const, - label: 'Cancelled', - description: 'This race has been cancelled', - }, - }; - - const config = statusConfig[race.status] || statusConfig.scheduled; - - const breadcrumbItems = [ - { label: 'Races', href: '/races' }, - ...(league ? [{ label: league.name, href: `/leagues/${league.id}` }] : []), - { label: race.track }, - ]; - return ( - - - {/* Navigation Row */} - - - } - > - Back - - + + - {/* User Result */} - {userResult && ( - - )} + + + {userResult && ( + + )} - {/* Hero Header */} - + + + - - - - + + + + + + Entry List + + ({ + id: entry.id, + name: entry.name, + carName: race.car, + rating: entry.rating || 0, + status: 'confirmed' + }))} + /> + + + - - - + + + {league && } + + - - - {league && } - - {/* Actions Card */} - - - Actions - - - - {race.status === 'completed' && ( - <> - }> - View Results - - {userResult && ( - }> - File Protest - - )} - }> - Stewarding - - > - )} + + Session Info + + + Format + {race.sessionType} + + + Car Class + {race.car} + - - - - {/* Status Info */} - - - - - - + + + + + + + ); } diff --git a/apps/website/templates/RaceResultsTemplate.tsx b/apps/website/templates/RaceResultsTemplate.tsx index b2ba8344b..3cbc4e913 100644 --- a/apps/website/templates/RaceResultsTemplate.tsx +++ b/apps/website/templates/RaceResultsTemplate.tsx @@ -1,22 +1,17 @@ 'use client'; import React from 'react'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { Button } from '@/ui/Button'; -import { Card } from '@/ui/Card'; -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 { Stack } from '@/ui/Stack'; import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Text } from '@/ui/Text'; import { Icon } from '@/ui/Icon'; -import { Surface } from '@/ui/Surface'; -import { ArrowLeft, Trophy, Zap, type LucideIcon } from 'lucide-react'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; +import { Box } from '@/ui/Box'; +import { Trophy, Zap, AlertTriangle, type LucideIcon } from 'lucide-react'; import type { RaceResultsViewData } from '@/lib/view-data/races/RaceResultsViewData'; -import { RaceResultRow } from '@/components/races/RaceResultRow'; -import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper'; +import { RaceResultsTable } from '@/ui/RaceResultsTable'; +import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader'; export interface RaceResultsTemplateProps { viewData: RaceResultsViewData; @@ -40,15 +35,9 @@ export function RaceResultsTemplate({ isLoading, error, onBack, - onImportResults, - importing, importSuccess, importError, }: RaceResultsTemplateProps) { - const formatDate = (date: string) => { - return DateDisplay.formatFull(date); - }; - const formatTime = (ms: number) => { const minutes = Math.floor(ms / 60000); const seconds = Math.floor((ms % 60000) / 1000); @@ -56,17 +45,10 @@ export function RaceResultsTemplate({ return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`; }; - const breadcrumbItems = [ - { label: 'Races', href: '/races' }, - ...(viewData.leagueName ? [{ label: viewData.leagueName, href: `/leagues/${viewData.leagueName}` }] : []), - ...(viewData.raceTrack ? [{ label: viewData.raceTrack, href: `/races/${viewData.raceTrack}` }] : []), - { label: 'Results' }, - ]; - if (isLoading) { return ( - + Loading results... @@ -76,147 +58,123 @@ export function RaceResultsTemplate({ if (error && !viewData.raceTrack) { return ( - - + + {error?.message || 'Race not found'} - - Back to Races - + Back to Schedule + - + ); } - const hasResults = viewData.results.length > 0; - return ( - - - - - } - > - Back - - + + - {/* Header */} - - - - - - - Race Results - - {viewData.raceTrack} • {viewData.raceScheduledAt ? formatDate(viewData.raceScheduledAt) : ''} - + + + {importSuccess && ( + + Success! + Results imported and standings updated. - + )} - {/* Stats */} - - - - - - - + {importError && ( + + Error: + {importError} + + )} - {importSuccess && ( - - Success! - Results imported and standings updated. - - )} + + + + + + Final Standings + + } + fastestLapTime={viewData.fastestLapTime} + penalties={viewData.penalties as unknown as never[]} + /> + + - {importError && ( - - Error: - {importError} - - )} - - - {hasResults ? ( - - {/* Results Table */} - - {viewData.results.map((result) => ( - - ))} - - - {/* Penalties Section */} - {viewData.penalties.length > 0 && ( - - - Penalties - - - {viewData.penalties.map((penalty, index) => ( - - ))} + + + + Session Stats + + + + - )} - - ) : ( - - - Import Results - - No results imported. Upload CSV to test the standings system. - - - {importing ? ( - - Importing results and updating standings... - - ) : ( - - - This is a placeholder for the import form. In the actual implementation, - this would render the ImportResultsForm component. - - - onImportResults([])} - disabled={importing} - > - Import Results (Demo) - + + {viewData.penalties.length > 0 && ( + + Penalties + + {viewData.penalties.map((penalty, index) => ( + + + + + {penalty.driverName || 'Unknown Driver'} + {penalty.reason || penalty.type} + + + + ))} + - - )} - - )} - - - + )} + + + + + + ); } function StatItem({ label, value, icon, color = 'text-white' }: { label: string, value: string | number, icon?: LucideIcon, color?: string }) { return ( - - {label} - - {icon && } - {value} + + + + {icon && } + {label} + + {value} - + ); } diff --git a/apps/website/templates/RaceStewardingTemplate.tsx b/apps/website/templates/RaceStewardingTemplate.tsx index 64a3dfc13..0adad12c2 100644 --- a/apps/website/templates/RaceStewardingTemplate.tsx +++ b/apps/website/templates/RaceStewardingTemplate.tsx @@ -1,29 +1,19 @@ 'use client'; import React from 'react'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; +import { Flag, CheckCircle, Gavel, Info } from 'lucide-react'; import { RaceStewardingStats } from '@/ui/RaceStewardingStats'; import { StewardingTabs } from '@/ui/StewardingTabs'; import { ProtestCard } from '@/components/leagues/ProtestCardWrapper'; import { RacePenaltyRow } from '@/ui/RacePenaltyRowWrapper'; -import { Card } from '@/ui/Card'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { Button } from '@/ui/Button'; -import { Container } from '@/ui/Container'; -import { Icon } from '@/ui/Icon'; -import { Surface } from '@/ui/Surface'; -import { - AlertTriangle, - ArrowLeft, - CheckCircle, - Flag, - Gavel, - Scale, -} from 'lucide-react'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; +import { RaceDetailsHeader } from '@/components/races/RaceDetailsHeader'; import type { RaceStewardingViewData } from '@/lib/view-data/races/RaceStewardingViewData'; export type StewardingTab = 'pending' | 'resolved' | 'penalties'; @@ -52,13 +42,13 @@ export function RaceStewardingTemplate({ setActiveTab, }: RaceStewardingTemplateProps) { const formatDate = (date: string) => { - return DateDisplay.formatShort(date); + return date; // Simplified for template }; if (isLoading) { return ( - + Loading stewarding data... @@ -68,164 +58,157 @@ export function RaceStewardingTemplate({ if (!viewData?.race) { return ( - - - - - - - Race not found - The race you're looking for doesn't exist. + + + Race Not Found + {`The race you're looking for doesn't exist.`} + + Back to Schedule - - Back to Races - - + ); } - const breadcrumbItems = [ - { label: 'Races', href: '/races' }, - { label: viewData.race.track, href: `/races/${viewData.race.id}` }, - { label: 'Stewarding' }, - ]; - return ( - - - {/* Navigation */} - - - } - > - Back to Race - + + + + + + + + + + + {activeTab === 'pending' && ( + + {viewData.pendingProtests.length === 0 ? ( + + + + + All Clear! + No pending protests to review + + + + ) : ( + viewData.pendingProtests.map((protest) => ( + + )) + )} + + )} + + {activeTab === 'resolved' && ( + + {viewData.resolvedProtests.length === 0 ? ( + + + + + No Resolved Protests + Resolved protests will appear here + + + + ) : ( + viewData.resolvedProtests.map((protest) => ( + + )) + )} + + )} + + {activeTab === 'penalties' && ( + + {viewData.penalties.length === 0 ? ( + + + + + No Penalties + Penalties issued for this race will appear here + + + + ) : ( + viewData.penalties.map((penalty) => ( + + )) + )} + + )} + + + + + + + + + Stewarding Stats + + + + + + - - {/* Header */} - - - - - - - Stewarding - - {viewData.race.track} • {formatDate(viewData.race.scheduledAt)} - - - - - {/* Stats */} - - - - {/* Tab Navigation */} - - - {/* Content */} - {activeTab === 'pending' && ( - - {viewData.pendingProtests.length === 0 ? ( - - - - - - - All Clear! - No pending protests to review - - - - ) : ( - viewData.pendingProtests.map((protest) => ( - - )) - )} - - )} - - {activeTab === 'resolved' && ( - - {viewData.resolvedProtests.length === 0 ? ( - - - - - - - No Resolved Protests - Resolved protests will appear here - - - - ) : ( - viewData.resolvedProtests.map((protest) => ( - - )) - )} - - )} - - {activeTab === 'penalties' && ( - - {viewData.penalties.length === 0 ? ( - - - - - - - No Penalties - Penalties issued for this race will appear here - - - - ) : ( - viewData.penalties.map((penalty) => ( - - )) - )} - - )} - - + + ); } diff --git a/apps/website/templates/RacesAllTemplate.tsx b/apps/website/templates/RacesAllTemplate.tsx index 5d9626e88..926ed4ebd 100644 --- a/apps/website/templates/RacesAllTemplate.tsx +++ b/apps/website/templates/RacesAllTemplate.tsx @@ -1,26 +1,17 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { Heading } from '@/ui/Heading'; -import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { - Flag, - SlidersHorizontal, - Calendar, -} from 'lucide-react'; -import { RaceFilterModal } from '@/ui/RaceFilterModal'; -import { Pagination } from '@/ui/Pagination'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; import { Container } from '@/ui/Container'; -import { Icon } from '@/ui/Icon'; -import { Surface } from '@/ui/Surface'; +import { Stack } from '@/ui/Stack'; import { Skeleton } from '@/ui/Skeleton'; -import { RaceListItem } from '@/components/races/RaceListItemWrapper'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; +import { Pagination } from '@/ui/Pagination'; +import { RaceFilterModal } from '@/ui/RaceFilterModal'; +import { RacesHeader } from '@/components/races/RacesHeader'; +import { RaceScheduleTable } from '@/components/races/RaceScheduleTable'; import type { RacesViewData } from '@/lib/view-data/RacesViewData'; +import type { SessionStatus } from '@/components/races/SessionStatusBadge'; export type StatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; @@ -66,26 +57,18 @@ export function RacesAllTemplate({ setLeagueFilter, searchQuery, setSearchQuery, - showFilters, - setShowFilters, showFilterModal, setShowFilterModal, onRaceClick, }: RacesAllTemplateProps) { - const breadcrumbItems = [ - { label: 'Races', href: '/races' }, - { label: 'All Races' }, - ]; - if (isLoading) { return ( - - + {[1, 2, 3, 4, 5].map(i => ( - + ))} @@ -94,98 +77,84 @@ export function RacesAllTemplate({ } return ( - - - {/* Breadcrumbs */} - + + + + - {/* Header */} - - - }> - All Races - - - {totalFilteredCount} race{totalFilteredCount !== 1 ? 's' : ''} found + + + Showing {totalFilteredCount} races + setShowFilterModal(true)} + px={4} + py={2} + bg="bg-surface-charcoal" + border + borderColor="border-outline-steel" + fontSize="10px" + weight="bold" + uppercase + letterSpacing="wider" + hoverBorderColor="border-primary-accent" + transition + > + Filters + - - setShowFilters(!showFilters)} - icon={} - > - Filters - + + + {races.length === 0 ? ( + + No races found matching your criteria. + + ) : ( + ({ + id: race.id, + track: race.track, + car: race.car, + leagueName: race.leagueName, + time: race.timeLabel, + status: race.status as SessionStatus + }))} + onRaceClick={onRaceClick} + /> + )} + + + + + setShowFilterModal(false)} + statusFilter={statusFilter} + setStatusFilter={setStatusFilter} + leagueFilter={leagueFilter} + setLeagueFilter={setLeagueFilter} + timeFilter="all" + setTimeFilter={() => {}} + searchQuery={searchQuery} + setSearchQuery={setSearchQuery} + leagues={viewData.leagues} + showSearch={true} + showTimeFilter={false} + /> - - {/* Search & Filters (Simplified for template) */} - {showFilters && ( - - - - Use the filter button to open advanced search and filtering options. - - - setShowFilterModal(true)}> - Open Filters - - - - - )} - - {/* Race List */} - {races.length === 0 ? ( - - - - - - - No races found - - {viewData.races.length === 0 - ? 'No races have been scheduled yet' - : 'Try adjusting your search or filters'} - - - - - ) : ( - - {races.map(race => ( - - ))} - - )} - - {/* Pagination */} - - - {/* Filter Modal */} - setShowFilterModal(false)} - statusFilter={statusFilter} - setStatusFilter={setStatusFilter} - leagueFilter={leagueFilter} - setLeagueFilter={setLeagueFilter} - timeFilter="all" - setTimeFilter={() => {}} - searchQuery={searchQuery} - setSearchQuery={setSearchQuery} - leagues={viewData.leagues} - showSearch={true} - showTimeFilter={false} - /> - - + + ); } diff --git a/apps/website/templates/RacesTemplate.tsx b/apps/website/templates/RacesTemplate.tsx index 16d954118..e7d23f34c 100644 --- a/apps/website/templates/RacesTemplate.tsx +++ b/apps/website/templates/RacesTemplate.tsx @@ -1,18 +1,20 @@ 'use client'; import React from 'react'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Container } from '@/ui/Container'; -import { RaceFilterModal } from '@/ui/RaceFilterModal'; -import type { RacesViewData } from '@/lib/view-data/RacesViewData'; -import { RacePageHeader } from '@/ui/RacePageHeader'; -import { LiveRacesBanner } from '@/components/races/LiveRacesBanner'; -import { RaceFilterBar } from '@/components/races/RaceFilterBar'; -import { RaceList } from '@/components/races/RaceList'; -import { RaceSidebar } from '@/components/races/RaceSidebar'; +import { Stack } from '@/ui/Stack'; import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { RaceFilterModal } from '@/ui/RaceFilterModal'; +import type { RacesViewData } from '@/lib/view-data/RacesViewData'; +import { RacesHeader } from '@/components/races/RacesHeader'; +import { LiveRacesBanner } from '@/components/races/LiveRacesBanner'; +import { RaceFilterBar } from '@/components/races/RaceFilterBar'; +import { RaceScheduleTable } from '@/components/races/RaceScheduleTable'; +import { RaceSidebar } from '@/components/races/RaceSidebar'; +import type { SessionStatus } from '@/components/races/SessionStatusBadge'; export type TimeFilter = 'all' | 'upcoming' | 'live' | 'past'; export type RaceStatusFilter = 'scheduled' | 'running' | 'completed' | 'cancelled' | 'all'; @@ -49,10 +51,10 @@ export function RacesTemplate({ setShowFilterModal, }: RacesTemplateProps) { return ( - - + + - setShowFilterModal(true)} /> - + + + Race Schedule + + ({ + id: race.id, + track: race.track, + car: race.car, + leagueName: race.leagueName, + time: race.timeLabel, + status: race.status as SessionStatus + }))} + onRaceClick={onRaceClick} + /> + diff --git a/apps/website/templates/RosterAdminTemplate.tsx b/apps/website/templates/RosterAdminTemplate.tsx index 41877153c..9e9aa1e2c 100644 --- a/apps/website/templates/RosterAdminTemplate.tsx +++ b/apps/website/templates/RosterAdminTemplate.tsx @@ -1,14 +1,15 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; -import { Text } from '@/ui/Text'; -import { Button } from '@/ui/Button'; -import { Select } from '@/ui/Select'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; import { Heading } from '@/ui/Heading'; import { Surface } from '@/ui/Surface'; +import { Button } from '@/ui/Button'; +import { Shield, UserPlus, UserMinus } from 'lucide-react'; +import { Icon } from '@/ui/Icon'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; import type { MembershipRole } from '@/lib/types/MembershipRole'; import type { LeagueRosterAdminViewData } from '@/lib/view-data/LeagueRosterAdminViewData'; @@ -36,119 +37,131 @@ export function RosterAdminTemplate({ const { joinRequests, members } = viewData; return ( - - - - - Roster Admin - - Manage join requests and member roles. - - - - - - Pending join requests - - {pendingCountLabel} - - - - {loading ? ( - Loading… - ) : joinRequests.length > 0 ? ( - - {joinRequests.map((req) => ( - - - - {req.driver.name} - {req.requestedAt} - {req.message && ( - {req.message} - )} - - - - onApprove(req.id)} - variant="primary" - size="sm" - > - Approve - - onReject(req.id)} - variant="secondary" - size="sm" - > - Reject - - - - - ))} - - ) : ( - No pending join requests. - )} - - - - - Members - - - {loading ? ( - Loading… - ) : members.length > 0 ? ( - - {members.map((member) => ( - - - - {member.driver.name} - {member.joinedAt} - - - - - onRoleChange(member.driverId, e.target.value as MembershipRole)} - options={roleOptions.map((role) => ({ value: role, label: role }))} - /> - - onRemove(member.driverId)} - variant="secondary" - size="sm" - > - Remove - - - - - ))} - - ) : ( - No members found. - )} + + {/* Join Requests Section */} + + + + + PENDING JOIN REQUESTS + + + {pendingCountLabel} - + + {loading ? ( + + Loading requests... + + ) : joinRequests.length > 0 ? ( + + + {joinRequests.map((req) => ( + + + + {req.driver.name} + {new Date(req.requestedAt).toLocaleString()} + {req.message && ( + "{req.message}" + )} + + + + onApprove(req.id)} variant="primary" size="sm"> + Approve + + onReject(req.id)} variant="secondary" size="sm"> + Reject + + + + + ))} + + + ) : ( + + No pending join requests. + + )} + + + {/* Members Section */} + + + + ACTIVE ROSTER + + + {loading ? ( + + Loading members... + + ) : members.length > 0 ? ( + + + + + Driver + Joined + Role + Actions + + + + {members.map((member) => ( + + + {member.driver.name} + + + {new Date(member.joinedAt).toLocaleDateString()} + + + ) => onRoleChange(member.driverId, e.target.value as MembershipRole)} + bg="bg-iron-gray" + border + borderColor="border-charcoal-outline" + rounded="md" + px={2} + py={1} + fontSize="xs" + weight="bold" + color="text-white" + > + {roleOptions.map((role) => ( + {role.toUpperCase()} + ))} + + + + onRemove(member.driverId)} + variant="ghost" + size="sm" + > + + + REMOVE + + + + + ))} + + + + ) : ( + + No members found. + + )} + ); } diff --git a/apps/website/templates/RulebookTemplate.tsx b/apps/website/templates/RulebookTemplate.tsx index b93e9eba4..ee5564834 100644 --- a/apps/website/templates/RulebookTemplate.tsx +++ b/apps/website/templates/RulebookTemplate.tsx @@ -1,15 +1,9 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; -import { Heading } from '@/ui/Heading'; -import { Badge } from '@/ui/Badge'; -import { Grid } from '@/ui/Grid'; -import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from '@/ui/Table'; -import { Surface } from '@/ui/Surface'; +import { LeagueRulesPanel } from '@/components/leagues/LeagueRulesPanel'; import type { RulebookViewData } from '@/lib/view-data/leagues/RulebookViewData'; interface RulebookTemplateProps { @@ -17,104 +11,77 @@ interface RulebookTemplateProps { } export function RulebookTemplate({ viewData }: RulebookTemplateProps) { + const rules = [ + { + id: 'points', + title: 'Points System', + content: `Points are awarded to the top ${viewData.positionPoints.length} finishers. 1st place receives ${viewData.positionPoints[0]?.points || 0} points.` + }, + { + id: 'drops', + title: 'Drop Policy', + content: viewData.hasActiveDropPolicy ? viewData.dropPolicySummary : 'No drop races are active for this season.' + }, + { + id: 'platform', + title: 'Platform & Sessions', + content: `Racing on ${viewData.gameName}. Sessions scored: ${viewData.sessionTypes}.` + } + ]; + + if (viewData.hasBonusPoints) { + rules.push({ + id: 'bonus', + title: 'Bonus Points', + content: viewData.bonusPoints.join('. ') + }); + } + return ( - - {/* Header */} - - - Rulebook - Official rules and regulations - - - {viewData.scoringPresetName || 'Custom Rules'} - - - - {/* Quick Stats */} - - - - - - - - {/* Points Table */} - - - Points System - - - - - Position - Points - - - - {viewData.positionPoints.map((point) => ( - - - {point.position} - - - {point.points} - - - ))} - - - - - {/* Bonus Points */} - {viewData.hasBonusPoints && ( - - - Bonus Points - - - {viewData.bonusPoints.map((bonus, idx) => ( - - - - + - - {bonus} - - - ))} - - - )} - - {/* Drop Policy */} - {viewData.hasActiveDropPolicy && ( - - - Drop Policy - - {viewData.dropPolicySummary} - - - Drop rules are applied automatically when calculating championship standings. + + + + Rulebook + + + {viewData.scoringPresetName || 'Custom Rules'} - - )} - - ); -} + + Official rules and regulations for this championship. + -function StatItem({ label, value }: { label: string, value: string | number }) { - return ( - - {label} - {value} - + + + + Points Classification + + + + + + Position + + + Points + + + + + {viewData.positionPoints.map((point) => ( + + + {point.position} + + + {point.points} + + + ))} + + + + + ); } diff --git a/apps/website/templates/ServerErrorTemplate.tsx b/apps/website/templates/ServerErrorTemplate.tsx new file mode 100644 index 000000000..ab985121a --- /dev/null +++ b/apps/website/templates/ServerErrorTemplate.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Surface } from '@/ui/Surface'; +import { Glow } from '@/ui/Glow'; +import { Stack } from '@/ui/Stack'; +import { ServerErrorPanel } from '@/components/errors/ServerErrorPanel'; +import { RecoveryActions } from '@/components/errors/RecoveryActions'; +import { ErrorDetails } from '@/components/errors/ErrorDetails'; + +export interface ServerErrorViewData { + error: Error & { digest?: string }; + incidentId?: string; +} + +interface ServerErrorTemplateProps { + viewData: ServerErrorViewData; + onRetry: () => void; + onHome: () => void; +} + +/** + * ServerErrorTemplate + * + * Template for the 500 error page. + * Composes semantic error components into an "instrument-grade" layout. + */ +export function ServerErrorTemplate({ viewData, onRetry, onHome }: ServerErrorTemplateProps) { + return ( + + {/* Background Accents */} + + + + + + + + + + + + + ); +} diff --git a/apps/website/templates/SponsorBillingTemplate.tsx b/apps/website/templates/SponsorBillingTemplate.tsx new file mode 100644 index 000000000..b6cd30966 --- /dev/null +++ b/apps/website/templates/SponsorBillingTemplate.tsx @@ -0,0 +1,216 @@ +'use client'; + +import React from 'react'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { InfoBanner } from '@/ui/InfoBanner'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; +import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel'; +import { SponsorPayoutQueueTable, PayoutItem } from '@/components/sponsors/SponsorPayoutQueueTable'; +import { + CreditCard, + Building2, + Download, + Percent, + Receipt, + ExternalLink, + LucideIcon +} from 'lucide-react'; +import { siteConfig } from '@/lib/siteConfig'; +import type { PaymentMethodDTO, InvoiceDTO } from '@/lib/types/tbd/SponsorBillingDTO'; + +export interface SponsorBillingViewData { + stats: { + totalSpent: number; + pendingAmount: number; + nextPaymentDate: string; + nextPaymentAmount: number; + averageMonthlySpend: number; + }; + paymentMethods: PaymentMethodDTO[]; + invoices: InvoiceDTO[]; +} + +interface SponsorBillingTemplateProps { + viewData: SponsorBillingViewData; + billingStats: Array<{ + label: string; + value: string; + subValue?: string; + icon: LucideIcon; + variant: 'success' | 'warning' | 'info' | 'default'; + }>; + transactions: Array<{ + id: string; + date: string; + amount: number | string; + status: string; + recipient?: string; + description?: string; + invoiceNumber?: string; + }>; + onSetDefaultPaymentMethod: (id: string) => void; + onDownloadInvoice: (id: string) => void; +} + +export function SponsorBillingTemplate({ + viewData, + billingStats, + transactions, + onSetDefaultPaymentMethod, + onDownloadInvoice +}: SponsorBillingTemplateProps) { + // Map transactions to PayoutItems for the new table + const payoutItems: PayoutItem[] = transactions.map(t => ({ + id: t.id, + date: t.date, + amount: typeof t.amount === 'number' ? t.amount.toFixed(2) : t.amount, + status: t.status === 'paid' ? 'completed' : t.status === 'pending' ? 'pending' : 'failed', + recipient: t.recipient || 'GridPilot Platform', + description: t.description || `Invoice ${t.invoiceNumber}` + })); + + return ( + + + console.log('Refresh')} + /> + + + + + + + {/* Billing History */} + + + Billing History + } onClick={() => onDownloadInvoice('all')}> + Export All + + + + + + + + {/* Platform Fees & VAT */} + + + + + + + Platform Fee + + + + + + {siteConfig.fees.platformFeePercent}% + + + {siteConfig.fees.description} + + + + + + + + + + VAT Information + + + + + + {siteConfig.vat.notice} + + + + Standard Rate + {siteConfig.vat.standardRate}% + + + B2B Reverse Charge + Available + + + + + + + + + + + {/* Payment Methods */} + + + Payment Methods + + {viewData.paymentMethods.map((method: PaymentMethodDTO) => ( + + + + + + + {method.brand || 'Bank Account'} •••• {method.last4} + + {method.isDefault && ( + Default + )} + + + {!method.isDefault && ( + onSetDefaultPaymentMethod(method.id)}> + Set Default + + )} + + + ))} + + + All payments are securely processed by our payment provider. + + + + + {/* Support */} + + + Billing Support + + Need help with an invoice or have questions about your plan? + + }> + Contact Support + + + + + + + + + ); +} diff --git a/apps/website/templates/SponsorCampaignsTemplate.tsx b/apps/website/templates/SponsorCampaignsTemplate.tsx new file mode 100644 index 000000000..fadffc48e --- /dev/null +++ b/apps/website/templates/SponsorCampaignsTemplate.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; +import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel'; +import { SponsorContractCard } from '@/components/sponsors/SponsorContractCard'; +import { + Search, + Check, + Clock, + Eye, + BarChart3, + LucideIcon +} from 'lucide-react'; + +export type SponsorshipType = 'all' | 'leagues' | 'teams' | 'drivers' | 'races' | 'platform'; +export type SponsorshipStatus = 'all' | 'active' | 'pending_approval' | 'approved' | 'rejected' | 'expired'; + +export interface SponsorCampaignsViewData { + sponsorships: Array<{ + id: string; + type: string; + status: string; + leagueName: string; + seasonName: string; + tier: string; + pricing: { amount: number; currency: string }; + metrics: { impressions: number }; + seasonStartDate?: Date; + seasonEndDate?: Date; + }>; + stats: { + total: number; + active: number; + pending: number; + approved: number; + rejected: number; + totalInvestment: number; + totalImpressions: number; + }; +} + +interface SponsorCampaignsTemplateProps { + viewData: SponsorCampaignsViewData; + filteredSponsorships: SponsorCampaignsViewData['sponsorships']; + typeFilter: SponsorshipType; + setTypeFilter: (type: SponsorshipType) => void; + searchQuery: string; + setSearchQuery: (query: string) => void; +} + +export function SponsorCampaignsTemplate({ + viewData, + filteredSponsorships, + typeFilter, + setTypeFilter, + searchQuery, + setSearchQuery +}: SponsorCampaignsTemplateProps) { + const billingStats: Array<{ + label: string; + value: string | number; + icon: LucideIcon; + variant: 'success' | 'warning' | 'info' | 'default'; + }> = [ + { + label: 'Active Campaigns', + value: viewData.stats.active, + icon: Check, + variant: 'success', + }, + { + label: 'Pending Approval', + value: viewData.stats.pending, + icon: Clock, + variant: viewData.stats.pending > 0 ? 'warning' : 'default', + }, + { + label: 'Total Investment', + value: `$${viewData.stats.totalInvestment.toLocaleString()}`, + icon: BarChart3, + variant: 'info', + }, + { + label: 'Total Impressions', + value: `${(viewData.stats.totalImpressions / 1000).toFixed(0)}k`, + icon: Eye, + variant: 'default', + }, + ]; + + return ( + + + console.log('Refresh')} + /> + + + + + + + + + + ) => setSearchQuery(e.target.value)} + w="full" + pl={10} + pr={4} + py={2} + rounded="lg" + border + borderColor="border-charcoal-outline" + bg="bg-iron-gray/50" + color="text-white" + outline="none" + focusBorderColor="border-primary-blue" + /> + + + + {(['all', 'leagues', 'teams', 'drivers'] as const).map((type) => ( + setTypeFilter(type)} + > + {type.charAt(0).toUpperCase() + type.slice(1)} + + ))} + + + + + {filteredSponsorships.map((s) => { + return ( + + ); + })} + + + + + ); +} diff --git a/apps/website/templates/SponsorDashboardTemplate.tsx b/apps/website/templates/SponsorDashboardTemplate.tsx index 15d7a4ace..5c3964a5b 100644 --- a/apps/website/templates/SponsorDashboardTemplate.tsx +++ b/apps/website/templates/SponsorDashboardTemplate.tsx @@ -6,38 +6,32 @@ import { Button } from '@/ui/Button'; import { Heading } from '@/ui/Heading'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; import { Link } from '@/ui/Link'; import { Container } from '@/ui/Container'; import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; -import { Surface } from '@/ui/Surface'; import { Icon } from '@/ui/Icon'; -import { Badge } from '@/ui/Badge'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; +import { SponsorContractCard } from '@/components/sponsors/SponsorContractCard'; import { MetricCard } from '@/components/sponsors/MetricCard'; import { SponsorshipCategoryCard } from '@/components/sponsors/SponsorshipCategoryCard'; -import { ActivityItem } from '@/components/sponsors/ActivityItem'; import { RenewalAlert } from '@/components/sponsors/RenewalAlert'; +import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel'; +import { SponsorActivityPanel, Activity } from '@/components/sponsors/SponsorActivityPanel'; import { Eye, Users, Trophy, TrendingUp, DollarSign, - Target, - ExternalLink, - Car, - Flag, - Megaphone, ChevronRight, Plus, Bell, - Settings, - CreditCard, - FileText, - RefreshCw, - BarChart3, - Calendar + Clock, + Car, + Flag, + Megaphone, + LucideIcon } from 'lucide-react'; import type { SponsorDashboardViewData } from '@/lib/view-data/SponsorDashboardViewData'; import { routes } from '@/lib/routing/RouteConfig'; @@ -49,43 +43,59 @@ interface SponsorDashboardTemplateProps { export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateProps) { const categoryData = viewData.categoryData; + const billingStats: Array<{ + label: string; + value: string | number; + icon: LucideIcon; + variant: 'info' | 'success' | 'default' | 'warning'; + }> = [ + { + label: 'Total Investment', + value: viewData.formattedTotalInvestment, + icon: DollarSign, + variant: 'info', + }, + { + label: 'Active Sponsorships', + value: viewData.activeSponsorships, + icon: Trophy, + variant: 'success', + }, + { + label: 'Cost per 1K Views', + value: viewData.costPerThousandViews, + icon: Eye, + variant: 'default', + }, + { + label: 'Upcoming Renewals', + value: viewData.upcomingRenewals.length, + icon: Bell, + variant: viewData.upcomingRenewals.length > 0 ? 'warning' : 'default', + }, + ]; + + const activities: Activity[] = viewData.recentActivity.map(a => ({ + id: a.id, + type: 'sponsorship_approved', // Mapping logic would go here + title: a.message, + description: a.formattedImpressions ? `${a.formattedImpressions} impressions` : '', + timestamp: a.time, + icon: Clock, + color: a.typeColor || 'text-primary-blue', + })); + return ( {/* Header */} - - - Sponsor Dashboard - Welcome back, {viewData.sponsorName} - - - {/* Time Range Selector */} - - - {(['7d', '30d', '90d', 'all'] as const).map((range) => ( - - {range === 'all' ? 'All' : range} - - ))} - - + console.log('Refresh')} + /> - - - - - - - - - - - - + {/* Billing Summary */} + {/* Key Metrics */} @@ -120,167 +130,135 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP /> - {/* Sponsorship Categories */} - - - Your Sponsorships - - - }> - View All - - - - - - - - - - - - - - {/* Main Content Grid */} - {/* Top Performing Sponsorships */} - - - - Top Performing - - - }> - Find More - - - - - - - - - - Main - - - - Sample League - - Sample details - - - - - 1.2k - impressions - - - - - - - - - + {/* Sponsorship Categories */} + + + Your Sponsorships + + }> + View All + + + - {/* Upcoming Events */} - - - }> - Upcoming Sponsored Events - - - - - - No upcoming sponsored events - - - + + + + + + + + + + {/* Top Performing Sponsorships */} + + + Top Performing + + }> + Find More + + + + + + + + + {/* Recent Activity */} + + {/* Quick Actions */} Quick Actions - - - }> - Find Leagues to Sponsor - - - - - - }> - Browse Teams - - - - - - }> - Discover Drivers - - - - - - }> - Manage Billing - - - - - - }> - View Analytics - - - + + }> + Find Leagues to Sponsor + + + + }> + Browse Teams + + + + }> + Discover Drivers + + + + }> + Manage Billing + + @@ -300,56 +278,6 @@ export function SponsorDashboardTemplate({ viewData }: SponsorDashboardTemplateP )} - - {/* Recent Activity */} - - - Recent Activity - - {viewData.recentActivity.map((activity) => ( - - ))} - - - - - {/* Investment Summary */} - - - }> - Investment Summary - - - - Active Sponsorships - {viewData.activeSponsorships} - - - Total Investment - {viewData.formattedTotalInvestment} - - - Cost per 1K Views - - {viewData.costPerThousandViews} - - - - Next Invoice - Jan 1, 2026 - - - - - }> - View Billing Details - - - - - - - diff --git a/apps/website/templates/SponsorLeagueDetailTemplate.tsx b/apps/website/templates/SponsorLeagueDetailTemplate.tsx index 21521a955..6daf2e0e4 100644 --- a/apps/website/templates/SponsorLeagueDetailTemplate.tsx +++ b/apps/website/templates/SponsorLeagueDetailTemplate.tsx @@ -1,14 +1,16 @@ 'use client'; import React from 'react'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; +import { PricingTableShell, PricingTier } from '@/components/sponsors/PricingTableShell'; +import { BillingSummaryPanel } from '@/components/sponsors/BillingSummaryPanel'; +import { SponsorBrandingPreview } from '@/components/sponsors/SponsorBrandingPreview'; +import { SponsorStatusChip } from '@/components/sponsors/SponsorStatusChip'; import { Trophy, - Users, Calendar, Eye, TrendingUp, - ExternalLink, - Star, Flag, BarChart3, Megaphone, @@ -28,9 +30,6 @@ import { Grid } from '@/ui/Grid'; import { GridItem } from '@/ui/GridItem'; import { Surface } from '@/ui/Surface'; import { Icon } from '@/ui/Icon'; -import { Badge } from '@/ui/Badge'; -import { SponsorTierCard } from '@/components/sponsors/SponsorTierCard'; -import { NumberDisplay } from '@/lib/display-objects/NumberDisplay'; import { siteConfig } from '@/lib/siteConfig'; import { routes } from '@/lib/routing/RouteConfig'; @@ -118,6 +117,60 @@ export function SponsorLeagueDetailTemplate({ }: SponsorLeagueDetailTemplateProps) { const league = viewData.league; + const billingStats: Array<{ + label: string; + value: string | number; + icon: LucideIcon; + variant: 'info' | 'success' | 'warning' | 'default'; + }> = [ + { + label: 'Total Views', + value: league.formattedTotalImpressions, + icon: Eye, + variant: 'info', + }, + { + label: 'Avg/Race', + value: league.formattedAvgViewsPerRace, + icon: TrendingUp, + variant: 'success', + }, + { + label: 'Engagement', + value: `${league.engagement}%`, + icon: BarChart3, + variant: 'warning', + }, + { + label: 'Races Left', + value: league.racesLeft, + icon: Calendar, + variant: 'default', + }, + ]; + + const pricingTiers: PricingTier[] = [ + { + id: 'main', + name: 'Main Sponsor', + price: league.sponsorSlots.main.price, + period: 'Season', + description: 'Exclusive primary branding across all league assets.', + features: league.sponsorSlots.main.benefits, + available: league.sponsorSlots.main.available, + isPopular: true, + }, + { + id: 'secondary', + name: 'Secondary Sponsor', + price: league.sponsorSlots.secondary.price, + period: 'Season', + description: 'Supporting branding on cars and broadcast overlays.', + features: league.sponsorSlots.secondary.benefits, + available: league.sponsorSlots.secondary.available > 0, + } + ]; + return ( @@ -137,49 +190,13 @@ export function SponsorLeagueDetailTemplate({ {/* Header */} - - - - ⭐ {league.tier} - Active Season - - - - {league.rating} - - - - {league.name} - - {league.game} • {league.season} • {league.completedRaces}/{league.races} races completed - - - {league.description} - - - - - - }> - View League - - - {(league.sponsorSlots.main.available || league.sponsorSlots.secondary.available > 0) && ( - setActiveTab('sponsor')} icon={}> - Become a Sponsor - - )} - - + console.log('Refresh')} + /> {/* Quick Stats */} - - - - - - - + {/* Tabs */} @@ -321,11 +338,11 @@ export function SponsorLeagueDetailTemplate({ {race.status === 'completed' ? ( - {NumberDisplay.format(race.views)} + {race.views.toLocaleString()} views ) : ( - Upcoming + )} @@ -336,85 +353,65 @@ export function SponsorLeagueDetailTemplate({ )} {activeTab === 'sponsor' && ( - - - setSelectedTier('main')} + + + setSelectedTier(id as 'main' | 'secondary')} /> - 0} - availableCount={league.sponsorSlots.secondary.available} - totalCount={league.sponsorSlots.secondary.total} - price={league.sponsorSlots.secondary.price} - benefits={league.sponsorSlots.secondary.benefits} - isSelected={selectedTier === 'secondary'} - onClick={() => setSelectedTier('secondary')} - /> - - - - - }> - Sponsorship Summary - - - - - - - - - - Total (excl. VAT) - - ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)} - + + + + + + + + + }> + Sponsorship Summary + + + + + + + + + + Total (excl. VAT) + + ${((selectedTier === 'main' ? league.sponsorSlots.main.price : league.sponsorSlots.secondary.price) * (1 + siteConfig.fees.platformFeePercent / 100)).toFixed(2)} + + + - - - - {siteConfig.vat.notice} - + + {siteConfig.vat.notice} + - - }> - Request Sponsorship - - }> - Download Info Pack - + + }> + Request Sponsorship + + }> + Download Info Pack + + + - - + + )} ); } -function StatCard({ icon, label, value, color }: { icon: LucideIcon, label: string, value: string | number, color: string }) { - return ( - - - - - - - {value} - {label} - - - - ); -} - function InfoRow({ label, value, color = 'text-white', last }: { label: string, value: string | number, color?: string, last?: boolean }) { return ( diff --git a/apps/website/templates/SponsorLeaguesTemplate.tsx b/apps/website/templates/SponsorLeaguesTemplate.tsx index 40e165917..83322a2a5 100644 --- a/apps/website/templates/SponsorLeaguesTemplate.tsx +++ b/apps/website/templates/SponsorLeaguesTemplate.tsx @@ -22,6 +22,7 @@ import { } from 'lucide-react'; import { siteConfig } from '@/lib/siteConfig'; import { routes } from '@/lib/routing/RouteConfig'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; import { AvailableLeagueCard } from '@/components/sponsors/AvailableLeagueCard'; import { Input } from '@/ui/Input'; @@ -47,7 +48,7 @@ export type SortOption = 'rating' | 'drivers' | 'price' | 'views'; export type TierFilter = 'all' | 'premium' | 'standard' | 'starter'; export type AvailabilityFilter = 'all' | 'main' | 'secondary'; -interface SponsorLeaguesViewData { +export interface SponsorLeaguesViewData { leagues: AvailableLeague[]; stats: { total: number; @@ -88,14 +89,10 @@ export function SponsorLeaguesTemplate({ {/* Header */} - - }> - League Sponsorship Marketplace - - - Discover racing leagues looking for sponsors. All prices shown exclude VAT. - - + console.log('Refresh')} + /> {/* Stats Overview */} diff --git a/apps/website/templates/SponsorSettingsTemplate.tsx b/apps/website/templates/SponsorSettingsTemplate.tsx new file mode 100644 index 000000000..327f42a06 --- /dev/null +++ b/apps/website/templates/SponsorSettingsTemplate.tsx @@ -0,0 +1,208 @@ +import React from 'react'; +import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Input } from '@/ui/Input'; +import { Toggle } from '@/ui/Toggle'; +import { FormField } from '@/ui/FormField'; +import { SponsorDashboardHeader } from '@/components/sponsors/SponsorDashboardHeader'; +import { + Building2, + Save, + Bell, + AlertCircle, + RefreshCw +} from 'lucide-react'; + +export interface SponsorSettingsViewData { + profile: { + companyName: string; + contactName: string; + contactEmail: string; + description: string; + industry: string; + }; + notifications: { + emailNewSponsorships: boolean; + emailWeeklyReport: boolean; + emailPaymentAlerts: boolean; + }; + privacy: { + publicProfile: boolean; + showStats: boolean; + showActiveSponsorships: boolean; + allowDirectContact: boolean; + }; +} + +interface SponsorSettingsTemplateProps { + viewData: SponsorSettingsViewData; + profile: { + companyName: string; + contactName: string; + contactEmail: string; + description: string; + industry: string; + }; + setProfile: (profile: { + companyName: string; + contactName: string; + contactEmail: string; + description: string; + industry: string; + }) => void; + notifications: { + emailNewSponsorships: boolean; + emailWeeklyReport: boolean; + emailPaymentAlerts: boolean; + }; + setNotifications: (notifications: { + emailNewSponsorships: boolean; + emailWeeklyReport: boolean; + emailPaymentAlerts: boolean; + }) => void; + onSaveProfile: () => void | Promise; + onDeleteAccount: () => void | Promise; + saving: boolean; + saved: boolean; +} + +export function SponsorSettingsTemplate({ + profile, + setProfile, + notifications, + setNotifications, + onSaveProfile, + onDeleteAccount, + saving, +}: SponsorSettingsTemplateProps) { + return ( + + + console.log('Refresh')} + /> + + + {/* Company Profile */} + + + }> + Company Profile + + + + + setProfile({ ...profile, companyName: e.target.value })} + /> + + + setProfile({ ...profile, industry: e.target.value })} + /> + + + setProfile({ ...profile, contactName: e.target.value })} + /> + + + setProfile({ ...profile, contactEmail: e.target.value })} + /> + + + + + ) => setProfile({ ...profile, description: e.target.value })} + w="full" + p={3} + rounded="lg" + border + borderColor="border-charcoal-outline" + bg="bg-iron-gray/50" + color="text-white" + outline="none" + focusBorderColor="border-primary-blue" + /> + + + + } + > + {saving ? 'Saving...' : 'Save Changes'} + + + + + + {/* Notifications */} + + + }> + Notifications + + + setNotifications({ ...notifications, emailNewSponsorships: checked })} + /> + setNotifications({ ...notifications, emailWeeklyReport: checked })} + /> + setNotifications({ ...notifications, emailPaymentAlerts: checked })} + /> + + + + + {/* Danger Zone */} + + + }> + Danger Zone + + + + Delete Sponsor Account + Permanently remove your account and all data. + + + Delete Account + + + + + + + + ); +} diff --git a/apps/website/templates/SponsorshipRequestsTemplate.tsx b/apps/website/templates/SponsorshipRequestsTemplate.tsx index 51daa250c..062e45fd4 100644 --- a/apps/website/templates/SponsorshipRequestsTemplate.tsx +++ b/apps/website/templates/SponsorshipRequestsTemplate.tsx @@ -1,95 +1,39 @@ 'use client'; import React from 'react'; -import { Card } from '@/ui/Card'; -import { Button } from '@/ui/Button'; -import { Container } from '@/ui/Container'; +import { Stack } from '@/ui/Stack'; import { Heading } from '@/ui/Heading'; import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; -import { Text } from '@/ui/Text'; -import { Surface } from '@/ui/Surface'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; import type { SponsorshipRequestsViewData } from '@/lib/view-data/SponsorshipRequestsViewData'; +import { SponsorshipRequestsPanel } from '@/components/profile/SponsorshipRequestsPanel'; export interface SponsorshipRequestsTemplateProps { viewData: SponsorshipRequestsViewData; onAccept: (requestId: string) => Promise; onReject: (requestId: string, reason?: string) => Promise; + processingId?: string | null; } export function SponsorshipRequestsTemplate({ viewData, onAccept, onReject, + processingId, }: SponsorshipRequestsTemplateProps) { return ( - - - - Sponsorship Requests - - Manage pending sponsorship requests for your profile. - - + + + Sponsorship Requests + - {viewData.sections.map((section) => ( - - - - {section.entityName} - - {section.requests.length} {section.requests.length === 1 ? 'request' : 'requests'} - - - - {section.requests.length === 0 ? ( - No pending requests. - ) : ( - - {section.requests.map((request) => ( - - - - {request.sponsorName} - {request.message && ( - {request.message} - )} - - {DateDisplay.formatShort(request.createdAtIso)} - - - - onAccept(request.id)} - size="sm" - > - Accept - - onReject(request.id)} - size="sm" - > - Reject - - - - - ))} - - )} - - - ))} - - + + + + ); } diff --git a/apps/website/templates/TeamDetailTemplate.tsx b/apps/website/templates/TeamDetailTemplate.tsx index b20e3b2dc..8e8431d6e 100644 --- a/apps/website/templates/TeamDetailTemplate.tsx +++ b/apps/website/templates/TeamDetailTemplate.tsx @@ -2,12 +2,11 @@ import React from 'react'; import { Breadcrumbs } from '@/ui/Breadcrumbs'; -import { SlotTemplates } from '@/components/sponsors/SlotTemplates'; import { SponsorInsightsCard } from '@/components/sponsors/SponsorInsightsCard'; +import { SlotTemplates } from '@/components/sponsors/SlotTemplates'; import { useSponsorMode } from '@/hooks/sponsor/useSponsorMode'; 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'; @@ -15,12 +14,11 @@ import { Heading } from '@/ui/Heading'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { HorizontalStatItem } from '@/ui/HorizontalStatItem'; -import { DateDisplay } from '@/lib/display-objects/DateDisplay'; +import { TeamDetailsHeader } from '@/components/teams/TeamDetailsHeader'; +import { TeamMembersTable } from '@/components/teams/TeamMembersTable'; +import { TeamStandingsPanel } from '@/components/teams/TeamStandingsPanel'; import { TeamAdmin } from '@/components/teams/TeamAdmin'; -import { TeamHero } from '@/components/teams/TeamHero'; -import { TeamRoster } from '@/components/teams/TeamRoster'; -import { TeamStandings } from '@/components/teams/TeamStandings'; import type { TeamDetailViewData } from '@/lib/view-data/TeamDetailViewData'; type Tab = 'overview' | 'roster' | 'standings' | 'admin'; @@ -34,7 +32,6 @@ export interface TeamDetailTemplateProps { onTabChange: (tab: Tab) => void; onUpdate: () => void; onRemoveMember: (driverId: string) => void; - onChangeRole: (driverId: string, newRole: 'owner' | 'admin' | 'member') => void; onGoBack: () => void; } @@ -45,170 +42,178 @@ export function TeamDetailTemplate({ onTabChange, onUpdate, onRemoveMember, - onChangeRole, onGoBack, }: TeamDetailTemplateProps) { const isSponsorMode = useSponsorMode(); - const team = viewData.team; - // Show loading state if (loading) { return ( - - - Loading team... + + + + Synchronizing Telemetry... - + ); } - // Show not found state if (!team) { return ( - - - - - Team Not Found - - The team you're looking for doesn't exist or has been disbanded. - + + + + 404: Team Disconnected + + The requested team entity is no longer broadcasting. + + + + Return to Base + - - Go Back - - - - + + + ); } return ( - - - {/* Breadcrumb */} - - - {/* Sponsor Insights Card */} - {isSponsorMode && team && ( - window.location.href = href} + + + + - )} - ({ id })) - }} - memberCount={viewData.memberships.length} - onUpdate={onUpdate} - /> - - {/* Tabs */} - - - {viewData.tabs.map((tab) => ( - tab.visible && ( - onTabChange(tab.id)} - pb={3} - cursor="pointer" - borderBottom={activeTab === tab.id ? '2px solid' : '2px solid'} - borderColor={activeTab === tab.id ? 'border-primary-blue' : 'border-transparent'} - color={activeTab === tab.id ? 'text-primary-blue' : 'text-gray-400'} - > - {tab.label} - - ) - ))} - - - - - {activeTab === 'overview' && ( - - - - - - About - - {team.description} - - - - - - - Quick Stats - - - - {team.category && ( - - )} - {team.leagues && team.leagues.length > 0 && ( - - )} - {team.createdAt && ( - - )} - - - - - - - - Recent Activity - - - No recent activity to display - - - - )} - - {activeTab === 'roster' && ( - window.location.href = href} /> )} - {activeTab === 'standings' && ( - - )} + onTabChange('admin')} + /> - {activeTab === 'admin' && viewData.isAdmin && ( - - )} - - - + {/* Tabs */} + + + {viewData.tabs.map((tab) => ( + tab.visible && ( + onTabChange(tab.id)} + pb={4} + cursor="pointer" + position="relative" + > + + {tab.label} + + {activeTab === tab.id && ( + + )} + + ) + ))} + + + + + {activeTab === 'overview' && ( + + + + + + Mission Statement + + {team.description || 'No description provided.'} + + + + + Recent Operations + + NO RECENT TELEMETRY DATA + + + + + + + + + Performance Metrics + + + + + + + + + + + )} + + {activeTab === 'roster' && ( + + + Active Personnel + {viewData.memberships.length} UNITS ACTIVE + + + + )} + + {activeTab === 'standings' && ( + // Mocked for now as in original + )} + + {activeTab === 'admin' && viewData.isAdmin && ( + + )} + + + + ); } diff --git a/apps/website/templates/TeamLeaderboardTemplate.tsx b/apps/website/templates/TeamLeaderboardTemplate.tsx index b03011846..d7cfbdd61 100644 --- a/apps/website/templates/TeamLeaderboardTemplate.tsx +++ b/apps/website/templates/TeamLeaderboardTemplate.tsx @@ -1,18 +1,16 @@ 'use client'; import React from 'react'; -import { Award, ArrowLeft } from 'lucide-react'; -import { Button } from '@/ui/Button'; -import { Heading } from '@/ui/Heading'; +import { Award, ChevronLeft } from 'lucide-react'; +import { Container } from '@/ui/Container'; import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; import { Text } from '@/ui/Text'; -import { Container } from '@/ui/Container'; +import { Button } from '@/ui/Button'; import { Icon } from '@/ui/Icon'; -import { ModalIcon } from '@/ui/ModalIcon'; -import { TeamPodium } from '@/components/teams/TeamPodium'; -import { TeamFilter } from '@/ui/TeamFilter'; -import { TeamRankingsTable } from '@/components/teams/TeamRankingsTable'; +import { Table, TableBody, TableHead, TableHeader, TableRow, TableCell } from '@/ui/Table'; +import { LeaderboardFiltersBar } from '@/components/leaderboards/LeaderboardFiltersBar'; import type { TeamLeaderboardViewData, SkillLevel, SortBy } from '@/lib/view-data/TeamLeaderboardViewData'; interface TeamLeaderboardTemplateProps { @@ -27,64 +25,93 @@ interface TeamLeaderboardTemplateProps { export function TeamLeaderboardTemplate({ viewData, onSearchChange, - filterLevelChange, - onSortChange, onTeamClick, onBackToTeams, }: TeamLeaderboardTemplateProps) { - const { searchQuery, filterLevel, sortBy, filteredAndSortedTeams } = viewData; + const { searchQuery, filteredAndSortedTeams } = viewData; return ( - - - {/* Header */} - - - } - > - Back to Teams - - - - - - - Team Leaderboard - Rankings of all teams by performance metrics - + + + + {/* Header */} + + + }> + Back + + + Global Standings + Team Performance Index + + + - - {/* Filters and Search */} - + - {/* Podium for Top 3 */} - {sortBy === 'rating' && filterLevel === 'all' && !searchQuery && filteredAndSortedTeams.length >= 3 && ( - - )} - - {/* Leaderboard Table */} - - - + + + + + Rank + Team + Personnel + Races + Rating + + + + {filteredAndSortedTeams.length > 0 ? ( + filteredAndSortedTeams.map((team, index) => ( + onTeamClick(team.id)} + cursor="pointer" + hoverBg="surface-charcoal/50" + > + + + #{index + 1} + + + + + + {team.name.substring(0, 2).toUpperCase()} + + {team.name} + + + + {team.memberCount} + + + {team.totalRaces} + + + 1450 + + + )) + ) : ( + + + + No teams found matching criteria + + + + )} + + + + + + ); } diff --git a/apps/website/templates/TeamsTemplate.tsx b/apps/website/templates/TeamsTemplate.tsx index 39abe1f96..18341f0a5 100644 --- a/apps/website/templates/TeamsTemplate.tsx +++ b/apps/website/templates/TeamsTemplate.tsx @@ -2,17 +2,15 @@ import React from 'react'; import { Users } from 'lucide-react'; -import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper'; -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 { Container } from '@/ui/Container'; -import { Grid } from '@/ui/Grid'; -import { TeamCard } from '@/ui/TeamCardWrapper'; import { EmptyState } from '@/components/shared/state/EmptyState'; -import type { TeamSummaryData, TeamsViewData } from '@/lib/view-data/TeamsViewData'; +import { TeamsDirectoryHeader } from '@/components/teams/TeamsDirectoryHeader'; +import { TeamGrid } from '@/components/teams/TeamGrid'; +import { TeamLeaderboardPreview } from '@/components/teams/TeamLeaderboardPreviewWrapper'; +import type { TeamsViewData } from '@/lib/view-data/TeamsViewData'; interface TeamsTemplateProps { viewData: TeamsViewData; @@ -25,50 +23,39 @@ export function TeamsTemplate({ viewData, onTeamClick, onViewFullLeaderboard, on const { teams } = viewData; return ( - - - - {/* Header */} - - - Teams - Browse and manage your racing teams - - - Create Team - - + + + + - {/* Teams Grid */} - {teams.length > 0 ? ( - - {teams.map((team: TeamSummaryData) => ( - onTeamClick?.(team.teamId)} - /> - ))} - - ) : ( - - )} + + + + Active Rosters + + + {teams.length > 0 ? ( + + ) : ( + + )} + {/* Team Leaderboard Preview */} - + + + + Global Standings + onTeamClick?.(id)} diff --git a/apps/website/templates/actions/ActionsTemplate.tsx b/apps/website/templates/actions/ActionsTemplate.tsx new file mode 100644 index 000000000..cfa9730bf --- /dev/null +++ b/apps/website/templates/actions/ActionsTemplate.tsx @@ -0,0 +1,21 @@ +'use client'; + +import { ActionsViewData } from '@/lib/view-data/ActionsViewData'; +import { ActionsHeader } from '@/components/actions/ActionsHeader'; +import { ActionList } from '@/components/actions/ActionList'; +import { ActionFiltersBar } from '@/components/actions/ActionFiltersBar'; +import { Box } from '@/ui/Box'; + +export function ActionsTemplate({ actions }: ActionsViewData) { + return ( + + + + + + + + + + ); +} diff --git a/apps/website/templates/auth/ForgotPasswordTemplate.tsx b/apps/website/templates/auth/ForgotPasswordTemplate.tsx index 68176c3cd..9889f9178 100644 --- a/apps/website/templates/auth/ForgotPasswordTemplate.tsx +++ b/apps/website/templates/auth/ForgotPasswordTemplate.tsx @@ -1,25 +1,18 @@ 'use client'; import React from 'react'; -import { - Mail, - ArrowLeft, - AlertCircle, - Flag, - Shield, - CheckCircle2, -} from 'lucide-react'; -import { Card } from '@/ui/Card'; +import { Mail, ArrowLeft, AlertCircle, Shield, CheckCircle2 } from 'lucide-react'; import { Button } from '@/ui/Button'; import { Input } from '@/ui/Input'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Link } from '@/ui/Link'; -import { Surface } from '@/ui/Surface'; -import { Icon } from '@/ui/Icon'; import { LoadingSpinner } from '@/ui/LoadingSpinner'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { AuthCard } from '@/components/auth/AuthCard'; +import { AuthForm } from '@/components/auth/AuthForm'; +import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks'; import { routes } from '@/lib/routing/RouteConfig'; import { ForgotPasswordViewData } from '@/lib/builders/view-data/types/ForgotPasswordViewData'; @@ -37,145 +30,97 @@ interface ForgotPasswordTemplateProps { } export function ForgotPasswordTemplate({ viewData, formActions, mutationState }: ForgotPasswordTemplateProps) { + const isSubmitting = mutationState.isPending; + return ( - - {/* Background Pattern */} - - - - {/* Header */} - - - - - Reset Password - - Enter your email and we will send you a reset link - - + + {!viewData.showSuccess ? ( + + } + /> - - {/* Background accent */} - - - {!viewData.showSuccess ? ( - - - {/* Email */} - - - Email Address - - - - - - - - {viewData.formState.fields.email.error && ( - - {viewData.formState.fields.email.error} - - )} - - - {/* Error Message */} - {mutationState.error && ( - - - - {mutationState.error} - - - )} - - {/* Submit Button */} - : } - > - {mutationState.isPending ? 'Sending...' : 'Send Reset Link'} - - - {/* Back to Login */} - - - - - Back to Login - - - + {mutationState.error && ( + + + + {mutationState.error} - ) : ( - - - - - - {viewData.successMessage} - {viewData.magicLink && ( - - Development Mode - Magic Link: - - {viewData.magicLink} - - - In production, this would be sent via email - - - )} - - - - - window.location.href = '/auth/login'} - fullWidth - > - Return to Login - - )} - - {/* Trust Indicators */} - - - - Secure reset process - - - - 15 minute expiration - - + : } + > + {isSubmitting ? 'Sending...' : 'Send Reset Link'} + - {/* Footer */} - - - Need help?{' '} - - Contact support + + + + + Back to Login + - - - - + + + ) : ( + + + + + + Check your email + {viewData.successMessage} + + + + + {viewData.magicLink && ( + + DEVELOPMENT MAGIC LINK + + + {viewData.magicLink} + + + + )} + + window.location.href = '/auth/login'} + fullWidth + > + Return to Login + + + )} + + + + Need help?{' '} + Contact support + + + ); } diff --git a/apps/website/templates/auth/LoginTemplate.tsx b/apps/website/templates/auth/LoginTemplate.tsx index be307676f..14541ea56 100644 --- a/apps/website/templates/auth/LoginTemplate.tsx +++ b/apps/website/templates/auth/LoginTemplate.tsx @@ -1,30 +1,20 @@ 'use client'; import React from 'react'; -import { - Mail, - Lock, - Eye, - EyeOff, - LogIn, - AlertCircle, - Flag, - Shield, -} from 'lucide-react'; -import { Card } from '@/ui/Card'; +import { LogIn, Mail, AlertCircle } from 'lucide-react'; import { Button } from '@/ui/Button'; import { Input } from '@/ui/Input'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Link } from '@/ui/Link'; -import { Surface } from '@/ui/Surface'; import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; import { LoadingSpinner } from '@/ui/LoadingSpinner'; +import { PasswordField } from '@/ui/PasswordField'; +import { AuthCard } from '@/components/auth/AuthCard'; +import { AuthForm } from '@/components/auth/AuthForm'; +import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks'; import { EnhancedFormError } from '@/components/errors/EnhancedFormError'; -import { UserRolesPreview } from '@/components/auth/UserRolesPreview'; -import { AuthWorkflowMockup } from '@/components/auth/AuthWorkflowMockup'; import { routes } from '@/lib/routing/RouteConfig'; import { LoginViewData } from '@/lib/builders/view-data/types/LoginViewData'; import { FormState } from '@/lib/builders/view-data/types/FormState'; @@ -45,264 +35,129 @@ interface LoginTemplateProps { } export function LoginTemplate({ viewData, formActions, mutationState }: LoginTemplateProps) { + const isSubmitting = viewData.formState.isSubmitting || mutationState.isPending; + return ( - - {/* Background Pattern */} - - - {/* Left Side - Info Panel (Hidden on mobile) */} - - - {/* Logo */} - - - - - GridPilot - + + + + } + /> - - - Your Sim Racing Infrastructure - - - - - Manage leagues, track performance, join teams, and compete with drivers worldwide. One account, multiple roles. - - - {/* Role Cards */} - - - {/* Workflow Mockup */} - - - - - {/* Trust Indicators */} - - - - Secure login - - iRacing verified - - - - - {/* Right Side - Login Form */} - - - {/* Mobile Logo/Header */} - - - - - Welcome Back - - Sign in to continue to GridPilot - - - - {/* Desktop Header */} - - Welcome Back - - Sign in to access your racing dashboard - - - - - {/* Background accent */} - - - - - {/* Email */} - - - Email Address - - - - - - - - {viewData.formState.fields.email.error && ( - - {viewData.formState.fields.email.error} - - )} - - - {/* Password */} - - - - Password - - - Forgot password? - - - - - - - - formActions.setShowPassword(!viewData.showPassword)} - position="absolute" - right="3" - top="50%" - zIndex={10} - bg="transparent" - borderStyle="none" - cursor="pointer" - > - - - - {viewData.formState.fields.password.error && ( - - {viewData.formState.fields.password.error} - - )} - - - {/* Remember Me */} - - - Keep me signed in - - - {/* Insufficient Permissions Message */} - {viewData.hasInsufficientPermissions && ( - - - - - Insufficient Permissions - - You don't have permission to access that page. Please log in with an account that has the required role. - - - - - )} - - {/* Enhanced Error Display */} - {viewData.submitError && ( - { - formActions.setFormState((prev: FormState) => ({ ...prev, submitError: undefined })); - }} - showDeveloperDetails={viewData.showErrorDetails} - /> - )} - - {/* Submit Button */} - : } - > - {mutationState.isPending || viewData.formState.isSubmitting ? 'Signing in...' : 'Sign In'} - - - - - {/* Divider */} - - - - - - - or continue with - - - - - {/* Sign Up Link */} - - - Don't have an account?{' '} - - Create one - - - - - - {/* Name Immutability Notice */} - - - - - - Note: Your display name cannot be changed after signup. Please ensure it's correct when creating your account. + + formActions.setShowPassword(!viewData.showPassword)} + /> + + + + Forgot password? - - - + + + - {/* Footer */} - - - By signing in, you agree to our{' '} - - Terms of Service - - {' '}and{' '} - - Privacy Policy - + + + + Keep me signed in - + + - {/* Mobile Role Info */} - - + {viewData.hasInsufficientPermissions && ( + + + + + Insufficient Permissions + + Please log in with an account that has the required role. + + + + )} + + {viewData.submitError && ( + { + formActions.setFormState((prev: FormState) => ({ ...prev, submitError: undefined })); + }} + showDeveloperDetails={viewData.showErrorDetails} + /> + )} + + : } + > + {isSubmitting ? 'Signing in...' : 'Sign In'} + + + + + + Don't have an account?{' '} + + Create one + + + + + + By signing in, you agree to our{' '} + Terms + {' '}and{' '} + Privacy + - - + + ); } diff --git a/apps/website/templates/auth/ResetPasswordTemplate.tsx b/apps/website/templates/auth/ResetPasswordTemplate.tsx index 317cca2cc..4d85acdbf 100644 --- a/apps/website/templates/auth/ResetPasswordTemplate.tsx +++ b/apps/website/templates/auth/ResetPasswordTemplate.tsx @@ -1,27 +1,18 @@ 'use client'; import React from 'react'; -import { - Lock, - Eye, - EyeOff, - AlertCircle, - Flag, - Shield, - CheckCircle2, - ArrowLeft, -} from 'lucide-react'; -import { Card } from '@/ui/Card'; +import { Shield, CheckCircle2, ArrowLeft, AlertCircle } from 'lucide-react'; import { Button } from '@/ui/Button'; -import { Input } from '@/ui/Input'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Link } from '@/ui/Link'; -import { Surface } from '@/ui/Surface'; -import { Icon } from '@/ui/Icon'; import { LoadingSpinner } from '@/ui/LoadingSpinner'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Icon } from '@/ui/Icon'; +import { AuthCard } from '@/components/auth/AuthCard'; +import { AuthForm } from '@/components/auth/AuthForm'; +import { PasswordField } from '@/ui/PasswordField'; +import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks'; import { routes } from '@/lib/routing/RouteConfig'; import { ResetPasswordViewData } from '@/lib/builders/view-data/types/ResetPasswordViewData'; @@ -50,194 +41,102 @@ export function ResetPasswordTemplate({ uiState, mutationState, }: ResetPasswordTemplateProps) { + const isSubmitting = mutationState.isPending; + return ( - - {/* Background Pattern */} - - - - {/* Header */} - - - - - Reset Password - - Create a new secure password for your account - - + + {!viewData.showSuccess ? ( + + + formActions.setShowPassword(!uiState.showPassword)} + /> - - {/* Background accent */} - + formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} + /> + - {!viewData.showSuccess ? ( - - - {/* New Password */} - - - New Password - - - - - - - formActions.setShowPassword(!uiState.showPassword)} - position="absolute" - right="3" - top="50%" - zIndex={10} - bg="transparent" - borderStyle="none" - cursor="pointer" - > - - - - {viewData.formState.fields.newPassword.error && ( - - {viewData.formState.fields.newPassword.error} - - )} - - - {/* Confirm Password */} - - - Confirm Password - - - - - - - formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} - position="absolute" - right="3" - top="50%" - zIndex={10} - bg="transparent" - borderStyle="none" - cursor="pointer" - > - - - - {viewData.formState.fields.confirmPassword.error && ( - - {viewData.formState.fields.confirmPassword.error} - - )} - - - {/* Error Message */} - {mutationState.error && ( - - - - {mutationState.error} - - - )} - - {/* Submit Button */} - : } - > - {mutationState.isPending ? 'Resetting...' : 'Reset Password'} - - - {/* Back to Login */} - - - - - Back to Login - - - + {mutationState.error && ( + + + + {mutationState.error} - ) : ( - - - - - - {viewData.successMessage} - - Your password has been successfully reset - - - - - - window.location.href = '/auth/login'} - fullWidth - > - Return to Login - - )} - - {/* Trust Indicators */} - - - - Secure password reset - - - - Encrypted transmission - - + : } + > + {isSubmitting ? 'Resetting...' : 'Reset Password'} + - {/* Footer */} - - - Need help?{' '} - - Contact support + + + + + Back to Login + - - - - + + + ) : ( + + + + + + Password Reset + {viewData.successMessage} + + + + + window.location.href = '/auth/login'} + fullWidth + > + Return to Login + + + )} + + + + Need help?{' '} + Contact support + + + ); } diff --git a/apps/website/templates/auth/SignupTemplate.tsx b/apps/website/templates/auth/SignupTemplate.tsx index 7ee2400e2..1dd6549d8 100644 --- a/apps/website/templates/auth/SignupTemplate.tsx +++ b/apps/website/templates/auth/SignupTemplate.tsx @@ -1,34 +1,19 @@ 'use client'; import React from 'react'; -import { - Mail, - Lock, - Eye, - EyeOff, - UserPlus, - AlertCircle, - Flag, - User, - Check, - X, - Car, - Users, - Trophy, - Shield, - Sparkles, -} from 'lucide-react'; -import { Card } from '@/ui/Card'; +import { UserPlus, Mail, User, Check, X, AlertCircle } from 'lucide-react'; import { Button } from '@/ui/Button'; import { Input } from '@/ui/Input'; -import { Heading } from '@/ui/Heading'; -import { Box } from '@/ui/Box'; -import { Stack } from '@/ui/Stack'; import { Text } from '@/ui/Text'; import { Link } from '@/ui/Link'; -import { Surface } from '@/ui/Surface'; import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; import { LoadingSpinner } from '@/ui/LoadingSpinner'; +import { PasswordField } from '@/ui/PasswordField'; +import { AuthCard } from '@/components/auth/AuthCard'; +import { AuthForm } from '@/components/auth/AuthForm'; +import { AuthFooterLinks } from '@/components/auth/AuthFooterLinks'; import { SignupViewData } from '@/lib/builders/view-data/types/SignupViewData'; import { checkPasswordStrength } from '@/lib/utils/validation'; @@ -50,432 +35,180 @@ interface SignupTemplateProps { }; } -const USER_ROLES = [ - { - icon: Car, - title: 'Driver', - description: 'Race, track stats, join teams', - color: '#3b82f6', - bg: 'bg-blue-500/10', - }, - { - icon: Trophy, - title: 'League Admin', - description: 'Organize leagues and events', - color: '#10b981', - bg: 'bg-green-500/10', - }, - { - icon: Users, - title: 'Team Manager', - description: 'Manage team and drivers', - color: '#a855f7', - bg: 'bg-purple-500/10', - }, -]; - -const FEATURES = [ - 'Track your racing statistics and progress', - 'Join or create competitive leagues', - 'Build or join racing teams', - 'Connect your iRacing account', - 'Compete in organized events', - 'Access detailed performance analytics', -]; - export function SignupTemplate({ viewData, formActions, uiState, mutationState }: SignupTemplateProps) { - const passwordStrength = checkPasswordStrength(viewData.formState.fields.password.value); + const isSubmitting = mutationState.isPending; + const passwordValue = viewData.formState.fields.password.value || ''; + const passwordStrength = checkPasswordStrength(passwordValue); const passwordRequirements = [ - { met: viewData.formState.fields.password.value.length >= 8, label: 'At least 8 characters' }, - { met: /[a-z]/.test(viewData.formState.fields.password.value) && /[A-Z]/.test(viewData.formState.fields.password.value), label: 'Upper and lowercase letters' }, - { met: /\d/.test(viewData.formState.fields.password.value), label: 'At least one number' }, - { met: /[^a-zA-Z\d]/.test(viewData.formState.fields.password.value), label: 'At least one special character' }, + { met: passwordValue.length >= 8, label: '8+ characters' }, + { met: /[a-z]/.test(passwordValue) && /[A-Z]/.test(passwordValue), label: 'Case mix' }, + { met: /\d/.test(passwordValue), label: 'Number' }, + { met: /[^a-zA-Z\d]/.test(passwordValue), label: 'Special' }, ]; return ( - - {/* Background Pattern */} - - - {/* Left Side - Info Panel (Hidden on mobile) */} - - - {/* Logo */} - - - - - GridPilot + + + + + Personal Information + + } + /> + } + /> + + + + + + + Note: Your name cannot be changed after signup. + + + + + } + /> - - Start Your Racing Journey - - - - Join thousands of sim racers. One account gives you access to all roles - race as a driver, organize leagues, or manage teams. - + + Security + formActions.setShowPassword(!uiState.showPassword)} + /> - {/* Role Cards */} - - {USER_ROLES.map((role) => ( - - - - - - - {role.title} - {role.description} + {passwordValue && ( + + + + + + {passwordStrength.label} + - - ))} - - - {/* Features List */} - - - - - What you'll get - - - {FEATURES.map((feature, index) => ( - - - {feature} - - ))} - - - - - {/* Trust Indicators */} - - - - Secure signup - - iRacing integration - - - - - {/* Right Side - Signup Form */} - - - {/* Mobile Logo/Header */} - - - - - Join GridPilot - - Create your account and start racing - - - - {/* Desktop Header */} - - Create Account - - Get started with your free account - - - - - {/* Background accent */} - - - - - {/* First Name */} - - - First Name - - - - - - - - {viewData.formState.fields.firstName.error && ( - - {viewData.formState.fields.firstName.error} - - )} - - - {/* Last Name */} - - - Last Name - - - - - - - - {viewData.formState.fields.lastName.error && ( - - {viewData.formState.fields.lastName.error} - - )} - Your name will be used as-is and cannot be changed later - - - {/* Name Immutability Warning */} - - - - - Important: Your name cannot be changed after signup. Please ensure it's correct. - - - - - {/* Email */} - - - Email Address - - - - - - - - {viewData.formState.fields.email.error && ( - - {viewData.formState.fields.email.error} - - )} - - - {/* Password */} - - - Password - - - - - - - formActions.setShowPassword(!uiState.showPassword)} - position="absolute" - right="3" - top="50%" - zIndex={10} - bg="transparent" - borderStyle="none" - cursor="pointer" - > - - - - {viewData.formState.fields.password.error && ( - - {viewData.formState.fields.password.error} - - )} - - {/* Password Strength */} - {viewData.formState.fields.password.value && ( - - - - - - - {passwordStrength.label} - - - - {passwordRequirements.map((req, index) => ( - - - - {req.label} - - - ))} - - - )} - - - {/* Confirm Password */} - - - Confirm Password - - - - - - - formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} - position="absolute" - right="3" - top="50%" - zIndex={10} - bg="transparent" - borderStyle="none" - cursor="pointer" - > - - - - {viewData.formState.fields.confirmPassword.error && ( - - {viewData.formState.fields.confirmPassword.error} - - )} - {viewData.formState.fields.confirmPassword.value && viewData.formState.fields.password.value === viewData.formState.fields.confirmPassword.value && ( - - - Passwords match + + {passwordRequirements.map((req, index) => ( + + + + {req.label} + - )} + ))} - - {/* Submit Button */} - : } - > - {mutationState.isPending ? 'Creating account...' : 'Create Account'} - - + )} - {/* Divider */} - - - - - - - or continue with - - - + formActions.setShowConfirmPassword(!uiState.showConfirmPassword)} + /> + + - {/* Login Link */} - - - Already have an account?{' '} - - Sign in - - - - - - {/* Footer */} - - - By creating an account, you agree to our{' '} - - Terms of Service - - {' '}and{' '} - - Privacy Policy - - - - - {/* Mobile Role Info */} - - One account for all roles - - {USER_ROLES.map((role) => ( - - - - - {role.title} - - ))} + {mutationState.error && ( + + + + {mutationState.error} + )} + + : } + > + {isSubmitting ? 'Creating account...' : 'Create Account'} + + + + + + Already have an account?{' '} + + Sign in + + + + + + By creating an account, you agree to our{' '} + Terms + {' '}and{' '} + Privacy + - - + + ); } diff --git a/apps/website/templates/layout/GlobalFooterTemplate.tsx b/apps/website/templates/layout/GlobalFooterTemplate.tsx new file mode 100644 index 000000000..2155402f5 --- /dev/null +++ b/apps/website/templates/layout/GlobalFooterTemplate.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { AppFooter } from '@/ui/AppFooter'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import Image from 'next/image'; +import Link from 'next/link'; + +export interface GlobalFooterViewData {} + +export function GlobalFooterTemplate(_props: GlobalFooterViewData) { + return ( + + + + + + + + + The professional infrastructure for serious sim racing. + Precision telemetry, automated results, and elite league management. + + + + + © 2026 GRIDPILOT + + + + + + + PLATFORM + + + + + Leagues + + + + + Teams + + + + + Leaderboards + + + + + + + + SUPPORT + + + + + Documentation + + + + + System Status + + + + + Contact + + + + + + + ); +} diff --git a/apps/website/templates/layout/GlobalSidebarTemplate.tsx b/apps/website/templates/layout/GlobalSidebarTemplate.tsx new file mode 100644 index 000000000..65d44251a --- /dev/null +++ b/apps/website/templates/layout/GlobalSidebarTemplate.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { DashboardRail } from '@/ui/DashboardRail'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Trophy, Users, Calendar, Layout, Settings, Home } from 'lucide-react'; +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; + +export interface GlobalSidebarViewData {} + +export function GlobalSidebarTemplate(_props: GlobalSidebarViewData) { + const pathname = usePathname(); + + const navItems = [ + { label: 'Dashboard', href: '/', icon: Home }, + { label: 'Leagues', href: '/leagues', icon: Trophy }, + { label: 'Teams', href: '/teams', icon: Users }, + { label: 'Races', href: '/races', icon: Calendar }, + { label: 'Leaderboards', href: '/leaderboards', icon: Layout }, + { label: 'Settings', href: '/settings', icon: Settings }, + ]; + + return ( + + + + + NAVIGATION + + + + {navItems.map((item) => { + const isActive = pathname === item.href; + const Icon = item.icon; + return ( + + + {item.label} + {isActive && ( + + )} + + ); + })} + + + + + + + SYSTEM ONLINE + + + + + + ); +} diff --git a/apps/website/templates/layout/HeaderContentTemplate.tsx b/apps/website/templates/layout/HeaderContentTemplate.tsx new file mode 100644 index 000000000..1dada35d5 --- /dev/null +++ b/apps/website/templates/layout/HeaderContentTemplate.tsx @@ -0,0 +1,53 @@ +import React from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import { Text } from '@/ui/Text'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; + +export interface HeaderContentViewData {} + +export function HeaderContentTemplate(_props: HeaderContentViewData) { + return ( + <> + + + + + + + + + + + + + MOTORSPORT INFRASTRUCTURE + + + + + + + STATUS: + OPERATIONAL + + + > + ); +} diff --git a/apps/website/templates/layout/RootAppShellTemplate.tsx b/apps/website/templates/layout/RootAppShellTemplate.tsx new file mode 100644 index 000000000..825131ec7 --- /dev/null +++ b/apps/website/templates/layout/RootAppShellTemplate.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React from 'react'; +import { AppShell } from '@/ui/AppShell'; +import { ControlBar } from '@/ui/ControlBar'; +import { TopNav } from '@/ui/TopNav'; +import { ContentViewport } from '@/ui/ContentViewport'; +import { GlobalSidebarTemplate } from './GlobalSidebarTemplate'; +import { GlobalFooterTemplate } from './GlobalFooterTemplate'; +import { HeaderContentTemplate } from './HeaderContentTemplate'; +import { Box } from '@/ui/Box'; + +export interface RootAppShellViewData { + children: React.ReactNode; +} + +/** + * RootAppShellTemplate orchestrates the top-level semantic shells of the application. + * It follows the "Telemetry Workspace" structure: + * - ControlBar = header/control bar + * - DashboardRail = sidebar rail + * - ContentViewport = content area + */ +export function RootAppShellTemplate({ children }: RootAppShellViewData) { + return ( + + + + + + + + + + + + + {children} + + + + + + ); +} diff --git a/apps/website/templates/onboarding/OnboardingTemplate.tsx b/apps/website/templates/onboarding/OnboardingTemplate.tsx new file mode 100644 index 000000000..f355e432a --- /dev/null +++ b/apps/website/templates/onboarding/OnboardingTemplate.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { OnboardingShell } from '@/components/onboarding/OnboardingShell'; +import { OnboardingStepper } from '@/components/onboarding/OnboardingStepper'; +import { OnboardingHelpPanel } from '@/components/onboarding/OnboardingHelpPanel'; +import { OnboardingStepPanel } from '@/components/onboarding/OnboardingStepPanel'; +import { OnboardingPrimaryActions } from '@/components/onboarding/OnboardingPrimaryActions'; +import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep'; +import { AvatarInfo, AvatarStep } from '@/components/onboarding/AvatarStep'; +import { FormEvent } from 'react'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { OnboardingError } from '@/ui/OnboardingError'; + +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 interface OnboardingViewData { + onCompleted: () => void; + onCompleteOnboarding: (data: { + firstName: string; + lastName: string; + displayName: string; + country: string; + timezone?: string; + }) => Promise<{ success: boolean; error?: string }>; + onGenerateAvatars: (params: { + facePhotoData: string; + suitColor: string; + }) => Promise<{ success: boolean; data?: { success: boolean; avatarUrls?: string[]; errorMessage?: string }; error?: string }>; + isProcessing: boolean; + step: OnboardingStep; + setStep: (step: OnboardingStep) => void; + errors: FormErrors; + setErrors: (errors: FormErrors) => void; + personalInfo: PersonalInfo; + setPersonalInfo: (info: PersonalInfo) => void; + avatarInfo: AvatarInfo; + setAvatarInfo: (info: AvatarInfo) => void; +} + +interface OnboardingTemplateProps { + viewData: OnboardingViewData; +} + +export function OnboardingTemplate({ viewData }: OnboardingTemplateProps) { + const { + onCompleted, + onCompleteOnboarding, + onGenerateAvatars, + isProcessing, + step, + setStep, + errors, + setErrors, + personalInfo, + setPersonalInfo, + avatarInfo, + setAvatarInfo + } = viewData; + + const steps = ['Personal Info', 'Racing Avatar']; + + // Validation + const validateStep = (currentStep: OnboardingStep): boolean => { + const newErrors: FormErrors = {}; + + if (currentStep === 1) { + if (!personalInfo.firstName.trim()) { + newErrors.firstName = 'First name is required'; + } + if (!personalInfo.lastName.trim()) { + newErrors.lastName = 'Last name is required'; + } + if (!personalInfo.displayName.trim()) { + newErrors.displayName = 'Display name is required'; + } else if (personalInfo.displayName.length < 3) { + newErrors.displayName = 'Display name must be at least 3 characters'; + } + if (!personalInfo.country) { + newErrors.country = 'Please select your country'; + } + } + + if (currentStep === 2) { + if (!avatarInfo.facePhoto) { + newErrors.facePhoto = 'Please upload a photo of your face'; + } + if (avatarInfo.generatedAvatars.length > 0 && avatarInfo.selectedAvatarIndex === null) { + newErrors.avatar = 'Please select one of the generated avatars'; + } + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleNext = () => { + const isValid = validateStep(step); + if (isValid && step < 2) { + setStep((step + 1) as OnboardingStep); + } + }; + + const handleBack = () => { + if (step > 1) { + setStep((step - 1) as OnboardingStep); + } + }; + + const generateAvatars = async () => { + if (!avatarInfo.facePhoto) { + setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); + return; + } + + setAvatarInfo({ ...avatarInfo, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null }); + const newErrors = { ...errors }; + delete newErrors.avatar; + setErrors(newErrors); + + try { + const result = await onGenerateAvatars({ + facePhotoData: avatarInfo.facePhoto, + suitColor: avatarInfo.suitColor, + }); + + if (result.success && result.data?.success && result.data.avatarUrls) { + setAvatarInfo({ + ...avatarInfo, + generatedAvatars: result.data.avatarUrls, + isGenerating: false, + }); + } else { + setErrors({ ...errors, avatar: result.data?.errorMessage || result.error || 'Failed to generate avatars' }); + setAvatarInfo({ ...avatarInfo, isGenerating: false }); + } + } catch (error) { + setErrors({ ...errors, avatar: 'Failed to generate avatars. Please try again.' }); + setAvatarInfo({ ...avatarInfo, isGenerating: false }); + } + }; + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + + if (!validateStep(2)) { + return; + } + + if (avatarInfo.selectedAvatarIndex === null) { + setErrors({ ...errors, avatar: 'Please select an avatar' }); + return; + } + + setErrors({}); + + try { + const result = await onCompleteOnboarding({ + firstName: personalInfo.firstName.trim(), + lastName: personalInfo.lastName.trim(), + displayName: personalInfo.displayName.trim(), + country: personalInfo.country, + timezone: personalInfo.timezone || undefined, + }); + + if (result.success) { + onCompleted(); + } else { + setErrors({ submit: result.error || 'Failed to create profile' }); + } + } catch (error) { + setErrors({ submit: 'Failed to create profile' }); + } + }; + + const header = ( + + + + GridPilot Onboarding + + + System Initialization + + + + + + + ); + + const sidebar = ( + + + Welcome to GridPilot. We're setting up your racing identity. This process ensures you're ready for the track with a complete profile and a unique AI-generated avatar. + + + {step === 2 && ( + + Our AI uses your photo to create a professional racing avatar. For best results, use a clear, front-facing photo with good lighting. + + )} + + ); + + return ( + + + {step === 1 && ( + + + + )} + + {step === 2 && ( + + + + )} + + {errors.submit && ( + + + + )} + + 1 ? handleBack : undefined} + onNext={step < 2 ? handleNext : undefined} + isLastStep={step === 2} + canNext={step === 1 ? true : avatarInfo.selectedAvatarIndex !== null} + isLoading={isProcessing} + type={step === 2 ? 'submit' : 'button'} + /> + + + ); +} diff --git a/apps/website/ui/AppFooter.tsx b/apps/website/ui/AppFooter.tsx new file mode 100644 index 000000000..d5416c6c0 --- /dev/null +++ b/apps/website/ui/AppFooter.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface AppFooterProps { + children?: React.ReactNode; + className?: string; +} + +/** + * AppFooter is the bottom section of the application. + */ +export function AppFooter({ children, className = '' }: AppFooterProps) { + return ( + + ); +} diff --git a/apps/website/ui/AppHeader.tsx b/apps/website/ui/AppHeader.tsx new file mode 100644 index 000000000..6f911911d --- /dev/null +++ b/apps/website/ui/AppHeader.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface AppHeaderProps { + children: React.ReactNode; + className?: string; +} + +/** + * AppHeader is the top control bar of the application. + * It follows the "Telemetry Workspace" structure. + */ +export function AppHeader({ children, className = '' }: AppHeaderProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/AppShell.tsx b/apps/website/ui/AppShell.tsx new file mode 100644 index 000000000..f6d7af878 --- /dev/null +++ b/apps/website/ui/AppShell.tsx @@ -0,0 +1,18 @@ +import React from 'react'; + +interface AppShellProps { + children: React.ReactNode; + className?: string; +} + +/** + * AppShell is the root container for the entire application layout. + * It provides the base background and layout structure. + */ +export function AppShell({ children, className = '' }: AppShellProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/AppSidebar.tsx b/apps/website/ui/AppSidebar.tsx new file mode 100644 index 000000000..149fbc5aa --- /dev/null +++ b/apps/website/ui/AppSidebar.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface AppSidebarProps { + children?: React.ReactNode; + className?: string; +} + +/** + * AppSidebar is the "dashboard rail" of the application. + * It provides global navigation and context. + */ +export function AppSidebar({ children, className = '' }: AppSidebarProps) { + return ( + + ); +} diff --git a/apps/website/ui/AuthContainer.tsx b/apps/website/ui/AuthContainer.tsx deleted file mode 100644 index 2ec95c3b6..000000000 --- a/apps/website/ui/AuthContainer.tsx +++ /dev/null @@ -1,16 +0,0 @@ - - -import React from 'react'; -import { Box } from './Box'; - -interface AuthContainerProps { - children: React.ReactNode; -} - -export function AuthContainer({ children }: AuthContainerProps) { - return ( - - {children} - - ); -} diff --git a/apps/website/ui/Avatar.tsx b/apps/website/ui/Avatar.tsx index 1ad1961f1..0a2332625 100644 --- a/apps/website/ui/Avatar.tsx +++ b/apps/website/ui/Avatar.tsx @@ -1,27 +1,51 @@ -/** - * Avatar - * - * Pure UI component for displaying driver avatars. - * Renders an image with fallback on error. - */ +import React from 'react'; +import { Box } from './Box'; +import { Image } from './Image'; +import { User } from 'lucide-react'; +import { Icon } from './Icon'; export interface AvatarProps { - driverId: string; + driverId?: string; + src?: string; alt: string; + size?: number; className?: string; + border?: boolean; } -export function Avatar({ driverId, alt, className = '' }: AvatarProps) { +export function Avatar({ + driverId, + src, + alt, + size = 40, + className = '', + border = true, +}: AvatarProps) { + const avatarSrc = src || (driverId ? `/media/avatar/${driverId}` : undefined); + return ( - // eslint-disable-next-line @next/next/no-img-element - { - // Fallback to default avatar - (e.target as HTMLImageElement).src = '/default-avatar.png'; - }} - /> + + {avatarSrc ? ( + + ) : ( + 32 ? 5 : 4} color="text-gray-500" /> + )} + ); -} \ No newline at end of file +} diff --git a/apps/website/ui/Box.tsx b/apps/website/ui/Box.tsx index 4f4640e3a..3dbb51e49 100644 --- a/apps/website/ui/Box.tsx +++ b/apps/website/ui/Box.tsx @@ -91,6 +91,7 @@ export interface BoxProps { groupHoverTextColor?: string; groupHoverScale?: boolean; groupHoverOpacity?: number; + groupHoverWidth?: 'full' | '0' | 'auto'; fontSize?: string; transform?: string; borderWidth?: string; @@ -115,6 +116,9 @@ export interface BoxProps { backgroundPosition?: string; backgroundColor?: string; insetY?: Spacing | string; + letterSpacing?: string; + lineHeight?: string; + backgroundImage?: string; } type ResponsiveValue = { @@ -190,6 +194,7 @@ export const Box = forwardRef(( groupHoverTextColor, groupHoverScale, groupHoverOpacity, + groupHoverWidth, fontSize, transform, borderWidth, @@ -213,6 +218,9 @@ export const Box = forwardRef(( backgroundPosition, backgroundColor, insetY, + letterSpacing, + lineHeight, + backgroundImage, ...props }: BoxProps & ComponentPropsWithoutRef, ref: ForwardedRef @@ -356,6 +364,7 @@ export const Box = forwardRef(( groupHoverTextColor ? `group-hover:text-${groupHoverTextColor}` : '', groupHoverScale ? 'group-hover:scale-[1.02]' : '', groupHoverOpacity !== undefined ? `group-hover:opacity-${groupHoverOpacity * 100}` : '', + groupHoverWidth ? `group-hover:w-${groupHoverWidth}` : '', getResponsiveClasses('', display), getFlexDirectionClass(flexDirection), getAlignItemsClass(alignItems), @@ -399,6 +408,9 @@ export const Box = forwardRef(( ...(webkitMaskImage ? { WebkitMaskImage: webkitMaskImage } : {}), ...(backgroundSize ? { backgroundSize } : {}), ...(backgroundPosition ? { backgroundPosition } : {}), + ...(backgroundImage ? { backgroundImage } : {}), + ...(letterSpacing ? { letterSpacing } : {}), + ...(lineHeight ? { lineHeight } : {}), ...(top !== undefined && !spacingMap[top as string | number] ? { top } : {}), ...(bottom !== undefined && !spacingMap[bottom as string | number] ? { bottom } : {}), ...(left !== undefined && !spacingMap[left as string | number] ? { left } : {}), diff --git a/apps/website/ui/BreadcrumbBar.tsx b/apps/website/ui/BreadcrumbBar.tsx new file mode 100644 index 000000000..725a6dc32 --- /dev/null +++ b/apps/website/ui/BreadcrumbBar.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface BreadcrumbBarProps { + children: React.ReactNode; + className?: string; +} + +/** + * BreadcrumbBar is a container for breadcrumbs, typically placed at the top of the ContentShell. + */ +export function BreadcrumbBar({ children, className = '' }: BreadcrumbBarProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/Button.tsx b/apps/website/ui/Button.tsx index 34d255443..fc217e1e5 100644 --- a/apps/website/ui/Button.tsx +++ b/apps/website/ui/Button.tsx @@ -1,6 +1,8 @@ import React, { ReactNode, MouseEventHandler, ButtonHTMLAttributes, forwardRef } from 'react'; import { Stack } from './Stack'; import { Box, BoxProps } from './Box'; +import { Loader2 } from 'lucide-react'; +import { Icon } from './Icon'; interface ButtonProps extends Omit, 'as' | 'onMouseEnter' | 'onMouseLeave' | 'onSubmit'>, Omit, 'as' | 'onClick' | 'onSubmit'> { children: ReactNode; @@ -9,6 +11,7 @@ interface ButtonProps extends Omit, 'as' variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord'; size?: 'sm' | 'md' | 'lg'; disabled?: boolean; + isLoading?: boolean; type?: 'button' | 'submit' | 'reset'; icon?: ReactNode; fullWidth?: boolean; @@ -25,6 +28,7 @@ export const Button = forwardRef(({ variant = 'primary', size = 'md', disabled = false, + isLoading = false, type = 'button', icon, fullWidth = false, @@ -51,7 +55,7 @@ export const Button = forwardRef(({ lg: 'min-h-[48px] px-6 py-3 text-base font-medium' }; - const disabledClasses = disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'; + const disabledClasses = (disabled || isLoading) ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'; const widthClasses = fullWidth ? 'w-full' : ''; const classes = [ @@ -63,12 +67,13 @@ export const Button = forwardRef(({ className ].filter(Boolean).join(' '); - const content = icon ? ( + const content = ( - {icon} + {isLoading && } + {!isLoading && icon} {children} - ) : children; + ); if (as === 'a') { return ( @@ -92,7 +97,7 @@ export const Button = forwardRef(({ type={type} className={classes} onClick={onClick} - disabled={disabled} + disabled={disabled || isLoading} {...props} > {content} diff --git a/apps/website/ui/CategoryIcon.tsx b/apps/website/ui/CategoryIcon.tsx index a87232c01..c3934a32d 100644 --- a/apps/website/ui/CategoryIcon.tsx +++ b/apps/website/ui/CategoryIcon.tsx @@ -1,27 +1,44 @@ -/** - * CategoryIcon - * - * Pure UI component for displaying category icons. - * Renders an image with fallback on error. - */ +import React from 'react'; +import { Box } from './Box'; +import { Image } from './Image'; +import { Tag } from 'lucide-react'; +import { Icon } from './Icon'; export interface CategoryIconProps { - categoryId: string; + categoryId?: string; + src?: string; alt: string; + size?: number; className?: string; } -export function CategoryIcon({ categoryId, alt, className = '' }: CategoryIconProps) { +export function CategoryIcon({ + categoryId, + src, + alt, + size = 24, + className = '', +}: CategoryIconProps) { + const iconSrc = src || (categoryId ? `/media/categories/${categoryId}/icon` : undefined); + return ( - // eslint-disable-next-line @next/next/no-img-element - { - // Fallback to default icon - (e.target as HTMLImageElement).src = '/default-category-icon.png'; - }} - /> + + {iconSrc ? ( + + ) : ( + 20 ? 4 : 3} color="text-gray-500" /> + )} + ); -} \ No newline at end of file +} diff --git a/apps/website/ui/ContentShell.tsx b/apps/website/ui/ContentShell.tsx new file mode 100644 index 000000000..858e6e59f --- /dev/null +++ b/apps/website/ui/ContentShell.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +interface ContentShellProps { + children: React.ReactNode; + className?: string; +} + +/** + * ContentShell is the main data zone of the application. + * It houses the primary content and track maps/data tables. + */ +export function ContentShell({ children, className = '' }: ContentShellProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/website/ui/ContentViewport.tsx b/apps/website/ui/ContentViewport.tsx new file mode 100644 index 000000000..6aaed7a93 --- /dev/null +++ b/apps/website/ui/ContentViewport.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface ContentViewportProps { + children: React.ReactNode; + className?: string; +} + +/** + * ContentViewport is the main data zone of the "Telemetry Workspace". + * It houses the primary content, track maps, and data tables. + * Aligned with "Precision Racing Minimal" theme. + */ +export function ContentViewport({ children, className = '' }: ContentViewportProps) { + return ( + + + {children} + + + ); +} diff --git a/apps/website/ui/ControlBar.tsx b/apps/website/ui/ControlBar.tsx new file mode 100644 index 000000000..5185d95bc --- /dev/null +++ b/apps/website/ui/ControlBar.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface ControlBarProps { + children: React.ReactNode; + className?: string; +} + +/** + * ControlBar is the top-level header of the "Telemetry Workspace". + * It provides global controls, navigation, and status information. + * Aligned with "Precision Racing Minimal" theme. + */ +export function ControlBar({ children, className = '' }: ControlBarProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/DashboardHero.tsx b/apps/website/ui/DashboardHero.tsx index f95f3058e..e2795933a 100644 --- a/apps/website/ui/DashboardHero.tsx +++ b/apps/website/ui/DashboardHero.tsx @@ -1,12 +1,9 @@ - - -import { ReactNode } from 'react'; -import { Badge } from './Badge'; +import React, { ReactNode } from 'react'; import { Box } from './Box'; import { Heading } from './Heading'; import { Image } from './Image'; -import { Stack } from './Stack'; import { Text } from './Text'; +import { Glow } from './Glow'; interface DashboardHeroProps { driverName: string; @@ -17,8 +14,15 @@ interface DashboardHeroProps { totalRaces: string | number; actions?: ReactNode; stats?: ReactNode; + className?: string; } +/** + * DashboardHero + * + * Redesigned for "Precision Racing Minimal" theme. + * Uses subtle accent glows and crisp separators. + */ export function DashboardHero({ driverName, avatarUrl, @@ -28,112 +32,111 @@ export function DashboardHero({ totalRaces, actions, stats, + className = '', }: DashboardHeroProps) { return ( - - {/* Background Pattern */} - + {/* Subtle Accent Glow */} + - - - {/* Welcome Message */} - + + + {/* Driver Identity */} + - - - + + - - Good morning, - - - {driverName} - + + + Driver Profile + + + / + + {country} + + + {driverName} - - - {rating} - - - #{rank} - - - {totalRaces} races completed - - + + + Rating + {rating} + + + Rank + #{rank} + + + Starts + {totalRaces} + + - + - {/* Quick Actions */} + {/* Actions */} {actions && ( - + {actions} - + )} - + - {/* Quick Stats Row */} + {/* Stats Grid */} {stats && ( - {stats} )} - + ); diff --git a/apps/website/ui/DashboardLayoutWrapper.tsx b/apps/website/ui/DashboardLayoutWrapper.tsx deleted file mode 100644 index 7874b0869..000000000 --- a/apps/website/ui/DashboardLayoutWrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { ReactNode } from 'react'; - -// TODO very useless component - -interface DashboardLayoutWrapperProps { - children: ReactNode; -} - -/** - * DashboardLayoutWrapper - * - * Full-screen layout wrapper for dashboard pages. - * Provides the base container with background styling. - */ -export function DashboardLayoutWrapper({ children }: DashboardLayoutWrapperProps) { - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/apps/website/ui/DashboardRail.tsx b/apps/website/ui/DashboardRail.tsx new file mode 100644 index 000000000..7f3279987 --- /dev/null +++ b/apps/website/ui/DashboardRail.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +interface DashboardRailProps { + children?: React.ReactNode; + className?: string; +} + +/** + * DashboardRail is the primary sidebar navigation for the "Telemetry Workspace". + * It provides a high-density, instrument-grade navigation experience. + * Aligned with "Precision Racing Minimal" theme. + */ +export function DashboardRail({ children, className = '' }: DashboardRailProps) { + return ( + + ); +} diff --git a/apps/website/ui/DriverHeaderPanel.tsx b/apps/website/ui/DriverHeaderPanel.tsx new file mode 100644 index 000000000..47c122b49 --- /dev/null +++ b/apps/website/ui/DriverHeaderPanel.tsx @@ -0,0 +1,109 @@ +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Stack } from './Stack'; +import { Image } from './Image'; +import { RatingBadge } from './RatingBadge'; + +interface DriverHeaderPanelProps { + name: string; + avatarUrl?: string; + nationality: string; + rating: number; + globalRank?: number | null; + bio?: string | null; + actions?: React.ReactNode; +} + +export function DriverHeaderPanel({ + name, + avatarUrl, + nationality, + rating, + globalRank, + bio, + actions +}: DriverHeaderPanelProps) { + const defaultAvatar = 'https://cdn.gridpilot.com/avatars/default.png'; + + return ( + + {/* Background Accent */} + + + + + {/* Avatar */} + + + + + {/* Info */} + + + + + {name} + + + + + + + {nationality} + + {globalRank !== undefined && globalRank !== null && ( + + Global Rank: #{globalRank} + + )} + + + {bio && ( + + {bio} + + )} + + + + {/* Actions */} + {actions && ( + + {actions} + + )} + + + + ); +} diff --git a/apps/website/ui/DurationField.tsx b/apps/website/ui/DurationField.tsx index 91124f1fc..bb705464c 100644 --- a/apps/website/ui/DurationField.tsx +++ b/apps/website/ui/DurationField.tsx @@ -55,7 +55,7 @@ export function DurationField({ disabled={disabled} min={1} className="pr-16" - error={!!error} + variant={error ? 'error' : 'default'} /> {unitLabel} diff --git a/apps/website/ui/FormSection.tsx b/apps/website/ui/FormSection.tsx new file mode 100644 index 000000000..bbe3bc1f8 --- /dev/null +++ b/apps/website/ui/FormSection.tsx @@ -0,0 +1,33 @@ +import React, { ReactNode } from 'react'; +import { Stack } from './Stack'; +import { Text } from './Text'; + +interface FormSectionProps { + children: ReactNode; + title?: string; +} + +/** + * FormSection + * + * Groups related form fields with an optional title. + */ +export function FormSection({ children, title }: FormSectionProps) { + return ( + + {title && ( + + {title} + + )} + + {children} + + + ); +} diff --git a/apps/website/ui/IconButton.tsx b/apps/website/ui/IconButton.tsx index ecb06a6d8..b898b4e8f 100644 --- a/apps/website/ui/IconButton.tsx +++ b/apps/website/ui/IconButton.tsx @@ -13,6 +13,8 @@ interface IconButtonProps { title?: string; disabled?: boolean; color?: string; + className?: string; + backgroundColor?: string; } export function IconButton({ @@ -23,6 +25,8 @@ export function IconButton({ title, disabled, color, + className = '', + backgroundColor, }: IconButtonProps) { const sizeMap = { sm: { btn: 'w-8 h-8 p-0', icon: 4 }, @@ -36,7 +40,8 @@ export function IconButton({ onClick={onClick} title={title} disabled={disabled} - className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0`} + className={`${sizeMap[size].btn} rounded-full flex items-center justify-center min-h-0 ${className}`} + backgroundColor={backgroundColor} > diff --git a/apps/website/ui/ImagePlaceholder.tsx b/apps/website/ui/ImagePlaceholder.tsx new file mode 100644 index 000000000..241c4b985 --- /dev/null +++ b/apps/website/ui/ImagePlaceholder.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import { Image as ImageIcon, AlertCircle, Loader2 } from 'lucide-react'; +import { Box } from './Box'; +import { Icon } from './Icon'; +import { Text } from './Text'; + +export interface ImagePlaceholderProps { + size?: number | string; + aspectRatio?: string; + variant?: 'default' | 'error' | 'loading'; + message?: string; + className?: string; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full'; +} + +export function ImagePlaceholder({ + size = 'full', + aspectRatio = '1/1', + variant = 'default', + message, + className = '', + rounded = 'md', +}: ImagePlaceholderProps) { + const config = { + default: { + icon: ImageIcon, + color: 'text-gray-500', + bg: 'bg-charcoal-outline/20', + borderColor: 'border-charcoal-outline/50', + animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined, + }, + error: { + icon: AlertCircle, + color: 'text-amber-500', + bg: 'bg-amber-500/5', + borderColor: 'border-amber-500/20', + animate: undefined as 'spin' | 'pulse' | 'bounce' | 'fade-in' | 'none' | undefined, + }, + loading: { + icon: Loader2, + color: 'text-blue-500', + bg: 'bg-blue-500/5', + borderColor: 'border-blue-500/20', + animate: 'spin' as const, + }, + }; + + const { icon, color, bg, borderColor, animate } = config[variant]; + + return ( + + + {message && ( + + {message} + + )} + + ); +} diff --git a/apps/website/ui/JoinRequestsPanel.tsx b/apps/website/ui/JoinRequestsPanel.tsx new file mode 100644 index 000000000..0c5655ef5 --- /dev/null +++ b/apps/website/ui/JoinRequestsPanel.tsx @@ -0,0 +1,88 @@ +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Heading } from './Heading'; +import { Button } from './Button'; +import { Check, X, Clock } from 'lucide-react'; +import { Icon } from './Icon'; + +interface JoinRequestsPanelProps { + requests: Array<{ + id: string; + driverName: string; + driverAvatar?: string; + message?: string; + requestedAt: string; + }>; + onAccept: (id: string) => void; + onDecline: (id: string) => void; +} + +export function JoinRequestsPanel({ requests, onAccept, onDecline }: JoinRequestsPanelProps) { + if (requests.length === 0) { + return ( + + No pending join requests + + ); + } + + return ( + + + + Pending Requests ({requests.length}) + + + + {requests.map((request) => ( + + + + + + {request.driverName.substring(0, 2).toUpperCase()} + + + + {request.driverName} + + + {request.requestedAt} + + + + + + onDecline(request.id)} + className="h-8 w-8 p-0 flex items-center justify-center border-red-500/30 hover:bg-red-500/10" + > + + + onAccept(request.id)} + className="h-8 w-8 p-0 flex items-center justify-center" + > + + + + + {request.message && ( + + + “{request.message}” + + + )} + + ))} + + + ); +} diff --git a/apps/website/ui/LandingItems.tsx b/apps/website/ui/LandingItems.tsx index e36ec09a0..b0c68607d 100644 --- a/apps/website/ui/LandingItems.tsx +++ b/apps/website/ui/LandingItems.tsx @@ -6,10 +6,10 @@ import { Box } from '@/ui/Box'; export function FeatureItem({ text }: { text: string }) { return ( - + - - + + {text} @@ -19,10 +19,10 @@ export function FeatureItem({ text }: { text: string }) { export function ResultItem({ text, color }: { text: string, color: string }) { return ( - + - - + + {text} @@ -32,12 +32,12 @@ export function ResultItem({ text, color }: { text: string, color: string }) { export function StepItem({ step, text }: { step: number, text: string }) { return ( - + - - {step.toString().padStart(2, '0')} + + {step.toString().padStart(2, '0')} - + {text} diff --git a/apps/website/ui/LeaderboardTableShell.tsx b/apps/website/ui/LeaderboardTableShell.tsx new file mode 100644 index 000000000..04173bbee --- /dev/null +++ b/apps/website/ui/LeaderboardTableShell.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Box } from './Box'; + +interface LeaderboardTableShellProps { + columns: { + key: string; + label: string; + align?: 'left' | 'center' | 'right'; + width?: string; + }[]; + children: React.ReactNode; + className?: string; +} + +export function LeaderboardTableShell({ columns, children, className = '' }: LeaderboardTableShellProps) { + return ( + + + + + + {columns.map((col) => ( + + {col.label} + + ))} + + + + {children} + + + + + ); +} diff --git a/apps/website/ui/LeagueCover.tsx b/apps/website/ui/LeagueCover.tsx index ed493bae5..c990cb907 100644 --- a/apps/website/ui/LeagueCover.tsx +++ b/apps/website/ui/LeagueCover.tsx @@ -1,20 +1,45 @@ - - +import React from 'react'; +import { Box } from './Box'; import { Image } from './Image'; +import { ImagePlaceholder } from './ImagePlaceholder'; export interface LeagueCoverProps { - leagueId: string; + leagueId?: string; + src?: string; alt: string; height?: string; + aspectRatio?: string; + className?: string; } -export function LeagueCover({ leagueId, alt, height = '12rem' }: LeagueCoverProps) { +export function LeagueCover({ + leagueId, + src, + alt, + height, + aspectRatio = '21/9', + className = '', +}: LeagueCoverProps) { + const coverSrc = src || (leagueId ? `/media/leagues/${leagueId}/cover` : undefined); + return ( - + + {coverSrc ? ( + + ) : ( + + )} + ); } diff --git a/apps/website/ui/LeagueLogo.tsx b/apps/website/ui/LeagueLogo.tsx index cb9cb2eba..69d720b54 100644 --- a/apps/website/ui/LeagueLogo.tsx +++ b/apps/website/ui/LeagueLogo.tsx @@ -1,22 +1,53 @@ - - +import React from 'react'; +import { Box } from './Box'; import { Image } from './Image'; +import { Trophy } from 'lucide-react'; +import { Icon } from './Icon'; export interface LeagueLogoProps { - leagueId: string; + leagueId?: string; + src?: string; alt: string; size?: number; + className?: string; + border?: boolean; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; } -export function LeagueLogo({ leagueId, alt, size = 100 }: LeagueLogoProps) { +export function LeagueLogo({ + leagueId, + src, + alt, + size = 64, + className = '', + border = true, + rounded = 'md', +}: LeagueLogoProps) { + const logoSrc = src || (leagueId ? `/media/leagues/${leagueId}/logo` : undefined); + return ( - + + {logoSrc ? ( + + ) : ( + 32 ? 5 : 4} color="text-gray-500" /> + )} + ); } diff --git a/apps/website/ui/LeagueSummaryCard.tsx b/apps/website/ui/LeagueSummaryCard.tsx index 994f8ae4b..f13defebb 100644 --- a/apps/website/ui/LeagueSummaryCard.tsx +++ b/apps/website/ui/LeagueSummaryCard.tsx @@ -7,7 +7,7 @@ import { Card } from './Card'; import { Grid } from './Grid'; import { Heading } from './Heading'; import { Icon } from './Icon'; -import { Image } from './Image'; +import { LeagueLogo } from './LeagueLogo'; import { Link } from './Link'; import { Stack } from './Stack'; import { Surface } from './Surface'; @@ -34,22 +34,7 @@ export function LeagueSummaryCard({ - - - + + + + + {title} + + + + + {items.map((item, index) => ( + + + {item.icon && } + + {item.label} + + + + {item.value} + + + ))} + + + ); +} + +// Helper to map common media metadata +export const mapMediaMetadata = (metadata: { + filename?: string; + size?: number; + dimensions?: string; + contentType?: string; + createdAt?: string | Date; +}): MediaMetaItem[] => { + const items: MediaMetaItem[] = []; + + if (metadata.filename) { + items.push({ label: 'Filename', value: metadata.filename, icon: FileText }); + } + if (metadata.size) { + items.push({ label: 'Size', value: `${Math.round(metadata.size / 1024)} KB`, icon: Maximize2 }); + } + if (metadata.dimensions) { + items.push({ label: 'Dimensions', value: metadata.dimensions, icon: Maximize2 }); + } + if (metadata.contentType) { + items.push({ label: 'Type', value: metadata.contentType, icon: Type }); + } + if (metadata.createdAt) { + const date = typeof metadata.createdAt === 'string' ? new Date(metadata.createdAt) : metadata.createdAt; + items.push({ label: 'Created', value: date.toLocaleDateString(), icon: Calendar }); + } + + return items; +}; diff --git a/apps/website/ui/MediaPreviewCard.tsx b/apps/website/ui/MediaPreviewCard.tsx new file mode 100644 index 000000000..b1c094dd2 --- /dev/null +++ b/apps/website/ui/MediaPreviewCard.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { ImagePlaceholder } from './ImagePlaceholder'; +import { Image } from './Image'; + +export interface MediaPreviewCardProps { + src?: string; + alt?: string; + title?: string; + subtitle?: string; + aspectRatio?: string; + isLoading?: boolean; + error?: string; + onClick?: () => void; + className?: string; + actions?: React.ReactNode; +} + +export function MediaPreviewCard({ + src, + alt = 'Media preview', + title, + subtitle, + aspectRatio = '16/9', + isLoading, + error, + onClick, + className = '', + actions, +}: MediaPreviewCardProps) { + return ( + + + {isLoading ? ( + + ) : error ? ( + + ) : src ? ( + + ) : ( + + )} + + {actions && ( + + {actions} + + )} + + + {(title || subtitle) && ( + + {title && ( + + {title} + + )} + {subtitle && ( + + {subtitle} + + )} + + )} + + ); +} diff --git a/apps/website/ui/MetricCard.tsx b/apps/website/ui/MetricCard.tsx new file mode 100644 index 000000000..44e433d94 --- /dev/null +++ b/apps/website/ui/MetricCard.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { LucideIcon } from 'lucide-react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Icon } from './Icon'; + +interface MetricCardProps { + label: string; + value: string | number; + icon?: LucideIcon; + color?: string; + trend?: { + value: number; + isPositive: boolean; + }; + border?: boolean; + bg?: string; +} + +/** + * A semantic component for displaying metrics. + * Instrument-grade typography and dense-but-readable hierarchy. + */ +export function MetricCard({ + label, + value, + icon, + color = 'text-primary-accent', + trend, + border = true, + bg = 'panel-gray/40', +}: MetricCardProps) { + return ( + + + + {icon && } + + {label} + + + {trend && ( + + {trend.isPositive ? '▲' : '▼'} {trend.value}% + + )} + + + {typeof value === 'number' ? value.toLocaleString() : value} + + + ); +} diff --git a/apps/website/ui/OnboardingCTA.tsx b/apps/website/ui/OnboardingCTA.tsx new file mode 100644 index 000000000..93188fff5 --- /dev/null +++ b/apps/website/ui/OnboardingCTA.tsx @@ -0,0 +1,59 @@ +import { Button } from '@/ui/Button'; +import { Stack } from '@/ui/Stack'; + +interface OnboardingCTAProps { + onBack?: () => void; + onNext?: () => void; + nextLabel?: string; + backLabel?: string; + isLastStep?: boolean; + canNext?: boolean; + isLoading?: boolean; + type?: 'button' | 'submit'; +} + +export function OnboardingCTA({ + onBack, + onNext, + nextLabel = 'Continue', + backLabel = 'Back', + isLastStep = false, + canNext = true, + isLoading = false, + type = 'button', +}: OnboardingCTAProps) { + return ( + + {onBack ? ( + + {backLabel} + + ) : ( + + )} + + + {isLoading ? ( + + ⟳ + Processing... + + ) : ( + isLastStep ? 'Complete Setup' : nextLabel + )} + + + ); +} diff --git a/apps/website/ui/OnboardingStepHeader.tsx b/apps/website/ui/OnboardingStepHeader.tsx new file mode 100644 index 000000000..a7389d0eb --- /dev/null +++ b/apps/website/ui/OnboardingStepHeader.tsx @@ -0,0 +1,22 @@ +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; + +interface OnboardingStepHeaderProps { + title: string; + description?: string; +} + +export function OnboardingStepHeader({ title, description }: OnboardingStepHeaderProps) { + return ( + + + {title} + + {description && ( + + {description} + + )} + + ); +} diff --git a/apps/website/ui/OnboardingStepPanel.tsx b/apps/website/ui/OnboardingStepPanel.tsx new file mode 100644 index 000000000..fdc687f5d --- /dev/null +++ b/apps/website/ui/OnboardingStepPanel.tsx @@ -0,0 +1,20 @@ +import { Surface } from '@/ui/Surface'; + +interface OnboardingStepPanelProps { + children: React.ReactNode; + className?: string; +} + +export function OnboardingStepPanel({ children, className = '' }: OnboardingStepPanelProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/Panel.tsx b/apps/website/ui/Panel.tsx new file mode 100644 index 000000000..79fc76e12 --- /dev/null +++ b/apps/website/ui/Panel.tsx @@ -0,0 +1,57 @@ +import React, { ReactNode } from 'react'; +import { Surface } from './Surface'; +import { Box, BoxProps } from './Box'; +import { Text } from './Text'; + +interface PanelProps extends Omit, 'variant' | 'padding'> { + children: ReactNode; + title?: string; + description?: string; + variant?: 'default' | 'muted' | 'dark' | 'glass'; + padding?: 0 | 1 | 2 | 3 | 4 | 6 | 8 | 10 | 12; + border?: boolean; + className?: string; +} + +/** + * A semantic wrapper for content panels. + * Follows the "Precision Racing Minimal" theme. + */ +export function Panel({ + children, + title, + description, + variant = 'default', + padding = 6, + border = true, + ...props +}: PanelProps) { + return ( + + {(title || description) && ( + + {title && ( + + {title} + + )} + {description && ( + + {description} + + )} + + )} + {children} + + ); +} diff --git a/apps/website/ui/PasswordField.tsx b/apps/website/ui/PasswordField.tsx new file mode 100644 index 000000000..794ec33ae --- /dev/null +++ b/apps/website/ui/PasswordField.tsx @@ -0,0 +1,42 @@ +import React, { ComponentProps } from 'react'; +import { Eye, EyeOff, Lock } from 'lucide-react'; +import { Input } from './Input'; +import { Box } from './Box'; + +interface PasswordFieldProps extends ComponentProps { + showPassword?: boolean; + onTogglePassword?: () => void; +} + +/** + * PasswordField + * + * A specialized input for passwords with visibility toggling. + * Stateless UI component. + */ +export function PasswordField({ showPassword, onTogglePassword, ...props }: PasswordFieldProps) { + return ( + + } + /> + {onTogglePassword && ( + + {showPassword ? : } + + )} + + ); +} diff --git a/apps/website/ui/ProfileLayoutShell.tsx b/apps/website/ui/ProfileLayoutShell.tsx deleted file mode 100644 index 4a1e18b43..000000000 --- a/apps/website/ui/ProfileLayoutShell.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import type { ReactNode } from 'react'; - -interface ProfileLayoutShellProps { - children: ReactNode; -} - -export function ProfileLayoutShell({ children }: ProfileLayoutShellProps) { - return {children}; -} diff --git a/apps/website/ui/QuickActionsPanel.tsx b/apps/website/ui/QuickActionsPanel.tsx new file mode 100644 index 000000000..a3dae9b33 --- /dev/null +++ b/apps/website/ui/QuickActionsPanel.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { Panel } from './Panel'; +import { Box } from './Box'; +import { Button } from './Button'; +import { Icon } from './Icon'; +import { LucideIcon } from 'lucide-react'; + +interface QuickAction { + label: string; + icon: LucideIcon; + onClick: () => void; + variant?: 'primary' | 'secondary' | 'ghost'; +} + +interface QuickActionsPanelProps { + actions: QuickAction[]; + className?: string; +} + +/** + * QuickActionsPanel + * + * Provides fast access to common dashboard tasks. + */ +export function QuickActionsPanel({ actions, className = '' }: QuickActionsPanelProps) { + return ( + + + {actions.map((action, index) => ( + + + + {action.label} + + + ))} + + + ); +} diff --git a/apps/website/ui/RaceActionBar.tsx b/apps/website/ui/RaceActionBar.tsx new file mode 100644 index 000000000..899f18ec2 --- /dev/null +++ b/apps/website/ui/RaceActionBar.tsx @@ -0,0 +1,146 @@ +import React from 'react'; +import { Stack } from './Stack'; +import { Button } from './Button'; +import { Icon } from './Icon'; +import { Trophy, Scale, LogOut, CheckCircle, XCircle, PlayCircle } from 'lucide-react'; + +interface RaceActionBarProps { + status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string; + isUserRegistered: boolean; + canRegister: boolean; + onRegister?: () => void; + onWithdraw?: () => void; + onResultsClick?: () => void; + onStewardingClick?: () => void; + onFileProtest?: () => void; + isAdmin?: boolean; + onCancel?: () => void; + onReopen?: () => void; + onEndRace?: () => void; + isLoading?: { + register?: boolean; + withdraw?: boolean; + cancel?: boolean; + reopen?: boolean; + complete?: boolean; + }; +} + +export function RaceActionBar({ + status, + isUserRegistered, + canRegister, + onRegister, + onWithdraw, + onResultsClick, + onStewardingClick, + onFileProtest, + isAdmin, + onCancel, + onReopen, + onEndRace, + isLoading = {} +}: RaceActionBarProps) { + return ( + + {status === 'scheduled' && ( + <> + {!isUserRegistered && canRegister && ( + } + > + Register + + )} + {isUserRegistered && ( + } + > + Withdraw + + )} + {isAdmin && ( + } + > + Cancel Race + + )} + > + )} + + {status === 'running' && ( + <> + }> + Live Now + + {isAdmin && ( + + End Race + + )} + > + )} + + {status === 'completed' && ( + <> + } + > + View Results + + {isUserRegistered && onFileProtest && ( + } + > + File Protest + + )} + } + > + Stewarding + + {isAdmin && onReopen && ( + + Reopen Race + + )} + > + )} + + {status === 'cancelled' && isAdmin && onReopen && ( + + Reopen Race + + )} + + ); +} diff --git a/apps/website/ui/RaceHeaderPanel.tsx b/apps/website/ui/RaceHeaderPanel.tsx new file mode 100644 index 000000000..64607b112 --- /dev/null +++ b/apps/website/ui/RaceHeaderPanel.tsx @@ -0,0 +1,95 @@ +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Stack } from './Stack'; +import { RaceStatusBadge } from './RaceStatusBadge'; +import { Icon } from './Icon'; +import { Calendar, MapPin, Car } from 'lucide-react'; + +interface RaceHeaderPanelProps { + track: string; + car: string; + scheduledAt: string; + status: string; + leagueName?: string; + actions?: React.ReactNode; +} + +export function RaceHeaderPanel({ + track, + car, + scheduledAt, + status, + leagueName, + actions +}: RaceHeaderPanelProps) { + return ( + + {/* Background Accent */} + + + + + {/* Info */} + + + + + {track} + + + + + + + + + {car} + + + + + + + {scheduledAt} + + + + {leagueName && ( + + + + {leagueName} + + + )} + + + + + {/* Actions */} + {actions && ( + + {actions} + + )} + + + + ); +} diff --git a/apps/website/ui/RacePageHeader.tsx b/apps/website/ui/RacePageHeader.tsx index a81d5ebed..d8ef8d022 100644 --- a/apps/website/ui/RacePageHeader.tsx +++ b/apps/website/ui/RacePageHeader.tsx @@ -1,13 +1,12 @@ import React from 'react'; -import { Flag, CalendarDays, Clock, Zap, Trophy } from 'lucide-react'; +import { Flag, CalendarDays, Clock, Zap, Trophy, LucideIcon } from 'lucide-react'; import { Heading } from '@/ui/Heading'; import { Text } from '@/ui/Text'; -import { Hero } from '@/ui/Hero'; import { Stack } from '@/ui/Stack'; import { Box } from '@/ui/Box'; import { Icon } from '@/ui/Icon'; import { Grid } from '@/ui/Grid'; -import { StatBox } from '@/ui/StatBox'; +import { Surface } from '@/ui/Surface'; interface RacePageHeaderProps { totalCount: number; @@ -23,26 +22,57 @@ export function RacePageHeader({ completedCount, }: RacePageHeaderProps) { return ( - - - - - - - Race Calendar - - - Track upcoming races, view live events, and explore results across all your leagues. - - + + {/* Background Accent */} + - {/* Quick Stats */} - - - - - - - + + + + + RACE DASHBOARD + + + Precision tracking for upcoming sessions and live events. + + + + + + + + + + + + ); +} + +function StatItem({ icon, label, value, color = 'text-white' }: { icon: LucideIcon, label: string, value: number, color?: string }) { + return ( + + + + + {label} + + {value} + + ); } diff --git a/apps/website/ui/RaceResultsTable.tsx b/apps/website/ui/RaceResultsTable.tsx index b9f52755a..37215bd98 100644 --- a/apps/website/ui/RaceResultsTable.tsx +++ b/apps/website/ui/RaceResultsTable.tsx @@ -129,7 +129,7 @@ export function RaceResultsTable({ Points +/- Penalties - {isAdmin && Actions} + {isAdmin && Actions} @@ -250,7 +250,7 @@ export function RaceResultsTable({ )} {isAdmin && ( - + {driver && penaltyButtonRenderer && penaltyButtonRenderer(driver)} )} diff --git a/apps/website/ui/RaceSidebarPanel.tsx b/apps/website/ui/RaceSidebarPanel.tsx new file mode 100644 index 000000000..6b59e3242 --- /dev/null +++ b/apps/website/ui/RaceSidebarPanel.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; +import { Stack } from './Stack'; +import { Icon } from './Icon'; +import { LucideIcon } from 'lucide-react'; + +interface RaceSidebarPanelProps { + title: string; + icon?: LucideIcon; + children: React.ReactNode; +} + +export function RaceSidebarPanel({ + title, + icon, + children +}: RaceSidebarPanelProps) { + return ( + + + + {icon && } + + {title} + + + + + {children} + + + ); +} diff --git a/apps/website/ui/RaceStatusBadge.tsx b/apps/website/ui/RaceStatusBadge.tsx index 1cccf5550..88ed4682c 100644 --- a/apps/website/ui/RaceStatusBadge.tsx +++ b/apps/website/ui/RaceStatusBadge.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StatusBadge } from './StatusBadge'; +import { Box } from './Box'; interface RaceStatusBadgeProps { status: 'scheduled' | 'running' | 'completed' | 'cancelled' | string; @@ -9,30 +9,52 @@ export function RaceStatusBadge({ status }: RaceStatusBadgeProps) { const config = { scheduled: { variant: 'info' as const, - label: 'Scheduled', + label: 'SCHEDULED', + color: 'text-primary-blue', + bg: 'bg-primary-blue/10', + border: 'border-primary-blue/30' }, running: { variant: 'success' as const, label: 'LIVE', + color: 'text-performance-green', + bg: 'bg-performance-green/10', + border: 'border-performance-green/30' }, completed: { variant: 'neutral' as const, - label: 'Completed', + label: 'COMPLETED', + color: 'text-gray-400', + bg: 'bg-gray-400/10', + border: 'border-gray-400/30' }, cancelled: { variant: 'warning' as const, - label: 'Cancelled', + label: 'CANCELLED', + color: 'text-warning-amber', + bg: 'bg-warning-amber/10', + border: 'border-warning-amber/30' }, }; const badgeConfig = config[status as keyof typeof config] || { variant: 'neutral' as const, - label: status, + label: status.toUpperCase(), + color: 'text-gray-400', + bg: 'bg-gray-400/10', + border: 'border-gray-400/30' }; return ( - + {badgeConfig.label} - + ); } diff --git a/apps/website/ui/RatingBadge.tsx b/apps/website/ui/RatingBadge.tsx new file mode 100644 index 000000000..f2859cbe3 --- /dev/null +++ b/apps/website/ui/RatingBadge.tsx @@ -0,0 +1,29 @@ +import React from 'react'; + +interface RatingBadgeProps { + rating: number; + size?: 'sm' | 'md' | 'lg'; + className?: string; +} + +export function RatingBadge({ rating, size = 'md', className = '' }: RatingBadgeProps) { + const getColor = (val: number) => { + if (val >= 2500) return 'text-yellow-400 bg-yellow-400/10 border-yellow-400/20'; + if (val >= 2000) return 'text-purple-400 bg-purple-400/10 border-purple-400/20'; + if (val >= 1500) return 'text-primary-blue bg-primary-blue/10 border-primary-blue/20'; + if (val >= 1000) return 'text-performance-green bg-performance-green/10 border-performance-green/20'; + return 'text-gray-400 bg-gray-400/10 border-gray-400/20'; + }; + + const sizeMap = { + sm: 'px-1.5 py-0.5 text-[10px]', + md: 'px-2 py-1 text-xs', + lg: 'px-3 py-1.5 text-sm', + }; + + return ( + + {rating.toLocaleString()} + + ); +} diff --git a/apps/website/ui/RosterTable.tsx b/apps/website/ui/RosterTable.tsx new file mode 100644 index 000000000..ccd298f02 --- /dev/null +++ b/apps/website/ui/RosterTable.tsx @@ -0,0 +1,85 @@ +import React, { ReactNode } from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table'; + +interface RosterTableProps { + children: ReactNode; + columns?: string[]; +} + +export function RosterTable({ children, columns = ['Driver', 'Role', 'Joined', 'Rating', 'Rank'] }: RosterTableProps) { + return ( + + + + + {columns.map((col) => ( + + + {col} + + + ))} + + {null} + + + + + {children} + + + + ); +} + +interface RosterTableRowProps { + driver: ReactNode; + role: ReactNode; + joined: string; + rating: ReactNode; + rank: ReactNode; + actions?: ReactNode; + onClick?: () => void; +} + +export function RosterTableRow({ + driver, + role, + joined, + rating, + rank, + actions, + onClick, +}: RosterTableRowProps) { + return ( + + + {driver} + + + {role} + + + {joined} + + + {rating} + + + {rank} + + + + {actions} + + + + ); +} diff --git a/apps/website/ui/Section.tsx b/apps/website/ui/Section.tsx index 06cc239c1..f2bcef80c 100644 --- a/apps/website/ui/Section.tsx +++ b/apps/website/ui/Section.tsx @@ -14,6 +14,10 @@ interface SectionProps { id?: string; py?: number; minHeight?: string; + borderBottom?: boolean; + borderColor?: string; + overflow?: 'hidden' | 'visible' | 'auto' | 'scroll'; + position?: 'relative' | 'absolute' | 'fixed' | 'sticky'; } export function Section({ @@ -24,7 +28,11 @@ export function Section({ variant = 'default', id, py = 16, - minHeight + minHeight, + borderBottom, + borderColor, + overflow, + position }: SectionProps) { const variantClasses = { default: '', @@ -40,7 +48,18 @@ export function Section({ ].filter(Boolean).join(' '); return ( - + {(title || description) && ( diff --git a/apps/website/ui/SessionSummaryPanel.tsx b/apps/website/ui/SessionSummaryPanel.tsx new file mode 100644 index 000000000..8416104f3 --- /dev/null +++ b/apps/website/ui/SessionSummaryPanel.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { Panel } from './Panel'; +import { Text } from './Text'; +import { Box } from './Box'; +import { StatusDot } from './StatusDot'; + +interface SessionSummaryPanelProps { + title: string; + status: 'live' | 'upcoming' | 'completed'; + startTime?: string; + trackName?: string; + carName?: string; + className?: string; +} + +/** + * SessionSummaryPanel + * + * Displays a dense summary of a racing session. + * Part of the "Telemetry Workspace" layout. + */ +export function SessionSummaryPanel({ + title, + status, + startTime, + trackName, + carName, + className = '', +}: SessionSummaryPanelProps) { + const statusColor = status === 'live' ? '#4ED4E0' : status === 'upcoming' ? '#FFBE4D' : '#94a3b8'; + + return ( + + + + {title} + + + + {status} + + + + + + {startTime && ( + + Start Time + {startTime} + + )} + {trackName && ( + + Track + {trackName} + + )} + {carName && ( + + Vehicle + {carName} + + )} + + + + ); +} diff --git a/apps/website/ui/SimpleCheckbox.tsx b/apps/website/ui/SimpleCheckbox.tsx new file mode 100644 index 000000000..fdead6363 --- /dev/null +++ b/apps/website/ui/SimpleCheckbox.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import { Box } from './Box'; + +interface CheckboxProps { + checked: boolean; + onChange: (checked: boolean) => void; + disabled?: boolean; + 'aria-label'?: string; +} + +/** + * SimpleCheckbox + * + * A checkbox without a label for use in tables. + */ +export function SimpleCheckbox({ checked, onChange, disabled, 'aria-label': ariaLabel }: CheckboxProps) { + return ( + ) => onChange(e.target.checked)} + disabled={disabled} + w="4" + h="4" + bg="bg-deep-graphite" + border + borderColor="border-charcoal-outline" + rounded="sm" + aria-label={ariaLabel} + className="text-primary-blue focus:ring-primary-blue" + /> + ); +} diff --git a/apps/website/ui/SponsorLogo.tsx b/apps/website/ui/SponsorLogo.tsx index b3ff1c84d..cf049f7b2 100644 --- a/apps/website/ui/SponsorLogo.tsx +++ b/apps/website/ui/SponsorLogo.tsx @@ -1,31 +1,53 @@ -/** - * SponsorLogo - * - * Pure UI component for displaying sponsor logos. - * Renders an optimized image with fallback on error. - */ - import React from 'react'; +import { Box } from './Box'; import { Image } from './Image'; +import { Building2 } from 'lucide-react'; +import { Icon } from './Icon'; export interface SponsorLogoProps { - sponsorId: string; + sponsorId?: string; + src?: string; alt: string; + size?: number; className?: string; + border?: boolean; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; } -export function SponsorLogo({ sponsorId, alt, className = '' }: SponsorLogoProps) { +export function SponsorLogo({ + sponsorId, + src, + alt, + size = 48, + className = '', + border = true, + rounded = 'md', +}: SponsorLogoProps) { + const logoSrc = src || (sponsorId ? `/media/sponsors/${sponsorId}/logo` : undefined); + return ( - { - // Fallback to default logo - (e.target as HTMLImageElement).src = '/default-sponsor-logo.png'; - }} - /> + + {logoSrc ? ( + + ) : ( + 32 ? 5 : 4} color="text-gray-500" /> + )} + ); } diff --git a/apps/website/ui/StatGrid.tsx b/apps/website/ui/StatGrid.tsx new file mode 100644 index 000000000..b2f579e7c --- /dev/null +++ b/apps/website/ui/StatGrid.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import { Grid } from './Grid'; +import { GridItem } from './GridItem'; +import { Surface } from './Surface'; +import { Text } from './Text'; +import { Stack } from './Stack'; + +type GridCols = 1 | 2 | 3 | 4 | 5 | 6 | 12; + +interface StatItem { + label: string; + value: string | number; + subValue?: string; + color?: string; + icon?: React.ElementType; +} + +interface StatGridProps { + stats: StatItem[]; + cols?: GridCols; + mdCols?: GridCols; + lgCols?: GridCols; + className?: string; +} + +export function StatGrid({ stats, cols = 2, mdCols = 3, lgCols = 4, className = '' }: StatGridProps) { + return ( + + {stats.map((stat, index) => ( + + + + + {stat.label} + + + + {stat.value} + + {stat.subValue && ( + + {stat.subValue} + + )} + + + + + ))} + + ); +} diff --git a/apps/website/ui/StatusDot.tsx b/apps/website/ui/StatusDot.tsx new file mode 100644 index 000000000..2fc3b084a --- /dev/null +++ b/apps/website/ui/StatusDot.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Box } from './Box'; + +interface StatusDotProps { + color?: string; + pulse?: boolean; + size?: number; + className?: string; +} + +/** + * StatusDot + * + * A simple status indicator dot with optional pulse effect. + */ +export function StatusDot({ + color = '#4ED4E0', + pulse = false, + size = 2, + className = '' +}: StatusDotProps) { + const sizeClass = `w-${size} h-${size}`; + + return ( + + + {pulse && ( + + )} + + ); +} diff --git a/apps/website/ui/StepProgressRail.tsx b/apps/website/ui/StepProgressRail.tsx new file mode 100644 index 000000000..c45127c66 --- /dev/null +++ b/apps/website/ui/StepProgressRail.tsx @@ -0,0 +1,22 @@ +import { Box } from '@/ui/Box'; +import { motion } from 'framer-motion'; + +interface StepProgressRailProps { + currentStep: number; + totalSteps: number; +} + +export function StepProgressRail({ currentStep, totalSteps }: StepProgressRailProps) { + const progress = (currentStep / totalSteps) * 100; + + return ( + + + + ); +} diff --git a/apps/website/ui/TeamHeaderPanel.tsx b/apps/website/ui/TeamHeaderPanel.tsx new file mode 100644 index 000000000..c7af9cc53 --- /dev/null +++ b/apps/website/ui/TeamHeaderPanel.tsx @@ -0,0 +1,117 @@ +import React, { ReactNode } from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Heading } from './Heading'; +import { Text } from './Text'; +import { TeamLogo } from './TeamLogo'; +import { TeamTag } from './TeamTag'; + +interface TeamHeaderPanelProps { + teamId: string; + name: string; + tag?: string | null; + description?: string; + memberCount: number; + activeLeaguesCount?: number; + foundedDate?: string; + category?: string | null; + actions?: ReactNode; +} + +export function TeamHeaderPanel({ + teamId, + name, + tag, + description, + memberCount, + activeLeaguesCount, + foundedDate, + category, + actions, +}: TeamHeaderPanelProps) { + return ( + + {/* Instrument-grade accent corner */} + + + + + {/* Logo Container */} + + + {/* Corner detail */} + + + + + + {name} + {tag && } + + + {description && ( + + {description} + + )} + + + + + + {memberCount} {memberCount === 1 ? 'Member' : 'Members'} + + + + {category && ( + + + + {category} + + + )} + + {activeLeaguesCount !== undefined && ( + + + + {activeLeaguesCount} {activeLeaguesCount === 1 ? 'League' : 'Leagues'} + + + )} + + {foundedDate && ( + + EST. {foundedDate} + + )} + + + + + {actions && ( + + {actions} + + )} + + + ); +} diff --git a/apps/website/ui/TeamLeaderboardPanel.tsx b/apps/website/ui/TeamLeaderboardPanel.tsx new file mode 100644 index 000000000..d78b9b05e --- /dev/null +++ b/apps/website/ui/TeamLeaderboardPanel.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { Box } from './Box'; +import { Stack } from './Stack'; +import { Text } from './Text'; +import { Table, TableHead, TableBody, TableRow, TableHeader, TableCell } from './Table'; +import { TeamLogo } from './TeamLogo'; +import { RankBadge } from './RankBadge'; + +interface TeamLeaderboardPanelProps { + teams: Array<{ + id: string; + name: string; + logoUrl?: string; + rating: number; + wins: number; + races: number; + memberCount: number; + }>; + onTeamClick: (id: string) => void; +} + +export function TeamLeaderboardPanel({ teams, onTeamClick }: TeamLeaderboardPanelProps) { + return ( + + + + + Rank + Team + Rating + Wins + Races + Members + + + + {teams.map((team, index) => ( + onTeamClick(team.id)} + clickable + className="group hover:bg-primary-blue/5 transition-colors border-b border-border-steel-grey/30 last:border-0" + > + + + + + + + + + + {team.name} + + + + + {team.rating} + + + {team.wins} + + + {team.races} + + + {team.memberCount} + + + ))} + + + + ); +} diff --git a/apps/website/ui/TeamLogo.tsx b/apps/website/ui/TeamLogo.tsx index ac5334964..2ad2080cf 100644 --- a/apps/website/ui/TeamLogo.tsx +++ b/apps/website/ui/TeamLogo.tsx @@ -1,31 +1,53 @@ -/** - * TeamLogo - * - * Pure UI component for displaying team logos. - * Renders an optimized image with fallback on error. - */ - import React from 'react'; +import { Box } from './Box'; import { Image } from './Image'; +import { Users } from 'lucide-react'; +import { Icon } from './Icon'; export interface TeamLogoProps { - teamId: string; + teamId?: string; + src?: string; alt: string; + size?: number; className?: string; + border?: boolean; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'; } -export function TeamLogo({ teamId, alt, className = '' }: TeamLogoProps) { +export function TeamLogo({ + teamId, + src, + alt, + size = 48, + className = '', + border = true, + rounded = 'md', +}: TeamLogoProps) { + const logoSrc = src || (teamId ? `/media/teams/${teamId}/logo` : undefined); + return ( - { - // Fallback to default logo - (e.target as HTMLImageElement).src = '/default-team-logo.png'; - }} - /> + + {logoSrc ? ( + + ) : ( + 32 ? 5 : 4} color="text-gray-500" /> + )} + ); } diff --git a/apps/website/ui/TelemetryStrip.tsx b/apps/website/ui/TelemetryStrip.tsx new file mode 100644 index 000000000..9ee1ff738 --- /dev/null +++ b/apps/website/ui/TelemetryStrip.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import { Box } from './Box'; +import { Text } from './Text'; + +interface TelemetryItem { + label: string; + value: string | number; + trend?: 'up' | 'down' | 'neutral'; + color?: string; +} + +interface TelemetryStripProps { + items: TelemetryItem[]; + className?: string; +} + +/** + * TelemetryStrip + * + * A thin, dense strip showing key telemetry or performance metrics. + * Follows the "Precision Racing Minimal" theme. + */ +export function TelemetryStrip({ items, className = '' }: TelemetryStripProps) { + return ( + + {items.map((item, index) => ( + + + {item.label} + + + {item.value} + + {item.trend && ( + + {item.trend === 'up' ? '↑' : item.trend === 'down' ? '↓' : '•'} + + )} + + ))} + + ); +} diff --git a/apps/website/ui/Text.tsx b/apps/website/ui/Text.tsx index 9e8db4cc1..7af14e0c7 100644 --- a/apps/website/ui/Text.tsx +++ b/apps/website/ui/Text.tsx @@ -36,7 +36,8 @@ interface TextProps extends Omit, 'c align?: TextAlign | ResponsiveTextAlign; truncate?: boolean; uppercase?: boolean; - letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em'; + capitalize?: boolean; + letterSpacing?: 'tighter' | 'tight' | 'normal' | 'wide' | 'wider' | 'widest' | '0.05em' | string; leading?: 'none' | 'tight' | 'snug' | 'normal' | 'relaxed' | 'loose'; fontSize?: string; style?: React.CSSProperties; @@ -68,6 +69,7 @@ export function Text({ align = 'left', truncate = false, uppercase = false, + capitalize = false, letterSpacing, leading, fontSize, @@ -180,6 +182,7 @@ export function Text({ color, truncate ? 'truncate' : '', uppercase ? 'uppercase' : '', + capitalize ? 'capitalize' : '', italic ? 'italic' : '', letterSpacing === '0.05em' ? 'tracking-wider' : letterSpacing ? `tracking-${letterSpacing}` : '', getSpacingClass('ml', ml), diff --git a/apps/website/ui/TopNav.tsx b/apps/website/ui/TopNav.tsx new file mode 100644 index 000000000..538bba4d5 --- /dev/null +++ b/apps/website/ui/TopNav.tsx @@ -0,0 +1,17 @@ +import React from 'react'; + +interface TopNavProps { + children: React.ReactNode; + className?: string; +} + +/** + * TopNav is a horizontal navigation container used within the AppHeader. + */ +export function TopNav({ children, className = '' }: TopNavProps) { + return ( + + {children} + + ); +} diff --git a/apps/website/ui/TrackImage.tsx b/apps/website/ui/TrackImage.tsx index 2793f80e0..743ec45b9 100644 --- a/apps/website/ui/TrackImage.tsx +++ b/apps/website/ui/TrackImage.tsx @@ -1,31 +1,46 @@ -/** - * TrackImage - * - * Pure UI component for displaying track images. - * Renders an optimized image with fallback on error. - */ - import React from 'react'; +import { Box } from './Box'; import { Image } from './Image'; +import { ImagePlaceholder } from './ImagePlaceholder'; export interface TrackImageProps { - trackId: string; + trackId?: string; + src?: string; alt: string; + aspectRatio?: string; className?: string; + rounded?: 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'; } -export function TrackImage({ trackId, alt, className = '' }: TrackImageProps) { +export function TrackImage({ + trackId, + src, + alt, + aspectRatio = '16/9', + className = '', + rounded = 'lg', +}: TrackImageProps) { + const imageSrc = src || (trackId ? `/media/tracks/${trackId}/image` : undefined); + return ( - { - // Fallback to default track image - (e.target as HTMLImageElement).src = '/default-track-image.png'; - }} - /> + + {imageSrc ? ( + + ) : ( + + )} + ); } diff --git a/apps/website/ui/state-types.ts b/apps/website/ui/state-types.ts index 7c5885069..a6aeef0da 100644 --- a/apps/website/ui/state-types.ts +++ b/apps/website/ui/state-types.ts @@ -6,7 +6,7 @@ import { LucideIcon } from 'lucide-react'; export interface EmptyStateAction { label: string; onClick: () => void; - variant?: 'primary' | 'secondary' | 'danger' | 'race-performance' | 'race-final'; + variant?: 'primary' | 'secondary' | 'danger' | 'ghost' | 'race-final' | 'discord'; icon?: LucideIcon; }
Loading sponsorships...
{error?.getUserMessage() || 'No sponsorships data available'}