diff --git a/apps/website/.eslintrc.json b/apps/website/.eslintrc.json index 48240eed1..85246ee8c 100644 --- a/apps/website/.eslintrc.json +++ b/apps/website/.eslintrc.json @@ -249,7 +249,8 @@ "rules": { "gridpilot-rules/no-raw-html-in-app": "error", "gridpilot-rules/no-nextjs-imports-in-ui": "error", - "gridpilot-rules/no-hardcoded-routes": "error" + "gridpilot-rules/no-hardcoded-routes": "error", + "gridpilot-rules/component-classification": "error" } }, { @@ -271,7 +272,7 @@ "rules": { "gridpilot-rules/ui-element-purity": "error", "gridpilot-rules/no-nextjs-imports-in-ui": "error", - "gridpilot-rules/component-classification": "warn" + "gridpilot-rules/component-classification": "error" } }, { @@ -281,7 +282,7 @@ ], "rules": { "gridpilot-rules/no-nextjs-imports-in-ui": "error", - "gridpilot-rules/component-classification": "warn", + "gridpilot-rules/component-classification": "error", "gridpilot-rules/no-hardcoded-routes": "error", "gridpilot-rules/no-raw-html-in-app": "error" } diff --git a/apps/website/app/admin/layout.tsx b/apps/website/app/admin/layout.tsx index 7161dbc3b..3e6c395b0 100644 --- a/apps/website/app/admin/layout.tsx +++ b/apps/website/app/admin/layout.tsx @@ -1,7 +1,7 @@ import { headers } from 'next/headers'; import { redirect } from 'next/navigation'; import { createRouteGuard } from '@/lib/auth/createRouteGuard'; -import Section from '@/components/ui/Section'; +import Section from '@/ui/Section'; interface AdminLayoutProps { children: React.ReactNode; diff --git a/apps/website/app/admin/page.tsx b/apps/website/app/admin/page.tsx index 7a301fb0f..f567b064d 100644 --- a/apps/website/app/admin/page.tsx +++ b/apps/website/app/admin/page.tsx @@ -1,6 +1,6 @@ import { AdminDashboardPageQuery } from '@/lib/page-queries/AdminDashboardPageQuery'; import { AdminDashboardWrapper } from '@/components/admin/AdminDashboardWrapper'; -import { ErrorBanner } from '@/components/ui/ErrorBanner'; +import { ErrorBanner } from '@/ui/ErrorBanner'; export default async function AdminPage() { const result = await AdminDashboardPageQuery.execute(); diff --git a/apps/website/app/admin/users/page.tsx b/apps/website/app/admin/users/page.tsx index 4f99bf708..7acea9895 100644 --- a/apps/website/app/admin/users/page.tsx +++ b/apps/website/app/admin/users/page.tsx @@ -1,6 +1,6 @@ import { AdminUsersPageQuery } from '@/lib/page-queries/AdminUsersPageQuery'; import { AdminUsersWrapper } from '@/components/admin/AdminUsersWrapper'; -import { ErrorBanner } from '@/components/ui/ErrorBanner'; +import { ErrorBanner } from '@/ui/ErrorBanner'; export default async function AdminUsersPage() { // Execute PageQuery using static method diff --git a/apps/website/app/auth/forgot-password/page.tsx b/apps/website/app/auth/forgot-password/page.tsx index a05915805..8aff8cde0 100644 --- a/apps/website/app/auth/forgot-password/page.tsx +++ b/apps/website/app/auth/forgot-password/page.tsx @@ -8,7 +8,7 @@ import { ForgotPasswordPageQuery } from '@/lib/page-queries/auth/ForgotPasswordPageQuery'; import { ForgotPasswordClient } from './ForgotPasswordClient'; -import { AuthError } from '@/components/ui/AuthError'; +import { AuthError } from '@/ui/AuthError'; export default async function ForgotPasswordPage({ searchParams, diff --git a/apps/website/app/auth/layout.tsx b/apps/website/app/auth/layout.tsx index 1955e0a85..f23a4661f 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 '@/components/ui/AuthContainer'; +import { AuthContainer } from '@/ui/AuthContainer'; interface AuthLayoutProps { children: React.ReactNode; diff --git a/apps/website/app/auth/login/LoginClient.tsx b/apps/website/app/auth/login/LoginClient.tsx index 80cc0c565..1ba313f54 100644 --- a/apps/website/app/auth/login/LoginClient.tsx +++ b/apps/website/app/auth/login/LoginClient.tsx @@ -17,7 +17,7 @@ import { LoginMutation } from '@/lib/mutations/auth/LoginMutation'; import { validateLoginForm, type LoginFormValues } from '@/lib/utils/validation'; import { LoginViewModelBuilder } from '@/lib/builders/view-models/LoginViewModelBuilder'; import { LoginViewModel } from '@/lib/view-models/auth/LoginViewModel'; -import { AuthLoading } from '@/components/ui/AuthLoading'; +import { AuthLoading } from '@/ui/AuthLoading'; interface LoginClientProps { viewData: LoginViewData; diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index 6c02d50de..2a1badc18 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -8,7 +8,7 @@ import { LoginPageQuery } from '@/lib/page-queries/auth/LoginPageQuery'; import { LoginClient } from './LoginClient'; -import { AuthError } from '@/components/ui/AuthError'; +import { AuthError } from '@/ui/AuthError'; export default async function LoginPage({ searchParams, diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx index f06cf61f0..6892d8c75 100644 --- a/apps/website/app/auth/reset-password/page.tsx +++ b/apps/website/app/auth/reset-password/page.tsx @@ -8,7 +8,7 @@ import { ResetPasswordPageQuery } from '@/lib/page-queries/auth/ResetPasswordPageQuery'; import { ResetPasswordClient } from './ResetPasswordClient'; -import { AuthError } from '@/components/ui/AuthError'; +import { AuthError } from '@/ui/AuthError'; export default async function ResetPasswordPage({ searchParams, diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index 76fc9a93e..5645fe3a3 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -8,7 +8,7 @@ import { SignupPageQuery } from '@/lib/page-queries/auth/SignupPageQuery'; import { SignupClient } from './SignupClient'; -import { AuthError } from '@/components/ui/AuthError'; +import { AuthError } from '@/ui/AuthError'; export default async function SignupPage({ searchParams, diff --git a/apps/website/app/drivers/DriversPageClient.tsx b/apps/website/app/drivers/DriversPageClient.tsx new file mode 100644 index 000000000..9954132bf --- /dev/null +++ b/apps/website/app/drivers/DriversPageClient.tsx @@ -0,0 +1,36 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { DriversTemplate } from '@/templates/DriversTemplate'; +import { useDriverSearch } from '@/lib/hooks/useDriverSearch'; +import type { DriverLeaderboardViewModel } from '@/lib/view-data/DriverLeaderboardViewModel'; + +interface DriversPageClientProps { + data: DriverLeaderboardViewModel | null; +} + +export function DriversPageClient({ data }: DriversPageClientProps) { + const router = useRouter(); + const drivers = data?.drivers || []; + const { searchQuery, setSearchQuery, filteredDrivers } = useDriverSearch(drivers); + + const handleDriverClick = (driverId: string) => { + router.push(`/drivers/${driverId}`); + }; + + const handleViewLeaderboard = () => { + router.push('/leaderboards/drivers'); + }; + + return ( + + ); +} diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 13ff5879a..a22de4009 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -4,7 +4,7 @@ import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; import { Metadata, Viewport } from 'next'; import React from 'react'; import './globals.css'; -import { AppWrapper } from '@/ui/AppWrapper'; +import { AppWrapper } from '@/components/AppWrapper'; import { Header } from '@/ui/Header'; import { HeaderContent } from '@/ui/HeaderContent'; import { MainContent } from '@/ui/MainContent'; diff --git a/apps/website/app/leaderboards/LeaderboardsPageClient.tsx b/apps/website/app/leaderboards/LeaderboardsPageClient.tsx new file mode 100644 index 000000000..e23f74e16 --- /dev/null +++ b/apps/website/app/leaderboards/LeaderboardsPageClient.tsx @@ -0,0 +1,41 @@ +'use client'; + +import React from 'react'; +import { useRouter } from 'next/navigation'; +import { LeaderboardsTemplate } from '@/templates/LeaderboardsTemplate'; +import type { LeaderboardsViewData } from '@/lib/view-data/LeaderboardsViewData'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface LeaderboardsPageClientProps { + viewData: LeaderboardsViewData; +} + +export function LeaderboardsPageClient({ viewData }: LeaderboardsPageClientProps) { + const router = useRouter(); + + const handleDriverClick = (driverId: string) => { + router.push(routes.driver.detail(driverId)); + }; + + const handleTeamClick = (teamId: string) => { + router.push(routes.team.detail(teamId)); + }; + + const handleNavigateToDrivers = () => { + router.push(routes.leaderboards.drivers); + }; + + const handleNavigateToTeams = () => { + router.push(routes.team.leaderboard); + }; + + return ( + + ); +} diff --git a/apps/website/app/leagues/[id]/layout.tsx b/apps/website/app/leagues/[id]/layout.tsx index 88bf19a5e..a7d00012d 100644 --- a/apps/website/app/leagues/[id]/layout.tsx +++ b/apps/website/app/leagues/[id]/layout.tsx @@ -23,18 +23,26 @@ export default async function LeagueLayout({ // Return error state return ( - Failed to load league + Failed to load league ); } - const data = result.unwrap(); - const league = data.league; + const viewData = result.unwrap(); // Define tab configuration const baseTabs = [ @@ -58,9 +66,7 @@ export default async function LeagueLayout({ return ( {children} diff --git a/apps/website/app/leagues/[id]/page.tsx b/apps/website/app/leagues/[id]/page.tsx index 424b363d7..24916e38b 100644 --- a/apps/website/app/leagues/[id]/page.tsx +++ b/apps/website/app/leagues/[id]/page.tsx @@ -2,7 +2,7 @@ import { notFound } from 'next/navigation'; import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; import { LeagueDetailPageQuery } from '@/lib/page-queries/page-queries/LeagueDetailPageQuery'; import { LeagueDetailViewDataBuilder } from '@/lib/builders/view-data/LeagueDetailViewDataBuilder'; -import { ErrorBanner } from '@/components/ui/ErrorBanner'; +import { ErrorBanner } from '@/ui/ErrorBanner'; interface Props { params: { id: string }; @@ -49,5 +49,5 @@ export default async function Page({ params }: Props) { sponsors: [], }); - return ; + return ; } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/rulebook/LeagueRulebookPageClient.tsx b/apps/website/app/leagues/[id]/rulebook/LeagueRulebookPageClient.tsx new file mode 100644 index 000000000..de1d32cd3 --- /dev/null +++ b/apps/website/app/leagues/[id]/rulebook/LeagueRulebookPageClient.tsx @@ -0,0 +1,22 @@ +'use client'; + +import React, { useState } from 'react'; +import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate'; +import { type RulebookSection } from '@/components/leagues/RulebookTabs'; +import type { LeagueRulebookViewData } from '@/lib/view-data/LeagueRulebookViewData'; + +interface LeagueRulebookPageClientProps { + viewData: LeagueRulebookViewData; +} + +export function LeagueRulebookPageClient({ viewData }: LeagueRulebookPageClientProps) { + const [activeSection, setActiveSection] = useState('scoring'); + + return ( + + ); +} diff --git a/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx b/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx index a9ead2e83..cacfac8ba 100644 --- a/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx +++ b/apps/website/app/leagues/[id]/stewarding/StewardingTemplate.tsx @@ -4,8 +4,8 @@ import PenaltyFAB from '@/components/leagues/PenaltyFAB'; import QuickPenaltyModal from '@/components/leagues/QuickPenaltyModal'; import { ReviewProtestModal } from '@/components/leagues/ReviewProtestModal'; import StewardingStats from '@/components/leagues/StewardingStats'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; import { useLeagueStewardingMutations } from "@/lib/hooks/league/useLeagueStewardingMutations"; import { AlertCircle, diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx index 5f1e0c754..df95b00df 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -1,7 +1,7 @@ 'use client'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; import { useEffectiveDriverId } from "@/lib/hooks/useEffectiveDriverId"; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { useInject } from '@/lib/di/hooks/useInject'; diff --git a/apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx b/apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx index 12a1f87d9..e2e579ecf 100644 --- a/apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx +++ b/apps/website/app/leagues/[id]/wallet/WalletTemplate.tsx @@ -1,8 +1,8 @@ 'use client'; import React, { useState } from 'react'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; import TransactionRow from '@/components/leagues/TransactionRow'; import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; import { diff --git a/apps/website/app/leagues/create/page.tsx b/apps/website/app/leagues/create/page.tsx index f6cc54f80..1664e416e 100644 --- a/apps/website/app/leagues/create/page.tsx +++ b/apps/website/app/leagues/create/page.tsx @@ -3,8 +3,8 @@ import React from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import CreateLeagueWizard from '@/components/leagues/CreateLeagueWizard'; -import Section from '@/components/ui/Section'; -import Container from '@/components/ui/Container'; +import Section from '@/ui/Section'; +import Container from '@/ui/Container'; type StepName = 'basics' | 'visibility' | 'structure' | 'schedule' | 'scoring' | 'stewarding' | 'review'; diff --git a/apps/website/app/profile/ProfilePageClient.tsx b/apps/website/app/profile/ProfilePageClient.tsx index 034b3df5e..600d789f6 100644 --- a/apps/website/app/profile/ProfilePageClient.tsx +++ b/apps/website/app/profile/ProfilePageClient.tsx @@ -1,20 +1,10 @@ 'use client'; -import { useState } from 'react'; -import Link from 'next/link'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; -import Input from '@/components/ui/Input'; -import TabNavigation from '@/components/ui/TabNavigation'; -import { routes } from '@/lib/routing/RouteConfig'; -import type { Result } from '@/lib/contracts/Result'; +import { useEffect, useState } from 'react'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { ProfileTemplate, type ProfileTab } from '@/templates/ProfileTemplate'; import type { ProfileViewData } from '@/lib/view-data/ProfileViewData'; - -type ProfileTab = 'overview' | 'history' | 'stats'; - -type SaveError = string | null; +import type { Result } from '@/lib/contracts/Result'; interface ProfilePageClientProps { viewData: ProfileViewData; @@ -23,112 +13,55 @@ interface ProfilePageClientProps { } export function ProfilePageClient({ viewData, mode, onSaveSettings }: ProfilePageClientProps) { - const [activeTab, setActiveTab] = useState('overview'); + 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 [bio, setBio] = useState(viewData.driver.bio ?? ''); - const [countryCode, setCountryCode] = useState(viewData.driver.countryCode ?? ''); - const [saveError, setSaveError] = useState(null); + const [friendRequestSent, setFriendRequestSent] = useState(false); - if (mode === 'needs-profile') { - return ( - - Create your driver profile - -

Driver profile not found for this account.

- - - -
-
- ); - } + useEffect(() => { + const params = new URLSearchParams(searchParams.toString()); + if (activeTab === 'overview') { + params.delete('tab'); + } else { + params.set('tab', activeTab); + } + const query = params.toString(); + const currentQuery = searchParams.toString(); + + if (query !== currentQuery) { + router.replace(`/profile${query ? `?${query}` : ''}`, { scroll: false }); + } + }, [activeTab, searchParams, router]); - if (editMode) { - return ( - - Edit profile - - - Profile - - setBio(e.target.value)} - placeholder="Bio" - /> - - setCountryCode(e.target.value)} - placeholder="Country code (e.g. DE)" - /> - - {saveError ?

{saveError}

: null} - - - - -
-
- ); - } + useEffect(() => { + const tab = searchParams.get('tab') as ProfileTab | null; + if (tab && tab !== activeTab) { + setActiveTab(tab); + } + }, [searchParams, activeTab]); return ( - - {viewData.driver.name || 'Profile'} - - - - setActiveTab(tabId as ProfileTab)} - /> - - {activeTab === 'overview' ? ( - - Driver -

{viewData.driver.countryCode}

-

{viewData.driver.joinedAtLabel}

-

{viewData.driver.bio ?? ''}

-
- ) : null} - - {activeTab === 'history' ? ( - - Race history -

Race history is currently unavailable in this view.

-
- ) : null} - - {activeTab === 'stats' ? ( - - Stats -

{viewData.stats?.ratingLabel ?? ''}

-

{viewData.stats?.globalRankLabel ?? ''}

-
- ) : null} -
+ 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/liveries/page.tsx b/apps/website/app/profile/liveries/page.tsx index 86ad42383..3e42e710e 100644 --- a/apps/website/app/profile/liveries/page.tsx +++ b/apps/website/app/profile/liveries/page.tsx @@ -1,8 +1,8 @@ import Link from 'next/link'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; import { routes } from '@/lib/routing/RouteConfig'; export default async function ProfileLiveriesPage() { diff --git a/apps/website/app/profile/liveries/upload/page.tsx b/apps/website/app/profile/liveries/upload/page.tsx index 09083ee7d..d8ae80991 100644 --- a/apps/website/app/profile/liveries/upload/page.tsx +++ b/apps/website/app/profile/liveries/upload/page.tsx @@ -1,8 +1,8 @@ import Link from 'next/link'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; import { routes } from '@/lib/routing/RouteConfig'; export default async function ProfileLiveryUploadPage() { diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index e0c8744f1..d6cb49a9c 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -4,7 +4,8 @@ import { updateProfileAction } from './actions'; import { ProfilePageClient } from './ProfilePageClient'; export default async function ProfilePage() { - const result = await ProfilePageQuery.execute(); + const query = new ProfilePageQuery(); + const result = await query.execute(); if (result.isErr()) { notFound(); @@ -13,5 +14,11 @@ export default async function ProfilePage() { const viewData = result.unwrap(); const mode = viewData.driver.id ? 'profile-exists' : 'needs-profile'; - return ; + return ( + + ); } diff --git a/apps/website/app/profile/settings/page.tsx b/apps/website/app/profile/settings/page.tsx index 9e673acfc..7880b4c86 100644 --- a/apps/website/app/profile/settings/page.tsx +++ b/apps/website/app/profile/settings/page.tsx @@ -1,8 +1,8 @@ import Link from 'next/link'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; import { routes } from '@/lib/routing/RouteConfig'; export default async function ProfileSettingsPage() { diff --git a/apps/website/app/races/RacesPageClient.tsx b/apps/website/app/races/RacesPageClient.tsx new file mode 100644 index 000000000..68a7c1359 --- /dev/null +++ b/apps/website/app/races/RacesPageClient.tsx @@ -0,0 +1,65 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import { RacesTemplate, type TimeFilter, type RaceStatusFilter } from '@/templates/RacesTemplate'; +import type { RacesViewData } from '@/lib/view-data/RacesViewData'; + +interface RacesPageClientProps { + viewData: RacesViewData; +} + +export function RacesPageClient({ viewData }: RacesPageClientProps) { + const [statusFilter, setStatusFilter] = useState('all'); + const [leagueFilter, setLeagueFilter] = useState('all'); + const [timeFilter, setTimeFilter] = useState('upcoming'); + const [showFilterModal, setShowFilterModal] = useState(false); + + const filteredRaces = useMemo(() => { + return viewData.races.filter((race) => { + if (statusFilter !== 'all' && race.status !== statusFilter) return false; + if (leagueFilter !== 'all' && race.leagueId !== leagueFilter) return false; + if (timeFilter === 'upcoming' && !race.isUpcoming) return false; + if (timeFilter === 'live' && !race.isLive) return false; + if (timeFilter === 'past' && !race.isPast) return false; + return true; + }); + }, [viewData.races, statusFilter, leagueFilter, timeFilter]); + + const racesByDate = useMemo(() => { + const grouped = new Map(); + filteredRaces.forEach((race) => { + const dateKey = race.scheduledAt.split('T')[0]!; + if (!grouped.has(dateKey)) { + grouped.set(dateKey, []); + } + grouped.get(dateKey)!.push(race); + }); + return Array.from(grouped.entries()).map(([dateKey, dayRaces]) => ({ + dateKey, + dateLabel: dayRaces[0]?.scheduledAtLabel || '', + races: dayRaces, + })); + }, [filteredRaces]); + + return ( + console.log('Race click', id)} + onLeagueClick={(id) => console.log('League click', id)} + onWithdraw={(id) => console.log('Withdraw', id)} + onCancel={(id) => console.log('Cancel', id)} + /> + ); +} diff --git a/apps/website/app/races/[id]/RaceDetailPageClient.tsx b/apps/website/app/races/[id]/RaceDetailPageClient.tsx new file mode 100644 index 000000000..e8774c486 --- /dev/null +++ b/apps/website/app/races/[id]/RaceDetailPageClient.tsx @@ -0,0 +1,85 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { RaceDetailTemplate, type RaceDetailViewData } from '@/templates/RaceDetailTemplate'; + +interface RaceDetailPageClientProps { + viewData: RaceDetailViewData; + onBack: () => void; + onRegister: () => void; + onWithdraw: () => void; + onCancel: () => void; + onReopen: () => void; + onEndRace: () => void; + onFileProtest: () => void; + onResultsClick: () => void; + onStewardingClick: () => void; + onLeagueClick: (id: string) => void; + onDriverClick: (id: string) => void; + isOwnerOrAdmin: boolean; +} + +export function RaceDetailPageClient({ + viewData, + onBack, + onRegister, + onWithdraw, + onCancel, + onReopen, + onEndRace, + onFileProtest, + onResultsClick, + onStewardingClick, + onLeagueClick, + onDriverClick, + isOwnerOrAdmin +}: RaceDetailPageClientProps) { + const [showProtestModal, setShowProtestModal] = useState(false); + const [showEndRaceModal, setShowEndRaceModal] = useState(false); + const [animatedRatingChange, setAnimatedRatingChange] = useState(0); + + const ratingChange = viewData.userResult?.ratingChange ?? null; + + useEffect(() => { + if (ratingChange !== null) { + let start = 0; + const end = ratingChange; + const duration = 1000; + const startTime = performance.now(); + + const animate = (currentTime: number) => { + const elapsed = currentTime - startTime; + const progress = Math.min(elapsed / duration, 1); + const eased = 1 - Math.pow(1 - progress, 3); + const current = Math.round(start + (end - start) * eased); + setAnimatedRatingChange(current); + + if (progress < 1) { + requestAnimationFrame(animate); + } + }; + + requestAnimationFrame(animate); + } + }, [ratingChange]); + + return ( + + ); +} diff --git a/apps/website/app/races/[id]/page.tsx b/apps/website/app/races/[id]/page.tsx index ad5b5e3e9..d645483a8 100644 --- a/apps/website/app/races/[id]/page.tsx +++ b/apps/website/app/races/[id]/page.tsx @@ -34,7 +34,7 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { data={null} Template={({ data: _data }) => ( {}} @@ -48,12 +48,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { onStewardingClick={() => {}} onLeagueClick={() => {}} onDriverClick={() => {}} - currentDriverId={''} isOwnerOrAdmin={false} - showProtestModal={false} - setShowProtestModal={() => {}} - showEndRaceModal={false} - setShowEndRaceModal={() => {}} + animatedRatingChange={0} mutationLoading={{ register: false, withdraw: false, @@ -78,23 +74,12 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { const viewData = result.unwrap(); - // Convert ViewData to ViewModel for the template - // The template expects a ViewModel, so we need to adapt - const viewModel = { - race: viewData.race, - league: viewData.league, - entryList: viewData.entryList, - registration: viewData.registration, - userResult: viewData.userResult, - canReopenRace: viewData.canReopenRace, - }; - return ( ( {}} @@ -108,12 +93,8 @@ export default async function RaceDetailPage({ params }: RaceDetailPageProps) { onStewardingClick={() => {}} onLeagueClick={() => {}} onDriverClick={() => {}} - currentDriverId={''} isOwnerOrAdmin={false} - showProtestModal={false} - setShowProtestModal={() => {}} - showEndRaceModal={false} - setShowEndRaceModal={() => {}} + animatedRatingChange={0} mutationLoading={{ register: false, withdraw: false, diff --git a/apps/website/app/races/page.tsx b/apps/website/app/races/page.tsx index e8ca8fa0a..ddd541723 100644 --- a/apps/website/app/races/page.tsx +++ b/apps/website/app/races/page.tsx @@ -1,9 +1,10 @@ import { notFound } from 'next/navigation'; -import { RacesTemplate } from '@/templates/RacesTemplate'; import { RacesPageQuery } from '@/lib/page-queries/races/RacesPageQuery'; +import { RacesPageClient } from './RacesPageClient'; export default async function Page() { - const result = await RacesPageQuery.execute(); + const query = new RacesPageQuery(); + const result = await query.execute(); if (result.isErr()) { const error = result.getError(); @@ -12,59 +13,13 @@ export default async function Page() { case 'notFound': notFound(); case 'redirect': - // Would redirect to login or other page notFound(); default: - // For other errors, show error state in template - return {}} - leagueFilter="all" - setLeagueFilter={() => {}} - timeFilter="upcoming" - setTimeFilter={() => {}} - onRaceClick={() => {}} - onLeagueClick={() => {}} - onRegister={() => {}} - onWithdraw={() => {}} - onCancel={() => {}} - showFilterModal={false} - setShowFilterModal={() => {}} - currentDriverId={undefined} - userMemberships={[]} - />; + notFound(); } } const viewData = result.unwrap(); - return {}} - leagueFilter="all" - setLeagueFilter={() => {}} - timeFilter="upcoming" - setTimeFilter={() => {}} - onRaceClick={() => {}} - onLeagueClick={() => {}} - onRegister={() => {}} - onWithdraw={() => {}} - onCancel={() => {}} - showFilterModal={false} - setShowFilterModal={() => {}} - currentDriverId={undefined} - userMemberships={[]} - />; -} \ No newline at end of file + return ; +} diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx index 45011f82a..65e17bf3c 100644 --- a/apps/website/app/sponsor/billing/page.tsx +++ b/apps/website/app/sponsor/billing/page.tsx @@ -2,13 +2,13 @@ import { useState } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import StatCard from '@/components/ui/StatCard'; -import SectionHeader from '@/components/ui/SectionHeader'; -import StatusBadge from '@/components/ui/StatusBadge'; -import InfoBanner from '@/components/ui/InfoBanner'; -import PageHeader from '@/components/ui/PageHeader'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; +import StatCard from '@/ui/StatCard'; +import SectionHeader from '@/ui/SectionHeader'; +import StatusBadge from '@/ui/StatusBadge'; +import InfoBanner from '@/ui/InfoBanner'; +import PageHeader from '@/ui/PageHeader'; import { siteConfig } from '@/lib/siteConfig'; import { useSponsorBilling } from "@/lib/hooks/sponsor/useSponsorBilling"; import { diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index 978cef648..9e616ebf2 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -4,9 +4,9 @@ import { useState } from 'react'; import { useSearchParams } from 'next/navigation'; import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import InfoBanner from '@/components/ui/InfoBanner'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; +import InfoBanner from '@/ui/InfoBanner'; import { useSponsorSponsorships } from "@/lib/hooks/sponsor/useSponsorSponsorships"; import { Megaphone, diff --git a/apps/website/app/sponsor/settings/page.tsx b/apps/website/app/sponsor/settings/page.tsx index e275f4b37..06a4ca248 100644 --- a/apps/website/app/sponsor/settings/page.tsx +++ b/apps/website/app/sponsor/settings/page.tsx @@ -2,13 +2,13 @@ import { useState } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; -import Toggle from '@/components/ui/Toggle'; -import SectionHeader from '@/components/ui/SectionHeader'; -import FormField from '@/components/ui/FormField'; -import PageHeader from '@/components/ui/PageHeader'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; +import Input from '@/ui/Input'; +import Toggle from '@/ui/Toggle'; +import SectionHeader from '@/ui/SectionHeader'; +import FormField from '@/ui/FormField'; +import PageHeader from '@/ui/PageHeader'; import { Settings, Building2, diff --git a/apps/website/app/sponsor/signup/page.tsx b/apps/website/app/sponsor/signup/page.tsx index 8da03dceb..23cfc3b83 100644 --- a/apps/website/app/sponsor/signup/page.tsx +++ b/apps/website/app/sponsor/signup/page.tsx @@ -2,9 +2,9 @@ import { useState } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; +import Input from '@/ui/Input'; import SponsorHero from '@/components/sponsors/SponsorHero'; import SponsorWorkflowMockup from '@/components/sponsors/SponsorWorkflowMockup'; import SponsorBenefitCard from '@/components/sponsors/SponsorBenefitCard'; diff --git a/apps/website/app/teams/TeamsPageClient.tsx b/apps/website/app/teams/TeamsPageClient.tsx index 3ba0b08f6..dddab8570 100644 --- a/apps/website/app/teams/TeamsPageClient.tsx +++ b/apps/website/app/teams/TeamsPageClient.tsx @@ -1,77 +1,36 @@ 'use client'; -import type { TeamsViewData } from '@/lib/view-data/TeamsViewData'; -import { TeamsTemplate } from '@/templates/TeamsTemplate'; +import React from 'react'; import { useRouter } from 'next/navigation'; -import { useState } from 'react'; +import { TeamsTemplate } from '@/templates/TeamsTemplate'; +import type { TeamsViewData } from '@/lib/view-data/TeamsViewData'; +import { routes } from '@/lib/routing/RouteConfig'; -interface TeamsPageClientProps extends TeamsViewData { - searchQuery?: string; - showCreateForm?: boolean; - onSearchChange?: (query: string) => void; - onShowCreateForm?: () => void; - onHideCreateForm?: () => void; - onTeamClick?: (teamId: string) => void; - onCreateSuccess?: (teamId: string) => void; - onBrowseTeams?: () => void; - onSkillLevelClick?: (level: string) => void; +interface TeamsPageClientProps { + viewData: TeamsViewData; } -export function TeamsPageClient({ teams }: TeamsPageClientProps) { +export function TeamsPageClient({ viewData }: TeamsPageClientProps) { const router = useRouter(); - // UI state only (no business logic) - const [searchQuery, setSearchQuery] = useState(''); - const [showCreateForm, setShowCreateForm] = useState(false); - - // Event handlers - const handleSearchChange = (query: string) => { - setSearchQuery(query); - }; - - const handleShowCreateForm = () => { - setShowCreateForm(true); - }; - - const handleHideCreateForm = () => { - setShowCreateForm(false); - }; - const handleTeamClick = (teamId: string) => { router.push(`/teams/${teamId}`); }; - const handleCreateSuccess = (teamId: string) => { - setShowCreateForm(false); - router.push(`/teams/${teamId}`); + const handleViewFullLeaderboard = () => { + router.push(routes.team.leaderboard); }; - - const handleBrowseTeams = () => { - const element = document.getElementById('teams-list'); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } - }; - - const handleSkillLevelClick = (level: string) => { - const element = document.getElementById(`level-${level}`); - if (element) { - element.scrollIntoView({ behavior: 'smooth' }); - } + + const handleCreateTeam = () => { + router.push(routes.team.detail('create')); }; return ( ); } diff --git a/apps/website/app/teams/page.tsx b/apps/website/app/teams/page.tsx index bb307d654..494997ddc 100644 --- a/apps/website/app/teams/page.tsx +++ b/apps/website/app/teams/page.tsx @@ -1,24 +1,15 @@ import { notFound } from 'next/navigation'; import { TeamsPageQuery } from '@/lib/page-queries/page-queries/TeamsPageQuery'; -import { TeamsViewDataBuilder } from '@/lib/builders/view-data/TeamsViewDataBuilder'; import { TeamsPageClient } from './TeamsPageClient'; export default async function Page() { - const result = await TeamsPageQuery.execute(); + const query = new TeamsPageQuery(); + const result = await query.execute(); - switch (result.status) { - case 'ok': - const viewData = TeamsViewDataBuilder.build(result.dto); - return ; - case 'notFound': - notFound(); - case 'redirect': - // This would typically use redirect() from next/navigation - // but we need to handle it at the page level - return null; - case 'error': - // For now, treat errors as not found - // In production, you might want a proper error page - notFound(); + if (result.isErr()) { + notFound(); } -} \ No newline at end of file + + const viewData = result.unwrap(); + return ; +} diff --git a/apps/website/ui/AppWrapper.tsx b/apps/website/components/AppWrapper.tsx similarity index 100% rename from apps/website/ui/AppWrapper.tsx rename to apps/website/components/AppWrapper.tsx diff --git a/apps/website/components/TeamRankingsFilter.tsx b/apps/website/components/TeamRankingsFilter.tsx index 350236d94..72bc6f5e5 100644 --- a/apps/website/components/TeamRankingsFilter.tsx +++ b/apps/website/components/TeamRankingsFilter.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Search, Star, Trophy, Percent, Hash } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Input from '@/components/ui/Input'; +import Button from '@/ui/Button'; +import Input from '@/ui/Input'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; type SortBy = 'rating' | 'wins' | 'winRate' | 'races'; diff --git a/apps/website/components/admin/AdminDashboardWrapper.tsx b/apps/website/components/admin/AdminDashboardWrapper.tsx index 9b9e01a22..914507241 100644 --- a/apps/website/components/admin/AdminDashboardWrapper.tsx +++ b/apps/website/components/admin/AdminDashboardWrapper.tsx @@ -24,7 +24,7 @@ export function AdminDashboardWrapper({ initialViewData }: AdminDashboardWrapper return ( diff --git a/apps/website/components/admin/AdminUsersWrapper.tsx b/apps/website/components/admin/AdminUsersWrapper.tsx index 77d4f22b2..44cfe3489 100644 --- a/apps/website/components/admin/AdminUsersWrapper.tsx +++ b/apps/website/components/admin/AdminUsersWrapper.tsx @@ -103,7 +103,7 @@ export function AdminUsersWrapper({ initialViewData }: AdminUsersWrapperProps) { return ( void; + onFilterRole: (role: string) => void; + onFilterStatus: (status: string) => void; + onClearFilters: () => void; +} + +export function UserFilters({ + search, + roleFilter, + statusFilter, + onSearch, + onFilterRole, + onFilterStatus, + onClearFilters, +}: UserFiltersProps) { + return ( + + + + + + Filters + + {(search || roleFilter || statusFilter) && ( + + )} + + + + + + + + onSearch(e.target.value)} + style={{ paddingLeft: '2.25rem' }} + /> + + + 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/UserStatsSummary.tsx b/apps/website/components/admin/UserStatsSummary.tsx new file mode 100644 index 000000000..fdad74c22 --- /dev/null +++ b/apps/website/components/admin/UserStatsSummary.tsx @@ -0,0 +1,51 @@ +'use client'; + +import React from 'react'; +import { Users, Shield } from 'lucide-react'; +import { Grid } from '@/ui/Grid'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface UserStatsSummaryProps { + total: number; + activeCount: number; + adminCount: number; +} + +export function UserStatsSummary({ total, activeCount, adminCount }: UserStatsSummaryProps) { + return ( + + + + + Total Users + {total} + + + + + + + + Active + {activeCount} + + + + + + + + Admins + {adminCount} + + + + + + ); +} diff --git a/apps/website/components/dashboard/ActivityFeed.tsx b/apps/website/components/dashboard/ActivityFeed.tsx new file mode 100644 index 000000000..39cbad1f1 --- /dev/null +++ b/apps/website/components/dashboard/ActivityFeed.tsx @@ -0,0 +1,62 @@ +'use client'; + +import React from 'react'; +import { Activity } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; + +interface FeedItem { + id: string; + headline: string; + body?: string; + formattedTime: string; + ctaHref?: string; + ctaLabel?: string; +} + +interface ActivityFeedProps { + items: FeedItem[]; + hasItems: boolean; +} + +export function ActivityFeed({ items, hasItems }: ActivityFeedProps) { + return ( + + + }> + Recent Activity + + {hasItems ? ( + + {items.slice(0, 5).map((item) => ( + + + {item.headline} + {item.body && {item.body}} + {item.formattedTime} + + {item.ctaHref && item.ctaLabel && ( + + + {item.ctaLabel} + + + )} + + ))} + + ) : ( + + + No activity yet + Join leagues and add friends to see activity here + + )} + + + ); +} diff --git a/apps/website/components/dashboard/ChampionshipStandings.tsx b/apps/website/components/dashboard/ChampionshipStandings.tsx new file mode 100644 index 000000000..3b1da05f7 --- /dev/null +++ b/apps/website/components/dashboard/ChampionshipStandings.tsx @@ -0,0 +1,56 @@ +'use client'; + +import React from 'react'; +import { Award, ChevronRight } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface Standing { + leagueId: string; + leagueName: string; + position: string; + points: string; + totalDrivers: string; +} + +interface ChampionshipStandingsProps { + standings: Standing[]; +} + +export function ChampionshipStandings({ standings }: ChampionshipStandingsProps) { + return ( + + + + }> + Your Championship Standings + + + + + View all + + + + + + + {standings.map((summary) => ( + + + {summary.leagueName} + Position {summary.position} • {summary.points} points + + {summary.totalDrivers} drivers + + ))} + + + + ); +} diff --git a/apps/website/components/dashboard/DashboardHero.tsx b/apps/website/components/dashboard/DashboardHero.tsx new file mode 100644 index 000000000..672399b43 --- /dev/null +++ b/apps/website/components/dashboard/DashboardHero.tsx @@ -0,0 +1,101 @@ +'use client'; + +import React from 'react'; +import { Trophy, Medal, Target, Users, Flag, User } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Image } from '@/ui/Image'; +import { Button } from '@/ui/Button'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { StatBox } from './StatBox'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface DashboardHeroProps { + currentDriver: { + name: string; + avatarUrl: string; + country: string; + rating: string | number; + rank: string | number; + totalRaces: string | number; + wins: string | number; + podiums: string | number; + consistency: string; + }; + activeLeaguesCount: string | number; +} + +export function DashboardHero({ currentDriver, activeLeaguesCount }: DashboardHeroProps) { + return ( + + {/* Background Pattern */} + + + + + + {/* Welcome Message */} + + + + + {currentDriver.name} + + + + + + Good morning, + + {currentDriver.name} + {currentDriver.country} + + + + {currentDriver.rating} + + + #{currentDriver.rank} + + {currentDriver.totalRaces} races completed + + + + + {/* Quick Actions */} + + + + + + + + + + + {/* Quick Stats Row */} + + {/* At md this should be 4 columns */} + + + + + + + + + ); +} diff --git a/apps/website/components/dashboard/FriendsSidebar.tsx b/apps/website/components/dashboard/FriendsSidebar.tsx new file mode 100644 index 000000000..129028c89 --- /dev/null +++ b/apps/website/components/dashboard/FriendsSidebar.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React from 'react'; +import { Users, UserPlus } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Image } from '@/ui/Image'; +import { Button } from '@/ui/Button'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface Friend { + id: string; + name: string; + avatarUrl: string; + country: string; +} + +interface FriendsSidebarProps { + friends: Friend[]; + hasFriends: boolean; +} + +export function FriendsSidebar({ friends, hasFriends }: FriendsSidebarProps) { + return ( + + + + }> + Friends + + {friends.length} friends + + {hasFriends ? ( + + {friends.slice(0, 6).map((friend) => ( + + + {friend.name} + + + {friend.name} + {friend.country} + + + ))} + {friends.length > 6 && ( + + + +{friends.length - 6} more + + + )} + + ) : ( + + + No friends yet + + + + + + + )} + + + ); +} diff --git a/apps/website/components/dashboard/NextRaceCard.tsx b/apps/website/components/dashboard/NextRaceCard.tsx new file mode 100644 index 000000000..21b53d4bd --- /dev/null +++ b/apps/website/components/dashboard/NextRaceCard.tsx @@ -0,0 +1,74 @@ +'use client'; + +import React from 'react'; +import { Calendar, Clock, 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 { Button } from '@/ui/Button'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; + +interface NextRaceCardProps { + nextRace: { + id: string; + track: string; + car: string; + formattedDate: string; + formattedTime: string; + timeUntil: string; + isMyLeague: boolean; + }; +} + +export function NextRaceCard({ nextRace }: NextRaceCardProps) { + return ( + + + + + + Next Race + + {nextRace.isMyLeague && ( + + Your League + + )} + + + + + {nextRace.track} + {nextRace.car} + + + + {nextRace.formattedDate} + + + + {nextRace.formattedTime} + + + + + + + Starts in + {nextRace.timeUntil} + + + + + + + + + + + ); +} diff --git a/apps/website/components/dashboard/StatBox.tsx b/apps/website/components/dashboard/StatBox.tsx new file mode 100644 index 000000000..3751b72d2 --- /dev/null +++ b/apps/website/components/dashboard/StatBox.tsx @@ -0,0 +1,33 @@ +'use client'; + +import React from 'react'; +import { Trophy, Medal, Target, Users, LucideIcon } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface StatBoxProps { + icon: LucideIcon; + label: string; + value: string | number; + color: string; +} + +export function StatBox({ icon, label, value, color }: StatBoxProps) { + return ( + + + + + + + {value} + {label} + + + + ); +} diff --git a/apps/website/components/dashboard/UpcomingRaces.tsx b/apps/website/components/dashboard/UpcomingRaces.tsx new file mode 100644 index 000000000..a3531e7cf --- /dev/null +++ b/apps/website/components/dashboard/UpcomingRaces.tsx @@ -0,0 +1,68 @@ +'use client'; + +import React from 'react'; +import { Calendar } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { routes } from '@/lib/routing/RouteConfig'; + +interface UpcomingRace { + id: string; + track: string; + car: string; + formattedDate: string; + formattedTime: string; + isMyLeague: boolean; +} + +interface UpcomingRacesProps { + races: UpcomingRace[]; + hasRaces: boolean; +} + +export function UpcomingRaces({ races, hasRaces }: UpcomingRacesProps) { + return ( + + + + }> + Upcoming Races + + + + View all + + + + {hasRaces ? ( + + {races.slice(0, 5).map((race) => ( + + {race.track} + {race.car} + + {race.formattedDate} + + {race.formattedTime} + + {race.isMyLeague && ( + + Your League + + )} + + ))} + + ) : ( + + No upcoming races + + )} + + + ); +} diff --git a/apps/website/components/drivers/CategoryDistribution.tsx b/apps/website/components/drivers/CategoryDistribution.tsx index ee797fc69..5be043b14 100644 --- a/apps/website/components/drivers/CategoryDistribution.tsx +++ b/apps/website/components/drivers/CategoryDistribution.tsx @@ -1,8 +1,6 @@ 'use client'; import { BarChart3 } from 'lucide-react'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - const CATEGORIES = [ { id: 'beginner', label: 'Beginner', color: 'text-green-400', bgColor: 'bg-green-400/10', borderColor: 'border-green-400/30' }, { id: 'intermediate', label: 'Intermediate', color: 'text-primary-blue', bgColor: 'bg-primary-blue/10', borderColor: 'border-primary-blue/30' }, @@ -13,7 +11,9 @@ const CATEGORIES = [ ]; interface CategoryDistributionProps { - drivers: DriverLeaderboardItemViewModel[]; + drivers: { + category?: string; + }[]; } export function CategoryDistribution({ drivers }: CategoryDistributionProps) { diff --git a/apps/website/components/drivers/DriverCard.tsx b/apps/website/components/drivers/DriverCard.tsx index af88d3342..ad7616418 100644 --- a/apps/website/components/drivers/DriverCard.tsx +++ b/apps/website/components/drivers/DriverCard.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; import RankBadge from '@/components/drivers/RankBadge'; import { DriverIdentity } from '@/components/drivers/DriverIdentity'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; diff --git a/apps/website/components/drivers/DriverIdentity.tsx b/apps/website/components/drivers/DriverIdentity.tsx index e7edd7f8a..02c585f0f 100644 --- a/apps/website/components/drivers/DriverIdentity.tsx +++ b/apps/website/components/drivers/DriverIdentity.tsx @@ -1,6 +1,6 @@ import Link from 'next/link'; import Image from 'next/image'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import PlaceholderImage from '@/ui/PlaceholderImage'; export interface DriverIdentityProps { driver: { diff --git a/apps/website/components/drivers/DriverRankings.tsx b/apps/website/components/drivers/DriverRankings.tsx index db334f106..bdb67a7ef 100644 --- a/apps/website/components/drivers/DriverRankings.tsx +++ b/apps/website/components/drivers/DriverRankings.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; export interface DriverRanking { type: 'overall' | 'league'; diff --git a/apps/website/components/drivers/DriversHero.tsx b/apps/website/components/drivers/DriversHero.tsx new file mode 100644 index 000000000..2157b90f4 --- /dev/null +++ b/apps/website/components/drivers/DriversHero.tsx @@ -0,0 +1,87 @@ +'use client'; + +import React from 'react'; +import { Users, Trophy, LucideIcon } 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 { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { DecorativeBlur } from '@/ui/DecorativeBlur'; + +interface StatItemProps { + label: string; + value: string | number; + color: string; + animate?: boolean; +} + +function StatItem({ label, value, color, animate }: StatItemProps) { + return ( + + + + {value} {label} + + + ); +} + +interface DriversHeroProps { + driverCount: number; + activeCount: number; + totalWins: number; + totalRaces: number; + onViewLeaderboard: () => void; +} + +export function DriversHero({ + driverCount, + activeCount, + totalWins, + totalRaces, + onViewLeaderboard, +}: DriversHeroProps) { + return ( + + + + + + + + + + + Drivers + + + Meet the racers who make every lap count. From rookies to champions, track their journey and see who's dominating the grid. + + + {/* Quick Stats */} + + + + + + + + + {/* CTA */} + + + See full driver rankings + + + + ); +} diff --git a/apps/website/components/drivers/DriversSearch.tsx b/apps/website/components/drivers/DriversSearch.tsx new file mode 100644 index 000000000..4bbaf46c3 --- /dev/null +++ b/apps/website/components/drivers/DriversSearch.tsx @@ -0,0 +1,30 @@ +'use client'; + +import React from 'react'; +import { Search } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Input } from '@/ui/Input'; + +interface DriversSearchProps { + query: string; + onChange: (query: string) => void; +} + +export function DriversSearch({ query, onChange }: DriversSearchProps) { + return ( + + + + + + onChange(e.target.value)} + style={{ paddingLeft: '2.75rem' }} + /> + + + ); +} diff --git a/apps/website/components/drivers/FeaturedDriverCard.tsx b/apps/website/components/drivers/FeaturedDriverCard.tsx index 6fa80fb63..8aded7ca9 100644 --- a/apps/website/components/drivers/FeaturedDriverCard.tsx +++ b/apps/website/components/drivers/FeaturedDriverCard.tsx @@ -3,8 +3,6 @@ import { Trophy, Crown, Star, TrendingUp, Shield, Flag } from 'lucide-react'; import Image from 'next/image'; import { mediaConfig } from '@/lib/config/mediaConfig'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - const SKILL_LEVELS = [ { id: 'pro', label: 'Pro', icon: Crown, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30', description: 'Elite competition level' }, { id: 'advanced', label: 'Advanced', icon: Star, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30', description: 'Highly competitive' }, @@ -22,7 +20,17 @@ const CATEGORIES = [ ]; interface FeaturedDriverCardProps { - driver: DriverLeaderboardItemViewModel; + driver: { + id: string; + name: string; + nationality: string; + avatarUrl?: string; + rating: number; + wins: number; + podiums: number; + skillLevel?: string; + category?: string; + }; position: number; onClick: () => void; } diff --git a/apps/website/components/drivers/HeroSection.tsx b/apps/website/components/drivers/HeroSection.tsx index 7fa23761c..e0f9f934d 100644 --- a/apps/website/components/drivers/HeroSection.tsx +++ b/apps/website/components/drivers/HeroSection.tsx @@ -1,4 +1,4 @@ -import Heading from '@/components/ui/Heading'; +import Heading from '@/ui/Heading'; import { Trophy, Users } from 'lucide-react'; import Button from '../ui/Button'; diff --git a/apps/website/components/drivers/LeaderboardPreview.tsx b/apps/website/components/drivers/LeaderboardPreview.tsx index 3efcb62d6..af60fcce8 100644 --- a/apps/website/components/drivers/LeaderboardPreview.tsx +++ b/apps/website/components/drivers/LeaderboardPreview.tsx @@ -3,10 +3,8 @@ import { useRouter } from 'next/navigation'; import { Award, Crown, Flag, ChevronRight } from 'lucide-react'; import Image from 'next/image'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; import { mediaConfig } from '@/lib/config/mediaConfig'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - const SKILL_LEVELS = [ { id: 'pro', label: 'Pro', color: 'text-yellow-400' }, { id: 'advanced', label: 'Advanced', color: 'text-purple-400' }, @@ -24,7 +22,16 @@ const CATEGORIES = [ ]; interface LeaderboardPreviewProps { - drivers: DriverLeaderboardItemViewModel[]; + drivers: { + id: string; + name: string; + avatarUrl?: string; + nationality: string; + rating: number; + wins: number; + skillLevel?: string; + category?: string; + }[]; onDriverClick: (id: string) => void; } diff --git a/apps/website/components/drivers/RankingsPodium.tsx b/apps/website/components/drivers/RankingsPodium.tsx new file mode 100644 index 000000000..0264e5c6f --- /dev/null +++ b/apps/website/components/drivers/RankingsPodium.tsx @@ -0,0 +1,94 @@ +'use client'; + +import React from 'react'; +import { Crown } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Image } from '@/ui/Image'; +import { Surface } from '@/ui/Surface'; + +interface PodiumDriver { + id: string; + name: string; + avatarUrl: string; + rating: number; + wins: number; + podiums: number; +} + +interface RankingsPodiumProps { + podium: PodiumDriver[]; + onDriverClick?: (id: string) => void; +} + +export function RankingsPodium({ podium, onDriverClick }: RankingsPodiumProps) { + return ( + + + {[1, 0, 2].map((index) => { + const driver = podium[index]; + if (!driver) return null; + + const position = index === 1 ? 1 : index === 0 ? 2 : 3; + const config = { + 1: { height: '10rem', color: 'rgba(250, 204, 21, 0.2)', borderColor: 'rgba(250, 204, 21, 0.4)', crown: '#facc15' }, + 2: { height: '8rem', color: 'rgba(209, 213, 219, 0.2)', borderColor: 'rgba(209, 213, 219, 0.4)', crown: '#d1d5db' }, + 3: { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' }, + }[position] || { height: '6rem', color: 'rgba(217, 119, 6, 0.2)', borderColor: 'rgba(217, 119, 6, 0.4)', crown: '#d97706' }; + + return ( + onDriverClick?.(driver.id)} + style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', backgroundColor: 'transparent', border: 'none', cursor: 'pointer' }} + > + + + {driver.name} + + + {position} + + + + + {driver.name} + + + + {driver.rating.toString()} + + + + + 🏆 + {driver.wins} + + + + 🏅 + {driver.podiums} + + + + + + {position} + + + + ); + })} + + + ); +} diff --git a/apps/website/components/drivers/RankingsTable.tsx b/apps/website/components/drivers/RankingsTable.tsx new file mode 100644 index 000000000..19d70a088 --- /dev/null +++ b/apps/website/components/drivers/RankingsTable.tsx @@ -0,0 +1,122 @@ +'use client'; + +import React from 'react'; +import { Medal } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Image } from '@/ui/Image'; +import { Icon } from '@/ui/Icon'; + +interface Driver { + id: string; + name: string; + avatarUrl: string; + rank: number; + nationality: string; + skillLevel: string; + racesCompleted: number; + rating: number; + wins: number; + medalBg?: string; + medalColor?: string; +} + +interface RankingsTableProps { + drivers: Driver[]; + onDriverClick?: (id: string) => void; +} + +export function RankingsTable({ drivers, onDriverClick }: RankingsTableProps) { + return ( + + {/* Table Header */} + + Rank + Driver + Races + Rating + Wins + + + {/* Table Body */} + + {drivers.map((driver, index) => { + const position = driver.rank; + + return ( + onDriverClick?.(driver.id)} + style={{ + display: 'grid', + gridTemplateColumns: 'repeat(12, minmax(0, 1fr))', + gap: '1rem', + padding: '1rem', + width: '100%', + textAlign: 'left', + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + borderBottom: index < drivers.length - 1 ? '1px solid rgba(38, 38, 38, 0.5)' : 'none' + }} + > + {/* Position */} + + + {position <= 3 ? : position} + + + + {/* Driver Info */} + + + {driver.name} + + + + {driver.name} + + + {driver.nationality} + {driver.skillLevel} + + + + + {/* Races */} + + {driver.racesCompleted} + + + {/* Rating */} + + + {driver.rating.toString()} + + + + {/* Wins */} + + + {driver.wins} + + + + ); + })} + + + {/* Empty State */} + {drivers.length === 0 && ( + + 🔍 + No drivers found + There are no drivers in the system yet + + )} + + ); +} diff --git a/apps/website/components/drivers/RecentActivity.tsx b/apps/website/components/drivers/RecentActivity.tsx index fed2cfd8c..fbe6ac519 100644 --- a/apps/website/components/drivers/RecentActivity.tsx +++ b/apps/website/components/drivers/RecentActivity.tsx @@ -3,8 +3,6 @@ import { Activity } from 'lucide-react'; import Image from 'next/image'; import { mediaConfig } from '@/lib/config/mediaConfig'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - const SKILL_LEVELS = [ { id: 'pro', label: 'Pro', color: 'text-yellow-400' }, { id: 'advanced', label: 'Advanced', color: 'text-purple-400' }, @@ -22,7 +20,14 @@ const CATEGORIES = [ ]; interface RecentActivityProps { - drivers: DriverLeaderboardItemViewModel[]; + drivers: { + id: string; + name: string; + avatarUrl?: string; + isActive: boolean; + skillLevel?: string; + category?: string; + }[]; onDriverClick: (id: string) => void; } diff --git a/apps/website/components/drivers/SkillDistribution.tsx b/apps/website/components/drivers/SkillDistribution.tsx index 0eae84d9b..bbd5ef603 100644 --- a/apps/website/components/drivers/SkillDistribution.tsx +++ b/apps/website/components/drivers/SkillDistribution.tsx @@ -1,8 +1,6 @@ 'use client'; import { BarChart3 } from 'lucide-react'; -import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; - const SKILL_LEVELS = [ { id: 'pro', label: 'Pro', icon: BarChart3, color: 'text-yellow-400', bgColor: 'bg-yellow-400/10', borderColor: 'border-yellow-400/30' }, { id: 'advanced', label: 'Advanced', icon: BarChart3, color: 'text-purple-400', bgColor: 'bg-purple-400/10', borderColor: 'border-purple-400/30' }, @@ -11,7 +9,9 @@ const SKILL_LEVELS = [ ]; interface SkillDistributionProps { - drivers: DriverLeaderboardItemViewModel[]; + drivers: { + skillLevel?: string; + }[]; } export function SkillDistribution({ drivers }: SkillDistributionProps) { diff --git a/apps/website/components/feed/FeedEmptyState.tsx b/apps/website/components/feed/FeedEmptyState.tsx index 696884959..3534cf0d6 100644 --- a/apps/website/components/feed/FeedEmptyState.tsx +++ b/apps/website/components/feed/FeedEmptyState.tsx @@ -1,5 +1,5 @@ -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; export default function FeedEmptyState() { return ( diff --git a/apps/website/components/feed/FeedItemCard.tsx b/apps/website/components/feed/FeedItemCard.tsx index 915464d43..1e9eca4e2 100644 --- a/apps/website/components/feed/FeedItemCard.tsx +++ b/apps/website/components/feed/FeedItemCard.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; import Image from 'next/image'; interface FeedItemData { diff --git a/apps/website/components/feed/FeedLayout.tsx b/apps/website/components/feed/FeedLayout.tsx index 0c931edbd..f7daa7d52 100644 --- a/apps/website/components/feed/FeedLayout.tsx +++ b/apps/website/components/feed/FeedLayout.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; import FeedList from '@/components/feed/FeedList'; import UpcomingRacesSidebar from '@/components/races/UpcomingRacesSidebar'; import LatestResultsSidebar from '@/components/races/LatestResultsSidebar'; diff --git a/apps/website/components/landing/AlternatingSection.tsx b/apps/website/components/landing/AlternatingSection.tsx index 00e307886..b67b2f160 100644 --- a/apps/website/components/landing/AlternatingSection.tsx +++ b/apps/website/components/landing/AlternatingSection.tsx @@ -1,7 +1,7 @@ 'use client'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; import { useParallax } from "@/lib/hooks/useScrollProgress"; import { useRef } from 'react'; diff --git a/apps/website/components/landing/DiscordCTA.tsx b/apps/website/components/landing/DiscordCTA.tsx index 1f4faff37..a761d4be3 100644 --- a/apps/website/components/landing/DiscordCTA.tsx +++ b/apps/website/components/landing/DiscordCTA.tsx @@ -1,7 +1,7 @@ 'use client'; import { useRef } from 'react'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; export default function DiscordCTA() { const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; diff --git a/apps/website/components/landing/FeatureGrid.tsx b/apps/website/components/landing/FeatureGrid.tsx index 4ad69f57e..6c21c139a 100644 --- a/apps/website/components/landing/FeatureGrid.tsx +++ b/apps/website/components/landing/FeatureGrid.tsx @@ -1,10 +1,10 @@ 'use client'; import { useRef, useState, useEffect } from 'react'; -import Section from '@/components/ui/Section'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; -import MockupStack from '@/components/ui/MockupStack'; +import Section from '@/ui/Section'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; +import MockupStack from '@/ui/MockupStack'; import LeagueHomeMockup from '@/components/mockups/LeagueHomeMockup'; import StandingsTableMockup from '@/components/mockups/StandingsTableMockup'; import TeamCompetitionMockup from '@/components/mockups/TeamCompetitionMockup'; diff --git a/apps/website/components/landing/Hero.tsx b/apps/website/components/landing/Hero.tsx index d9bf73872..5ad82f1f0 100644 --- a/apps/website/components/landing/Hero.tsx +++ b/apps/website/components/landing/Hero.tsx @@ -1,9 +1,9 @@ 'use client'; import { useRef } from 'react'; -import Button from '@/components/ui/Button'; -import Container from '@/components/ui/Container'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Container from '@/ui/Container'; +import Heading from '@/ui/Heading'; import { useParallax } from '@/lib/hooks/useScrollProgress'; const discordUrl = process.env.NEXT_PUBLIC_DISCORD_URL || '#'; diff --git a/apps/website/components/landing/LandingItems.tsx b/apps/website/components/landing/LandingItems.tsx new file mode 100644 index 000000000..5ee7f9283 --- /dev/null +++ b/apps/website/components/landing/LandingItems.tsx @@ -0,0 +1,54 @@ +'use client'; + +import React from 'react'; +import { Check } from 'lucide-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'; + +export function FeatureItem({ text }: { text: string }) { + return ( + + + + + + + {text} + + + + ); +} + +export function ResultItem({ text, color }: { text: string, color: string }) { + return ( + + + + + + + {text} + + + + ); +} + +export function StepItem({ step, text }: { step: number, text: string }) { + return ( + + + + {step} + + + {text} + + + + ); +} diff --git a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx index c79ad00d9..a66dcb94e 100644 --- a/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/DriverLeaderboardPreview.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Trophy, Crown, Flag, ChevronRight } from 'lucide-react'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; import Image from 'next/image'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; diff --git a/apps/website/components/leaderboards/LeaderboardsHero.tsx b/apps/website/components/leaderboards/LeaderboardsHero.tsx new file mode 100644 index 000000000..d8b0cdab8 --- /dev/null +++ b/apps/website/components/leaderboards/LeaderboardsHero.tsx @@ -0,0 +1,59 @@ +'use client'; + +import React from 'react'; +import { Award, Trophy, Users } from 'lucide-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 { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { DecorativeBlur } from '@/ui/DecorativeBlur'; + +interface LeaderboardsHeroProps { + onNavigateToDrivers: () => void; + onNavigateToTeams: () => void; +} + +export function LeaderboardsHero({ onNavigateToDrivers, onNavigateToTeams }: LeaderboardsHeroProps) { + return ( + + + + + + + + + + + Leaderboards + Where champions rise and legends are made + + + + + Track the best drivers and teams across all competitions. Every race counts. Every position matters. Who will claim the throne? + + + + + + + + + ); +} diff --git a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx index 6e226849c..f4d47fa1e 100644 --- a/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx +++ b/apps/website/components/leaderboards/TeamLeaderboardPreview.tsx @@ -1,7 +1,7 @@ import React from 'react'; import Image from 'next/image'; import { Users, Crown, Shield, ChevronRight } from 'lucide-react'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; import { getMediaUrl } from '@/lib/utilities/media'; import { SkillLevelDisplay } from '@/lib/display-objects/SkillLevelDisplay'; import { MedalDisplay } from '@/lib/display-objects/MedalDisplay'; diff --git a/apps/website/components/leagues/BonusPointsCard.tsx b/apps/website/components/leagues/BonusPointsCard.tsx index fc9dc9e56..7526bdd64 100644 --- a/apps/website/components/leagues/BonusPointsCard.tsx +++ b/apps/website/components/leagues/BonusPointsCard.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; interface BonusPointsCardProps { bonusSummary: string[]; diff --git a/apps/website/components/leagues/ChampionshipCard.tsx b/apps/website/components/leagues/ChampionshipCard.tsx index 43f3f464d..dceb4facc 100644 --- a/apps/website/components/leagues/ChampionshipCard.tsx +++ b/apps/website/components/leagues/ChampionshipCard.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; import type { LeagueScoringChampionshipViewModel } from '@/lib/view-models/LeagueScoringChampionshipViewModel'; type PointsPreviewRow = { diff --git a/apps/website/components/leagues/CreateLeagueWizard.tsx b/apps/website/components/leagues/CreateLeagueWizard.tsx index 058cb9c0a..501e00c69 100644 --- a/apps/website/components/leagues/CreateLeagueWizard.tsx +++ b/apps/website/components/leagues/CreateLeagueWizard.tsx @@ -1,10 +1,10 @@ 'use client'; import LeagueReviewSummary from '@/components/leagues/LeagueReviewSummary'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Heading from '@/components/ui/Heading'; -import Input from '@/components/ui/Input'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; +import Heading from '@/ui/Heading'; +import Input from '@/ui/Input'; import { useAuth } from '@/lib/auth/AuthContext'; import { AlertCircle, diff --git a/apps/website/components/leagues/DropRulesExplanation.tsx b/apps/website/components/leagues/DropRulesExplanation.tsx index 811fe886d..194d4cc0a 100644 --- a/apps/website/components/leagues/DropRulesExplanation.tsx +++ b/apps/website/components/leagues/DropRulesExplanation.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; interface DropRulesExplanationProps { dropPolicyDescription: string; diff --git a/apps/website/components/leagues/EmptyState.tsx b/apps/website/components/leagues/EmptyState.tsx index aade98e00..280da55ab 100644 --- a/apps/website/components/leagues/EmptyState.tsx +++ b/apps/website/components/leagues/EmptyState.tsx @@ -1,7 +1,7 @@ import { Trophy, Sparkles, Search } from 'lucide-react'; -import Heading from '@/components/ui/Heading'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; +import Heading from '@/ui/Heading'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; interface EmptyStateProps { title: string; diff --git a/apps/website/components/leagues/EndRaceModal.tsx b/apps/website/components/leagues/EndRaceModal.tsx index e81557a73..62abb3201 100644 --- a/apps/website/components/leagues/EndRaceModal.tsx +++ b/apps/website/components/leagues/EndRaceModal.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { AlertTriangle, TestTube, CheckCircle2 } from 'lucide-react'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; interface EndRaceModalProps { raceId: string; diff --git a/apps/website/components/leagues/HeroSection.tsx b/apps/website/components/leagues/HeroSection.tsx index fdae70ff8..f57bd9e20 100644 --- a/apps/website/components/leagues/HeroSection.tsx +++ b/apps/website/components/leagues/HeroSection.tsx @@ -1,6 +1,6 @@ import { Trophy, Plus } from 'lucide-react'; -import Heading from '@/components/ui/Heading'; -import Button from '@/components/ui/Button'; +import Heading from '@/ui/Heading'; +import Button from '@/ui/Button'; interface StatItem { value: number; diff --git a/apps/website/components/leagues/LeagueBasicsSection.tsx b/apps/website/components/leagues/LeagueBasicsSection.tsx index bf3a1372d..4eb45c10d 100644 --- a/apps/website/components/leagues/LeagueBasicsSection.tsx +++ b/apps/website/components/leagues/LeagueBasicsSection.tsx @@ -2,7 +2,7 @@ import React from 'react'; import { FileText, Gamepad2, AlertCircle, Check } from 'lucide-react'; -import Input from '@/components/ui/Input'; +import Input from '@/ui/Input'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; interface LeagueBasicsSectionProps { diff --git a/apps/website/components/leagues/LeagueCard.tsx b/apps/website/components/leagues/LeagueCard.tsx index 99af5118e..b95a50db3 100644 --- a/apps/website/components/leagues/LeagueCard.tsx +++ b/apps/website/components/leagues/LeagueCard.tsx @@ -14,7 +14,7 @@ import { } from 'lucide-react'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import { getLeagueCoverClasses } from '@/lib/leagueCovers'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import PlaceholderImage from '@/ui/PlaceholderImage'; import { getMediaUrl } from '@/lib/utilities/media'; interface LeagueCardProps { diff --git a/apps/website/components/leagues/LeagueChampionshipStats.tsx b/apps/website/components/leagues/LeagueChampionshipStats.tsx index fcf26e61c..81b627349 100644 --- a/apps/website/components/leagues/LeagueChampionshipStats.tsx +++ b/apps/website/components/leagues/LeagueChampionshipStats.tsx @@ -1,5 +1,12 @@ +'use client'; + import React from 'react'; -import Card from '@/components/ui/Card'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Grid } from '@/ui/Grid'; +import { Surface } from '@/ui/Surface'; interface LeagueChampionshipStatsProps { standings: Array<{ @@ -21,45 +28,45 @@ export function LeagueChampionshipStats({ standings, drivers }: LeagueChampionsh const totalRaces = Math.max(...standings.map(s => s.racesFinished), 0); return ( -
+ -
-
- 🏆 -
-
-

Championship Leader

-

{drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'}

-

{leader?.totalPoints || 0} points

-
-
+ + + 🏆 + + + Championship Leader + {drivers.find(d => d.id === leader?.driverId)?.name || 'N/A'} + {leader?.totalPoints || 0} points + +
-
-
- 🏁 -
-
-

Races Completed

-

{totalRaces}

-

Season in progress

-
-
+ + + 🏁 + + + Races Completed + {totalRaces} + Season in progress + +
-
-
- 👥 -
-
-

Active Drivers

-

{standings.length}

-

Competing for points

-
-
+ + + 👥 + + + Active Drivers + {standings.length} + Competing for points + +
-
+ ); } diff --git a/apps/website/components/leagues/LeagueDecalPlacementEditor.tsx b/apps/website/components/leagues/LeagueDecalPlacementEditor.tsx index 249fc3b58..5e17d42d9 100644 --- a/apps/website/components/leagues/LeagueDecalPlacementEditor.tsx +++ b/apps/website/components/leagues/LeagueDecalPlacementEditor.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState, useRef, useCallback } from 'react'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; import { Move, RotateCw, diff --git a/apps/website/components/leagues/LeagueOwnershipTransfer.tsx b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx index 266f781b6..b5a32c8c8 100644 --- a/apps/website/components/leagues/LeagueOwnershipTransfer.tsx +++ b/apps/website/components/leagues/LeagueOwnershipTransfer.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import DriverSummaryPill from '@/components/profile/DriverSummaryPill'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; import { UserCog } from 'lucide-react'; import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; diff --git a/apps/website/components/leagues/LeagueStructureSection.tsx b/apps/website/components/leagues/LeagueStructureSection.tsx index 5e1a7bfeb..b2cca01c8 100644 --- a/apps/website/components/leagues/LeagueStructureSection.tsx +++ b/apps/website/components/leagues/LeagueStructureSection.tsx @@ -4,7 +4,7 @@ import { User, Users2, Info, Check, HelpCircle, X } from 'lucide-react'; import { useState, useRef, useEffect, useMemo } from 'react'; import type * as React from 'react'; import { createPortal } from 'react-dom'; -import Input from '@/components/ui/Input'; +import Input from '@/ui/Input'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; // ============================================================================ diff --git a/apps/website/components/leagues/LeagueSummaryCard.tsx b/apps/website/components/leagues/LeagueSummaryCard.tsx new file mode 100644 index 000000000..c9f74c049 --- /dev/null +++ b/apps/website/components/leagues/LeagueSummaryCard.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { ArrowRight } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Image } from '@/ui/Image'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Grid } from '@/ui/Grid'; +import { Surface } from '@/ui/Surface'; +import { Link } from '@/ui/Link'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; + +interface LeagueSummaryCardProps { + league: { + id: string; + name: string; + description?: string; + settings: { + maxDrivers: number; + qualifyingFormat: string; + }; + }; +} + +export function LeagueSummaryCard({ league }: LeagueSummaryCardProps) { + return ( + + + + + {league.name} + + + League + {league.name} + + + + {league.description && ( + {league.description} + )} + + + + + Max Drivers + {league.settings.maxDrivers} + + + Format + {league.settings.qualifyingFormat} + + + + + + + + + + + + ); +} diff --git a/apps/website/components/leagues/LeagueTabs.tsx b/apps/website/components/leagues/LeagueTabs.tsx new file mode 100644 index 000000000..4a8cee445 --- /dev/null +++ b/apps/website/components/leagues/LeagueTabs.tsx @@ -0,0 +1,36 @@ +'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 LeagueTabsProps { + tabs: Tab[]; +} + +export function LeagueTabs({ tabs }: LeagueTabsProps) { + return ( + + + {tabs.map((tab) => ( + + + {tab.label} + + + ))} + + + ); +} diff --git a/apps/website/components/leagues/LeagueTimingsSection.tsx b/apps/website/components/leagues/LeagueTimingsSection.tsx index 9178dceb1..2879f7198 100644 --- a/apps/website/components/leagues/LeagueTimingsSection.tsx +++ b/apps/website/components/leagues/LeagueTimingsSection.tsx @@ -20,8 +20,8 @@ import { } from 'lucide-react'; import type { LeagueConfigFormModel } from '@/lib/types/LeagueConfigFormModel'; import type { Weekday } from '@/lib/types/Weekday'; -import Input from '@/components/ui/Input'; -import RangeField from '@/components/ui/RangeField'; +import Input from '@/ui/Input'; +import RangeField from '@/ui/RangeField'; // Common time zones for racing leagues const TIME_ZONES = [ diff --git a/apps/website/components/leagues/LeaguesClient.tsx b/apps/website/components/leagues/LeaguesClient.tsx index 3bb58c1f0..03a5b75ca 100644 --- a/apps/website/components/leagues/LeaguesClient.tsx +++ b/apps/website/components/leagues/LeaguesClient.tsx @@ -19,10 +19,10 @@ import { Timer, } from 'lucide-react'; import LeagueCard from '@/components/leagues/LeagueCard'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; -import Input from '@/components/ui/Input'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; +import Input from '@/ui/Input'; +import Heading from '@/ui/Heading'; import type { LeaguesViewData } from '@/lib/view-data/LeaguesViewData'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; diff --git a/apps/website/components/leagues/NoResultsState.tsx b/apps/website/components/leagues/NoResultsState.tsx index 0d9cdc561..c036dc8c9 100644 --- a/apps/website/components/leagues/NoResultsState.tsx +++ b/apps/website/components/leagues/NoResultsState.tsx @@ -1,6 +1,6 @@ import { Search } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Card from '@/components/ui/Card'; +import Button from '@/ui/Button'; +import Card from '@/ui/Card'; interface NoResultsStateProps { icon?: React.ElementType; diff --git a/apps/website/components/leagues/PointsBreakdownTable.tsx b/apps/website/components/leagues/PointsBreakdownTable.tsx index 2893298d3..3f21de609 100644 --- a/apps/website/components/leagues/PointsBreakdownTable.tsx +++ b/apps/website/components/leagues/PointsBreakdownTable.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; interface PointsBreakdownTableProps { positionPoints: Array<{ position: number; points: number }>; diff --git a/apps/website/components/leagues/PointsTable.tsx b/apps/website/components/leagues/PointsTable.tsx index 7fc619ad0..ff1c0f4a5 100644 --- a/apps/website/components/leagues/PointsTable.tsx +++ b/apps/website/components/leagues/PointsTable.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; interface PointsTableProps { title?: string; diff --git a/apps/website/components/leagues/QuickPenaltyModal.tsx b/apps/website/components/leagues/QuickPenaltyModal.tsx index 4f121e84c..d2544f8ce 100644 --- a/apps/website/components/leagues/QuickPenaltyModal.tsx +++ b/apps/website/components/leagues/QuickPenaltyModal.tsx @@ -2,7 +2,7 @@ import React, { useState } from 'react'; import { useRouter } from 'next/navigation'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; import { usePenaltyMutation } from "@/lib/hooks/league/usePenaltyMutation"; import { AlertTriangle, Clock, Flag, Zap } from 'lucide-react'; diff --git a/apps/website/components/leagues/RulebookTabs.tsx b/apps/website/components/leagues/RulebookTabs.tsx new file mode 100644 index 000000000..992544715 --- /dev/null +++ b/apps/website/components/leagues/RulebookTabs.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Surface } from '@/ui/Surface'; + +export type RulebookSection = 'scoring' | 'conduct' | 'protests' | 'penalties'; + +interface RulebookTabsProps { + activeSection: RulebookSection; + onSectionChange: (section: RulebookSection) => void; +} + +export function RulebookTabs({ activeSection, onSectionChange }: RulebookTabsProps) { + const sections: { id: RulebookSection; label: string }[] = [ + { id: 'scoring', label: 'Scoring' }, + { id: 'conduct', label: 'Conduct' }, + { id: 'protests', label: 'Protests' }, + { id: 'penalties', label: 'Penalties' }, + ]; + + return ( + + + {sections.map((section) => ( + + ))} + + + ); +} diff --git a/apps/website/components/leagues/ScheduleRaceCard.tsx b/apps/website/components/leagues/ScheduleRaceCard.tsx new file mode 100644 index 000000000..1ca39bbd6 --- /dev/null +++ b/apps/website/components/leagues/ScheduleRaceCard.tsx @@ -0,0 +1,77 @@ +'use client'; + +import React from 'react'; +import { Calendar, Clock, MapPin, Car, Trophy } from 'lucide-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 { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface Race { + id: string; + name: string; + track?: string; + car?: string; + scheduledAt: string; + status: string; + sessionType?: string; + isPast?: boolean; +} + +interface ScheduleRaceCardProps { + race: Race; +} + +export function ScheduleRaceCard({ race }: ScheduleRaceCardProps) { + return ( + + + + + {race.name} + + {race.status === 'completed' ? 'Completed' : 'Scheduled'} + + + + + + + {new Date(race.scheduledAt).toLocaleDateString()} + + + + + {new Date(race.scheduledAt).toLocaleTimeString()} + + + {race.track && ( + + + {race.track} + + )} + + {race.car && ( + + + {race.car} + + )} + + + {race.sessionType && ( + + + {race.sessionType} Session + + )} + + + ); +} diff --git a/apps/website/components/leagues/ScoringOverviewCard.tsx b/apps/website/components/leagues/ScoringOverviewCard.tsx index dc185f6ce..cf02b8e7a 100644 --- a/apps/website/components/leagues/ScoringOverviewCard.tsx +++ b/apps/website/components/leagues/ScoringOverviewCard.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; interface ScoringOverviewCardProps { gameName: string; diff --git a/apps/website/components/leagues/SearchAndFilterBar.tsx b/apps/website/components/leagues/SearchAndFilterBar.tsx index 099d6f877..c359376d1 100644 --- a/apps/website/components/leagues/SearchAndFilterBar.tsx +++ b/apps/website/components/leagues/SearchAndFilterBar.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { Search, Filter } from 'lucide-react'; -import Input from '@/components/ui/Input'; -import Button from '@/components/ui/Button'; +import Input from '@/ui/Input'; +import Button from '@/ui/Button'; interface Category { id: string; diff --git a/apps/website/components/leagues/SponsorshipRequestCard.tsx b/apps/website/components/leagues/SponsorshipRequestCard.tsx new file mode 100644 index 000000000..90dc062b5 --- /dev/null +++ b/apps/website/components/leagues/SponsorshipRequestCard.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React from 'react'; +import { CheckCircle, XCircle, AlertCircle } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Badge } from '@/ui/Badge'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface SponsorshipRequest { + id: string; + sponsorName: string; + status: 'pending' | 'approved' | 'rejected'; + requestedAt: string; + slotName: string; +} + +interface SponsorshipRequestCardProps { + request: SponsorshipRequest; +} + +export function SponsorshipRequestCard({ request }: SponsorshipRequestCardProps) { + const statusVariant = { + pending: 'warning' as const, + approved: 'success' as const, + rejected: 'danger' as const, + }[request.status]; + + const statusIcon = { + pending: AlertCircle, + approved: CheckCircle, + rejected: XCircle, + }[request.status]; + + const statusColor = { + pending: '#f59e0b', + approved: '#10b981', + rejected: '#ef4444', + }[request.status]; + + return ( + + + + + + {request.sponsorName} + + {request.status} + + + + + Requested: {request.slotName} + + + + {new Date(request.requestedAt).toLocaleDateString()} + + + + + ); +} diff --git a/apps/website/components/leagues/SponsorshipSlotCard.tsx b/apps/website/components/leagues/SponsorshipSlotCard.tsx new file mode 100644 index 000000000..fc608a6b1 --- /dev/null +++ b/apps/website/components/leagues/SponsorshipSlotCard.tsx @@ -0,0 +1,67 @@ +'use client'; + +import React from 'react'; +import { DollarSign } from 'lucide-react'; +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 { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface SponsorshipSlot { + id: string; + name: string; + description: string; + price: number; + currency: string; + isAvailable: boolean; + sponsoredBy?: { + name: string; + }; +} + +interface SponsorshipSlotCardProps { + slot: SponsorshipSlot; +} + +export function SponsorshipSlotCard({ slot }: SponsorshipSlotCardProps) { + return ( + + + + {slot.name} + + {slot.isAvailable ? 'Available' : 'Taken'} + + + + {slot.description} + + + + + {slot.price} {slot.currency} + + + + {!slot.isAvailable && slot.sponsoredBy && ( + + Sponsored by + {slot.sponsoredBy.name} + + )} + + + ); +} diff --git a/apps/website/components/leagues/StandingsTable.tsx b/apps/website/components/leagues/StandingsTable.tsx index 91cb08a52..4090930cc 100644 --- a/apps/website/components/leagues/StandingsTable.tsx +++ b/apps/website/components/leagues/StandingsTable.tsx @@ -3,8 +3,8 @@ import { useState, useRef, useEffect } from 'react'; import Link from 'next/link'; import Image from 'next/image'; -import CountryFlag from '@/components/ui/CountryFlag'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import CountryFlag from '@/ui/CountryFlag'; +import PlaceholderImage from '@/ui/PlaceholderImage'; // League role display data const leagueRoleDisplay = { diff --git a/apps/website/components/leagues/TransactionRow.tsx b/apps/website/components/leagues/TransactionRow.tsx index 0dbe15eed..3cad17ab9 100644 --- a/apps/website/components/leagues/TransactionRow.tsx +++ b/apps/website/components/leagues/TransactionRow.tsx @@ -1,91 +1,77 @@ +'use client'; + import React from 'react'; -import { - ArrowDownLeft, - ArrowUpRight, - CheckCircle, - Clock, - CreditCard, - DollarSign, - TrendingUp, - XCircle -} from 'lucide-react'; +import { ArrowUpRight, ArrowDownRight, DollarSign, TrendingUp, LucideIcon } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; interface Transaction { id: string; - amount: number; - type: 'sponsorship' | 'membership' | 'withdrawal' | 'prize'; - status: 'completed' | 'pending' | 'failed'; + type: string; description: string; - reference?: string; formattedDate: string; formattedAmount: string; - fee: number; + typeColor: string; + status: string; + statusColor: string; + amountColor: string; } interface TransactionRowProps { transaction: Transaction; } -export default function TransactionRow({ transaction }: TransactionRowProps) { - const isIncoming = transaction.amount > 0; - - const typeIcons = { - sponsorship: DollarSign, - membership: CreditCard, - withdrawal: ArrowUpRight, - prize: TrendingUp, +export function TransactionRow({ transaction }: TransactionRowProps) { + const getTransactionIcon = (type: string): LucideIcon => { + switch (type) { + case 'deposit': return ArrowUpRight; + case 'withdrawal': return ArrowDownRight; + case 'sponsorship': return DollarSign; + case 'prize': return TrendingUp; + default: return DollarSign; + } }; - const TypeIcon = typeIcons[transaction.type]; - - const statusConfig = { - completed: { color: 'text-performance-green', bg: 'bg-performance-green/10', icon: CheckCircle }, - pending: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', icon: Clock }, - failed: { color: 'text-racing-red', bg: 'bg-racing-red/10', icon: XCircle }, - }; - const status = statusConfig[transaction.status]; - const StatusIcon = status.icon; return ( -
-
-
- {isIncoming ? ( - - ) : ( - - )} -
-
-
- {transaction.description} - - {transaction.status} - -
-
- - {transaction.type} - {transaction.reference && ( - <> - - {transaction.reference} - - )} - - {transaction.formattedDate} -
-
-
-
-
- {transaction.formattedAmount} -
- {transaction.fee > 0 && ( -
- Fee: ${transaction.fee.toFixed(2)} -
- )} -
-
+ + + + + + + + + {transaction.description} + + + {transaction.formattedDate} + + + {transaction.type} + + + + {transaction.status} + + + + + + + + {transaction.formattedAmount} + + + + ); -} \ No newline at end of file +} diff --git a/apps/website/components/notifications/ModalNotification.tsx b/apps/website/components/notifications/ModalNotification.tsx index 26d7e18c2..7ab78d04d 100644 --- a/apps/website/components/notifications/ModalNotification.tsx +++ b/apps/website/components/notifications/ModalNotification.tsx @@ -21,7 +21,7 @@ import { Zap, X, } from 'lucide-react'; -import Button from '@/components/ui/Button'; +import Button from '@/ui/Button'; interface ModalNotificationProps { notification: Notification; diff --git a/apps/website/components/onboarding/AvatarStep.tsx b/apps/website/components/onboarding/AvatarStep.tsx index 0ccf0bd0d..a67aff935 100644 --- a/apps/website/components/onboarding/AvatarStep.tsx +++ b/apps/website/components/onboarding/AvatarStep.tsx @@ -1,7 +1,7 @@ import { useRef, ChangeEvent } from 'react'; import { Camera, Upload, Loader2, Sparkles, Palette, Check, User } from 'lucide-react'; -import Button from '@/components/ui/Button'; -import Heading from '@/components/ui/Heading'; +import Button from '@/ui/Button'; +import Heading from '@/ui/Heading'; export type RacingSuitColor = | 'red' diff --git a/apps/website/components/onboarding/OnboardingCardAccent.tsx b/apps/website/components/onboarding/OnboardingCardAccent.tsx new file mode 100644 index 000000000..c65b208e1 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingCardAccent.tsx @@ -0,0 +1,5 @@ +export function OnboardingCardAccent() { + return ( +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingContainer.tsx b/apps/website/components/onboarding/OnboardingContainer.tsx new file mode 100644 index 000000000..d3e9a2572 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingContainer.tsx @@ -0,0 +1,11 @@ +interface OnboardingContainerProps { + children: React.ReactNode; +} + +export function OnboardingContainer({ children }: OnboardingContainerProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingError.tsx b/apps/website/components/onboarding/OnboardingError.tsx new file mode 100644 index 000000000..613d56da8 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingError.tsx @@ -0,0 +1,12 @@ +interface OnboardingErrorProps { + message: string; +} + +export function OnboardingError({ message }: OnboardingErrorProps) { + return ( +
+ +

{message}

+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingForm.tsx b/apps/website/components/onboarding/OnboardingForm.tsx new file mode 100644 index 000000000..20bc2890e --- /dev/null +++ b/apps/website/components/onboarding/OnboardingForm.tsx @@ -0,0 +1,12 @@ +interface OnboardingFormProps { + children: React.ReactNode; + onSubmit: (e: React.FormEvent) => void | Promise; +} + +export function OnboardingForm({ children, onSubmit }: OnboardingFormProps) { + return ( +
+ {children} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingHeader.tsx b/apps/website/components/onboarding/OnboardingHeader.tsx new file mode 100644 index 000000000..7ceac3257 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingHeader.tsx @@ -0,0 +1,17 @@ +interface OnboardingHeaderProps { + title: string; + subtitle: string; + emoji: string; +} + +export function OnboardingHeader({ title, subtitle, emoji }: OnboardingHeaderProps) { + return ( +
+
+ {emoji} +
+

{title}

+

{subtitle}

+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingHelpText.tsx b/apps/website/components/onboarding/OnboardingHelpText.tsx new file mode 100644 index 000000000..f58a58e0f --- /dev/null +++ b/apps/website/components/onboarding/OnboardingHelpText.tsx @@ -0,0 +1,7 @@ +export function OnboardingHelpText() { + return ( +

+ Your avatar will be AI-generated based on your photo and chosen suit color +

+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingNavigation.tsx b/apps/website/components/onboarding/OnboardingNavigation.tsx new file mode 100644 index 000000000..b8eafe233 --- /dev/null +++ b/apps/website/components/onboarding/OnboardingNavigation.tsx @@ -0,0 +1,58 @@ +import Button from '@/ui/Button'; + +interface OnboardingNavigationProps { + onBack: () => void; + onNext?: () => void; + isLastStep: boolean; + canSubmit: boolean; + loading: boolean; +} + +export function OnboardingNavigation({ onBack, onNext, isLastStep, canSubmit, loading }: OnboardingNavigationProps) { + return ( +
+ + + {!isLastStep ? ( + + ) : ( + + )} +
+ ); +} \ No newline at end of file diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx index f40fb493c..f503f3b79 100644 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -1,17 +1,17 @@ 'use client'; -import { useState, FormEvent } from 'react'; -import Card from '@/components/ui/Card'; +import { OnboardingCardAccent } from '@/components/onboarding/OnboardingCardAccent'; +import { OnboardingContainer } from '@/components/onboarding/OnboardingContainer'; +import { OnboardingError } from '@/components/onboarding/OnboardingError'; +import { OnboardingForm } from '@/components/onboarding/OnboardingForm'; +import { OnboardingHeader } from '@/components/onboarding/OnboardingHeader'; +import { OnboardingHelpText } from '@/components/onboarding/OnboardingHelpText'; +import { OnboardingNavigation } from '@/components/onboarding/OnboardingNavigation'; +import { PersonalInfo, PersonalInfoStep } from '@/components/onboarding/PersonalInfoStep'; +import Card from '@/ui/Card'; import { StepIndicator } from '@/ui/StepIndicator'; -import { PersonalInfoStep, PersonalInfo } from '@/ui/onboarding/PersonalInfoStep'; -import { AvatarStep, AvatarInfo } from './AvatarStep'; -import { OnboardingHeader } from '@/ui/onboarding/OnboardingHeader'; -import { OnboardingHelpText } from '@/ui/onboarding/OnboardingHelpText'; -import { OnboardingError } from '@/ui/onboarding/OnboardingError'; -import { OnboardingNavigation } from '@/ui/onboarding/OnboardingNavigation'; -import { OnboardingContainer } from '@/ui/onboarding/OnboardingContainer'; -import { OnboardingCardAccent } from '@/ui/onboarding/OnboardingCardAccent'; -import { OnboardingForm } from '@/ui/onboarding/OnboardingForm'; +import { FormEvent, useState } from 'react'; +import { AvatarInfo, AvatarStep } from './AvatarStep'; type OnboardingStep = 1 | 2; diff --git a/apps/website/components/onboarding/PersonalInfoStep.tsx b/apps/website/components/onboarding/PersonalInfoStep.tsx new file mode 100644 index 000000000..4c24a9da4 --- /dev/null +++ b/apps/website/components/onboarding/PersonalInfoStep.tsx @@ -0,0 +1,151 @@ +import { User, Clock, ChevronRight } from 'lucide-react'; +import Input from '@/ui/Input'; +import Heading from '@/ui/Heading'; +import CountrySelect from '@/ui/CountrySelect'; + +export interface PersonalInfo { + firstName: string; + lastName: string; + displayName: string; + country: string; + timezone: string; +} + +interface FormErrors { + [key: string]: string | undefined; +} + +interface PersonalInfoStepProps { + personalInfo: PersonalInfo; + setPersonalInfo: (info: PersonalInfo) => void; + errors: FormErrors; + loading: boolean; +} + +const TIMEZONES = [ + { value: 'America/New_York', label: 'Eastern Time (ET)' }, + { value: 'America/Chicago', label: 'Central Time (CT)' }, + { value: 'America/Denver', label: 'Mountain Time (MT)' }, + { value: 'America/Los_Angeles', label: 'Pacific Time (PT)' }, + { value: 'Europe/London', label: 'Greenwich Mean Time (GMT)' }, + { value: 'Europe/Berlin', label: 'Central European Time (CET)' }, + { value: 'Europe/Paris', label: 'Central European Time (CET)' }, + { value: 'Australia/Sydney', label: 'Australian Eastern Time (AET)' }, + { value: 'Asia/Tokyo', label: 'Japan Standard Time (JST)' }, + { value: 'America/Sao_Paulo', label: 'Brasília Time (BRT)' }, +]; + +export function PersonalInfoStep({ personalInfo, setPersonalInfo, errors, loading }: PersonalInfoStepProps) { + return ( +
+
+ + + Personal Information + +

+ Tell us a bit about yourself +

+
+ +
+
+ + + setPersonalInfo({ ...personalInfo, firstName: e.target.value }) + } + error={!!errors.firstName} + errorMessage={errors.firstName} + placeholder="John" + disabled={loading} + /> +
+ +
+ + + setPersonalInfo({ ...personalInfo, lastName: e.target.value }) + } + error={!!errors.lastName} + errorMessage={errors.lastName} + placeholder="Racer" + disabled={loading} + /> +
+
+ +
+ + + setPersonalInfo({ ...personalInfo, displayName: e.target.value }) + } + error={!!errors.displayName} + errorMessage={errors.displayName} + placeholder="SpeedyRacer42" + disabled={loading} + /> +
+ +
+
+ + + setPersonalInfo({ ...personalInfo, country: value }) + } + error={!!errors.country} + errorMessage={errors.country ?? ''} + disabled={loading} + /> +
+ +
+ +
+ + + +
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/components/profile/AchievementGrid.tsx b/apps/website/components/profile/AchievementGrid.tsx new file mode 100644 index 000000000..d1b8e9eb8 --- /dev/null +++ b/apps/website/components/profile/AchievementGrid.tsx @@ -0,0 +1,83 @@ +'use client'; + +import React from 'react'; +import { Award, Trophy, Medal, Star, Crown, Target, Zap } from 'lucide-react'; +import { Card } from '@/ui/Card'; +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 { Icon } from '@/ui/Icon'; +import { Grid } from '@/ui/Grid'; + +interface Achievement { + id: string; + title: string; + description: string; + icon: string; + rarity: string; + earnedAt: Date; +} + +interface AchievementGridProps { + achievements: Achievement[]; +} + +function getAchievementIcon(icon: string) { + switch (icon) { + case 'trophy': return Trophy; + case 'medal': return Medal; + case 'star': return Star; + case 'crown': return Crown; + case 'target': return Target; + case 'zap': return Zap; + default: return Award; + } +} + +export function AchievementGrid({ achievements }: AchievementGridProps) { + return ( + + + + }> + Achievements + + {achievements.length} earned + + + + {achievements.map((achievement) => { + const AchievementIcon = getAchievementIcon(achievement.icon); + return ( + + + + + + + {achievement.title} + {achievement.description} + + {achievement.earnedAt.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + + + + + ); + })} + + + ); +} diff --git a/apps/website/components/profile/CareerStats.tsx b/apps/website/components/profile/CareerStats.tsx new file mode 100644 index 000000000..8648e9279 --- /dev/null +++ b/apps/website/components/profile/CareerStats.tsx @@ -0,0 +1,46 @@ +'use client'; + +import React from 'react'; +import { TrendingUp } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Grid } from '@/ui/Grid'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; + +interface CareerStatsProps { + stats: { + totalRaces: number; + wins: number; + podiums: number; + consistency: number | null; + }; +} + +export function CareerStats({ stats }: CareerStatsProps) { + return ( + + + }> + Career Statistics + + + + + + + + + + ); +} + +function StatItem({ label, value, color = 'text-white' }: { label: string, value: string | number, color?: string }) { + return ( + + {value} + {label} + + ); +} diff --git a/apps/website/components/profile/DriverSummaryPill.tsx b/apps/website/components/profile/DriverSummaryPill.tsx index 19e5c1b14..cb5d6f1ff 100644 --- a/apps/website/components/profile/DriverSummaryPill.tsx +++ b/apps/website/components/profile/DriverSummaryPill.tsx @@ -5,7 +5,7 @@ import Image from 'next/image'; import Link from 'next/link'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import DriverRating from '@/components/profile/DriverRatingPill'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import PlaceholderImage from '@/ui/PlaceholderImage'; export interface DriverSummaryPillProps { driver: DriverViewModel; diff --git a/apps/website/components/profile/FriendsPreview.tsx b/apps/website/components/profile/FriendsPreview.tsx new file mode 100644 index 000000000..a132a61d0 --- /dev/null +++ b/apps/website/components/profile/FriendsPreview.tsx @@ -0,0 +1,70 @@ +'use client'; + +import React from 'react'; +import { Users } from 'lucide-react'; +import { Card } from '@/ui/Card'; +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 { Image } from '@/ui/Image'; +import { Surface } from '@/ui/Surface'; +import { Icon } from '@/ui/Icon'; +import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; +import { mediaConfig } from '@/lib/config/mediaConfig'; + +interface Friend { + id: string; + name: string; + avatarUrl?: string; + country: string; +} + +interface FriendsPreviewProps { + friends: Friend[]; +} + +export function FriendsPreview({ friends }: FriendsPreviewProps) { + return ( + + + + }> + Friends + + ({friends.length}) + + + + {friends.slice(0, 8).map((friend) => ( + + + + + {friend.name} + + {friend.name} + {CountryFlagDisplay.fromCountryCode(friend.country).toString()} + + + + ))} + {friends.length > 8 && ( + + +{friends.length - 8} more + + )} + + + ); +} diff --git a/apps/website/components/profile/LeagueListItem.tsx b/apps/website/components/profile/LeagueListItem.tsx new file mode 100644 index 000000000..9710a6a92 --- /dev/null +++ b/apps/website/components/profile/LeagueListItem.tsx @@ -0,0 +1,61 @@ +'use client'; + +import React from 'react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Button } from '@/ui/Button'; +import { Surface } from '@/ui/Surface'; + +interface League { + leagueId: string; + name: string; + description: string; + membershipRole?: string; +} + +interface LeagueListItemProps { + league: League; + isAdmin?: boolean; +} + +export function LeagueListItem({ league, isAdmin }: LeagueListItemProps) { + return ( + + + {league.name} + + {league.description} + + {league.membershipRole && ( + + Your role:{' '} + {league.membershipRole} + + )} + + + + View + + {isAdmin && ( + + + + )} + + + ); +} diff --git a/apps/website/components/profile/LiveryCard.tsx b/apps/website/components/profile/LiveryCard.tsx index 10c76a600..5a9a222d2 100644 --- a/apps/website/components/profile/LiveryCard.tsx +++ b/apps/website/components/profile/LiveryCard.tsx @@ -1,5 +1,5 @@ -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; +import Card from '@/ui/Card'; +import Button from '@/ui/Button'; import { Car, Download, Trash2, Edit } from 'lucide-react'; interface DriverLiveryItem { diff --git a/apps/website/components/profile/PerformanceOverview.tsx b/apps/website/components/profile/PerformanceOverview.tsx new file mode 100644 index 000000000..2597191ef --- /dev/null +++ b/apps/website/components/profile/PerformanceOverview.tsx @@ -0,0 +1,113 @@ +'use client'; + +import React from 'react'; +import { Activity, TrendingUp, Target, BarChart3 } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Heading } from '@/ui/Heading'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Grid } from '@/ui/Grid'; +import { GridItem } from '@/ui/GridItem'; +import { Icon } from '@/ui/Icon'; +import { CircularProgress } from '@/components/drivers/CircularProgress'; +import { HorizontalBarChart } from '@/components/drivers/HorizontalBarChart'; + +interface PerformanceOverviewProps { + stats: { + wins: number; + podiums: number; + totalRaces: number; + consistency: number | null; + dnfs: number; + bestFinish: number; + avgFinish: number | null; + }; +} + +export function PerformanceOverview({ stats }: PerformanceOverviewProps) { + return ( + + + }> + Performance Overview + + + + + + + + + + + + + + + + + + + }> + Results Breakdown + + + + + + + + + + + Best Finish + + P{stats.bestFinish} + + + + + + + Avg Finish + + + P{(stats.avgFinish ?? 0).toFixed(1)} + + + + + + + + + ); +} diff --git a/apps/website/components/profile/ProfileBio.tsx b/apps/website/components/profile/ProfileBio.tsx new file mode 100644 index 000000000..71e92f1cc --- /dev/null +++ b/apps/website/components/profile/ProfileBio.tsx @@ -0,0 +1,26 @@ +'use client'; + +import React from 'react'; +import { User } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; +import { Box } from '@/ui/Box'; + +interface ProfileBioProps { + bio: string; +} + +export function ProfileBio({ bio }: ProfileBioProps) { + return ( + + + }> + About + + + {bio} + + ); +} diff --git a/apps/website/components/profile/ProfileHeader.tsx b/apps/website/components/profile/ProfileHeader.tsx index 10a4c5df9..555dce0c4 100644 --- a/apps/website/components/profile/ProfileHeader.tsx +++ b/apps/website/components/profile/ProfileHeader.tsx @@ -4,8 +4,8 @@ import Image from 'next/image'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import Button from '../ui/Button'; import DriverRatingPill from '@/components/profile/DriverRatingPill'; -import CountryFlag from '@/components/ui/CountryFlag'; -import PlaceholderImage from '@/components/ui/PlaceholderImage'; +import CountryFlag from '@/ui/CountryFlag'; +import PlaceholderImage from '@/ui/PlaceholderImage'; interface ProfileHeaderProps { driver: DriverViewModel; diff --git a/apps/website/components/profile/ProfileHero.tsx b/apps/website/components/profile/ProfileHero.tsx new file mode 100644 index 000000000..683e1da53 --- /dev/null +++ b/apps/website/components/profile/ProfileHero.tsx @@ -0,0 +1,172 @@ +'use client'; + +import React from 'react'; +import { Star, Trophy, Globe, Calendar, Clock, UserPlus, ExternalLink, LucideIcon } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Heading } from '@/ui/Heading'; +import { Image } from '@/ui/Image'; +import { Button } from '@/ui/Button'; +import { Link } from '@/ui/Link'; +import { Surface } from '@/ui/Surface'; +import { mediaConfig } from '@/lib/config/mediaConfig'; +import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; + +interface ProfileHeroProps { + driver: { + name: string; + avatarUrl?: string; + country: string; + iracingId: number; + joinedAt: string | Date; + }; + stats: { + rating: number; + } | null; + globalRank: number; + timezone: string; + socialHandles: { + platform: string; + handle: string; + url: string; + }[]; + onAddFriend: () => void; + friendRequestSent: boolean; +} + +function getSocialIcon(platform: string) { + const { Twitter, Youtube, Twitch, MessageCircle } = require('lucide-react'); + switch (platform) { + case 'twitter': return Twitter; + case 'youtube': return Youtube; + case 'twitch': return Twitch; + case 'discord': return MessageCircle; + default: return Globe; + } +} + +export function ProfileHero({ + driver, + stats, + globalRank, + timezone, + socialHandles, + onAddFriend, + friendRequestSent, +}: ProfileHeroProps) { + return ( + + + {/* Avatar */} + + + + {driver.name} + + + + + {/* Driver Info */} + + + {driver.name} + + {CountryFlagDisplay.fromCountryCode(driver.country).toString()} + + + + {/* Rating and Rank */} + + {stats && ( + <> + + + + {stats.rating} + Rating + + + + + + #{globalRank} + Global + + + + )} + + + {/* Meta info */} + + + + iRacing: {driver.iracingId} + + + + + Joined{' '} + {new Date(driver.joinedAt).toLocaleDateString('en-US', { + month: 'short', + year: 'numeric', + })} + + + + + {timezone} + + + + + {/* Action Buttons */} + + + + + + {/* Social Handles */} + {socialHandles.length > 0 && ( + + + Connect: + {socialHandles.map((social) => { + const Icon = getSocialIcon(social.platform); + return ( + + + + + {social.handle} + + + + + ); + })} + + + )} + + ); +} diff --git a/apps/website/components/profile/ProfileStatGrid.tsx b/apps/website/components/profile/ProfileStatGrid.tsx new file mode 100644 index 000000000..4cb859305 --- /dev/null +++ b/apps/website/components/profile/ProfileStatGrid.tsx @@ -0,0 +1,29 @@ +'use client'; + +import React from 'react'; +import { Grid } from '@/ui/Grid'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; + +interface Stat { + label: string; + value: string | number; + color?: string; +} + +interface ProfileStatGridProps { + stats: Stat[]; +} + +export function ProfileStatGrid({ stats }: ProfileStatGridProps) { + return ( + + {stats.map((stat, idx) => ( + + {stat.value} + {stat.label} + + ))} + + ); +} diff --git a/apps/website/components/profile/ProfileTabs.tsx b/apps/website/components/profile/ProfileTabs.tsx new file mode 100644 index 000000000..a420d2297 --- /dev/null +++ b/apps/website/components/profile/ProfileTabs.tsx @@ -0,0 +1,40 @@ +'use client'; + +import React from 'react'; +import { User, BarChart3 } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +type ProfileTab = 'overview' | 'stats'; + +interface ProfileTabsProps { + activeTab: ProfileTab; + onTabChange: (tab: ProfileTab) => void; +} + +export function ProfileTabs({ activeTab, onTabChange }: ProfileTabsProps) { + return ( + + + + + + + ); +} diff --git a/apps/website/components/profile/RacingProfile.tsx b/apps/website/components/profile/RacingProfile.tsx new file mode 100644 index 000000000..d80971bed --- /dev/null +++ b/apps/website/components/profile/RacingProfile.tsx @@ -0,0 +1,79 @@ +'use client'; + +import React from 'react'; +import { Flag, Users, UserPlus } from 'lucide-react'; +import { Card } from '@/ui/Card'; +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 { Icon } from '@/ui/Icon'; + +interface RacingProfileProps { + racingStyle: string; + favoriteTrack: string; + favoriteCar: string; + availableHours: string; + lookingForTeam: boolean; + openToRequests: boolean; +} + +export function RacingProfile({ + racingStyle, + favoriteTrack, + favoriteCar, + availableHours, + lookingForTeam, + openToRequests, +}: RacingProfileProps) { + return ( + + + }> + Racing Profile + + + + + Racing Style + {racingStyle} + + + Favorite Track + {favoriteTrack} + + + Favorite Car + {favoriteCar} + + + Available + {availableHours} + + + {/* Status badges */} + + + {lookingForTeam && ( + + + + Looking for Team + + + )} + {openToRequests && ( + + + + Open to Friend Requests + + + )} + + + + + ); +} diff --git a/apps/website/components/profile/TeamMembershipGrid.tsx b/apps/website/components/profile/TeamMembershipGrid.tsx new file mode 100644 index 000000000..6b3476598 --- /dev/null +++ b/apps/website/components/profile/TeamMembershipGrid.tsx @@ -0,0 +1,63 @@ +'use client'; + +import React from 'react'; +import { Shield, Users, ChevronRight } from 'lucide-react'; +import { Card } from '@/ui/Card'; +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'; + +interface TeamMembership { + team: { + id: string; + name: string; + }; + role: string; + joinedAt: Date; +} + +interface TeamMembershipGridProps { + memberships: TeamMembership[]; +} + +export function TeamMembershipGrid({ memberships }: TeamMembershipGridProps) { + return ( + + + }> + Team Memberships + ({memberships.length}) + + + + {memberships.map((membership) => ( + + + + + + + + {membership.team.name} + + + {membership.role} + + Since {membership.joinedAt.toLocaleDateString('en-US', { month: 'short', year: 'numeric' })} + + + + + + + ))} + + + ); +} diff --git a/apps/website/components/profile/UserPill.test.tsx b/apps/website/components/profile/UserPill.test.tsx deleted file mode 100644 index eadd2f480..000000000 --- a/apps/website/components/profile/UserPill.test.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import React from 'react'; -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import UserPill from './UserPill'; - -// Mock useAuth to control session state -vi.mock('@/lib/auth/AuthContext', () => { - return { - useAuth: () => mockedAuthValue, - }; -}); - -// Mock effective driver id hook -vi.mock('@/hooks/useEffectiveDriverId', () => { - return { - useEffectiveDriverId: () => mockedDriverId, - }; -}); - -// Mock the new DI hooks -const mockFindById = vi.fn(); -let mockDriverData: any = null; - -vi.mock('@/hooks/driver/useFindDriverById', () => ({ - useFindDriverById: (driverId: string) => { - return { - data: mockDriverData, - isLoading: false, - isError: false, - isSuccess: !!mockDriverData, - refetch: vi.fn(), - }; - }, -})); - -interface MockSessionUser { - id: string; -} - -interface MockSession { - user: MockSessionUser | null; -} - -let mockedAuthValue: { session: MockSession | null } = { session: null }; -let mockedDriverId: string | null = null; - -// Provide global stats helpers used by UserPill's rating/rank computation -// They are UI-level helpers, so a minimal stub is sufficient for these tests. -(globalThis as any).getDriverStats = (driverId: string) => ({ - driverId, - rating: 2000, - overallRank: 10, - wins: 5, -}); - -(globalThis as any).getAllDriverRankings = () => [ - { driverId: 'driver-1', rating: 2100 }, - { driverId: 'driver-2', rating: 2000 }, -]; - -describe('UserPill', () => { - beforeEach(() => { - mockedAuthValue = { session: null }; - mockedDriverId = null; - mockDriverData = null; - mockFindById.mockReset(); - }); - - it('renders auth links when there is no session', () => { - mockedAuthValue = { session: null }; - - const { container } = render(); - - expect(screen.getByText('Sign In')).toBeInTheDocument(); - expect(screen.getByText('Get Started')).toBeInTheDocument(); - expect(mockFindById).not.toHaveBeenCalled(); - expect(container).toMatchSnapshot(); - }); - - it('does not load driver when there is no primary driver id', async () => { - mockedAuthValue = { session: { user: { id: 'user-1' } } }; - mockedDriverId = null; - - const { container } = render(); - - // Component should still render user pill with session user info - await waitFor(() => { - expect(screen.getByText('User')).toBeInTheDocument(); - }); - - expect(mockFindById).not.toHaveBeenCalled(); - }); - - it('loads driver via driverService and uses driver avatarUrl', async () => { - const driver = { - id: 'driver-1', - iracingId: 'ir-123', - name: 'Test Driver', - country: 'DE', - joinedAt: '2023-01-01', - avatarUrl: '/api/media/avatar/driver-1', - }; - - mockedAuthValue = { session: { user: { id: 'user-1' } } }; - mockedDriverId = driver.id; - - // Set the mock data that the hook will return - mockDriverData = driver; - - render(); - - await waitFor(() => { - expect(screen.getByText('Test Driver')).toBeInTheDocument(); - }); - - expect(mockFindById).not.toHaveBeenCalled(); // Hook is mocked, not called directly - }); -}); diff --git a/apps/website/components/profile/__snapshots__/UserPill.test.tsx.snap b/apps/website/components/profile/__snapshots__/UserPill.test.tsx.snap deleted file mode 100644 index 8426787cb..000000000 --- a/apps/website/components/profile/__snapshots__/UserPill.test.tsx.snap +++ /dev/null @@ -1,22 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`UserPill > renders auth links when there is no session 1`] = ` - -`; diff --git a/apps/website/components/races/FileProtestModal.tsx b/apps/website/components/races/FileProtestModal.tsx index 76086d286..9803da588 100644 --- a/apps/website/components/races/FileProtestModal.tsx +++ b/apps/website/components/races/FileProtestModal.tsx @@ -1,8 +1,8 @@ 'use client'; import { useState } from 'react'; -import Modal from '@/components/ui/Modal'; -import Button from '@/components/ui/Button'; +import Modal from '@/ui/Modal'; +import Button from '@/ui/Button'; import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO'; import { useFileProtest } from "@/lib/hooks/race/useFileProtest"; import { diff --git a/apps/website/components/races/ImportResultsForm.tsx b/apps/website/components/races/ImportResultsForm.tsx index 3c2caacb3..4b329fdb1 100644 --- a/apps/website/components/races/ImportResultsForm.tsx +++ b/apps/website/components/races/ImportResultsForm.tsx @@ -4,11 +4,10 @@ import { useState } from 'react'; import Button from '../ui/Button'; import { useInject } from '@/lib/di/hooks/useInject'; import { RACE_RESULTS_SERVICE_TOKEN } from '@/lib/di/tokens'; -import type { ImportResultRowDTO } from '@/lib/services/races/RaceResultsService'; interface ImportResultsFormProps { raceId: string; - onSuccess: (results: ImportResultRowDTO[]) => void; + onSuccess: (results: any[]) => void; onError: (error: string) => void; } @@ -26,7 +25,7 @@ export default function ImportResultsForm({ raceId, onSuccess, onError }: Import try { const content = await file.text(); - const results = raceResultsService.parseAndTransformCSV(content, raceId); + const results = (raceResultsService as any).parseAndTransformCSV(content, raceId); onSuccess(results); } catch (err) { const errorMessage = diff --git a/apps/website/components/races/LatestResultsSidebar.tsx b/apps/website/components/races/LatestResultsSidebar.tsx index cb2552cc0..5beb71426 100644 --- a/apps/website/components/races/LatestResultsSidebar.tsx +++ b/apps/website/components/races/LatestResultsSidebar.tsx @@ -1,4 +1,4 @@ -import Card from '@/components/ui/Card'; +import Card from '@/ui/Card'; type RaceWithResults = { raceId: string; diff --git a/apps/website/components/races/LiveRacesBanner.tsx b/apps/website/components/races/LiveRacesBanner.tsx new file mode 100644 index 000000000..b61d8029a --- /dev/null +++ b/apps/website/components/races/LiveRacesBanner.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import { PlayCircle, ChevronRight } from 'lucide-react'; +import { Box } from '@/ui/Box'; +import { Heading } from '@/ui/Heading'; +import { Text } from '@/ui/Text'; +import { Stack } from '@/ui/Stack'; +import type { RaceViewData } from '@/lib/view-data/RacesViewData'; + +interface LiveRacesBannerProps { + liveRaces: RaceViewData[]; + onRaceClick: (raceId: string) => void; +} + +export function LiveRacesBanner({ liveRaces, onRaceClick }: LiveRacesBannerProps) { + if (liveRaces.length === 0) return null; + + return ( + + + + + + + + LIVE NOW + + + + + {liveRaces.map((race) => ( + onRaceClick(race.id)} + style={{ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '1rem', + backgroundColor: 'rgba(15, 17, 21, 0.8)', + borderRadius: '0.5rem', + border: '1px solid rgba(16, 185, 129, 0.2)', + cursor: 'pointer' + }} + > + + + + + + {race.track} + {race.leagueName} + + + + + ))} + + + + ); +} diff --git a/apps/website/components/races/ProtestCard.tsx b/apps/website/components/races/ProtestCard.tsx new file mode 100644 index 000000000..36c2b0431 --- /dev/null +++ b/apps/website/components/races/ProtestCard.tsx @@ -0,0 +1,124 @@ +'use client'; + +import React from 'react'; +import { AlertCircle, AlertTriangle, Video } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Box } from '@/ui/Box'; +import { Stack } from '@/ui/Stack'; +import { Text } from '@/ui/Text'; +import { Link } from '@/ui/Link'; +import { Button } from '@/ui/Button'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; + +interface Protest { + id: string; + status: string; + protestingDriverId: string; + accusedDriverId: string; + filedAt: string; + incident: { + lap: number; + description: string; + }; + proofVideoUrl?: string; + decisionNotes?: string; +} + +interface Driver { + id: string; + name: string; +} + +interface ProtestCardProps { + protest: Protest; + protester?: Driver; + accused?: Driver; + isAdmin: boolean; + onReview: (id: string) => void; + formatDate: (date: string) => string; +} + +export function ProtestCard({ protest, protester, accused, isAdmin, onReview, formatDate }: ProtestCardProps) { + const daysSinceFiled = Math.floor( + (Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24) + ); + const isUrgent = daysSinceFiled > 2 && protest.status === 'pending'; + + const getStatusBadge = (status: string) => { + const variants: Record = { + pending: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' }, + under_review: { bg: 'rgba(245, 158, 11, 0.2)', text: '#f59e0b', label: 'Pending' }, + upheld: { bg: 'rgba(239, 68, 68, 0.2)', text: '#ef4444', label: 'Upheld' }, + dismissed: { bg: 'rgba(115, 115, 115, 0.2)', text: '#9ca3af', label: 'Dismissed' }, + withdrawn: { bg: 'rgba(59, 130, 246, 0.2)', text: '#3b82f6', label: 'Withdrawn' }, + }; + const config = variants[status] || variants.pending; + return ( + + {config.label} + + ); + }; + + return ( + + + + + + + {protester?.name || 'Unknown'} + + vs + + {accused?.name || 'Unknown'} + + {getStatusBadge(protest.status)} + {isUrgent && ( + + + + {daysSinceFiled}d old + + + )} + + + Lap {protest.incident.lap} + + Filed {formatDate(protest.filedAt)} + {protest.proofVideoUrl && ( + <> + + + + + Video Evidence + + + + )} + + {protest.incident.description} + + {protest.decisionNotes && ( + + Steward Decision + {protest.decisionNotes} + + )} + + {isAdmin && protest.status === 'pending' && ( + + )} + + + ); +} diff --git a/apps/website/components/races/RaceCard.tsx b/apps/website/components/races/RaceCard.tsx index 94ffb9504..8270108dd 100644 --- a/apps/website/components/races/RaceCard.tsx +++ b/apps/website/components/races/RaceCard.tsx @@ -1,6 +1,5 @@ import Link from 'next/link'; import { ChevronRight, Car, Zap, Trophy, ArrowRight } from 'lucide-react'; -import { formatTime, getRelativeTime } from '@/lib/utilities/time'; import { raceStatusConfig } from '@/lib/utilities/raceStatus'; interface RaceCardProps { @@ -19,6 +18,7 @@ interface RaceCardProps { } export function RaceCard({ race, onClick, className }: RaceCardProps) { + const scheduledAtDate = new Date(race.scheduledAt); const config = raceStatusConfig[race.status as keyof typeof raceStatusConfig] || { border: 'border-charcoal-outline', bg: 'bg-charcoal-outline', @@ -41,12 +41,12 @@ export function RaceCard({ race, onClick, className }: RaceCardProps) { {/* Time Column */}

- {formatTime(race.scheduledAt)} + {scheduledAtDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}

{race.status === 'running' ? 'LIVE' - : getRelativeTime(race.scheduledAt)} + : scheduledAtDate.toLocaleDateString()}

diff --git a/apps/website/components/races/RaceDetailCard.tsx b/apps/website/components/races/RaceDetailCard.tsx new file mode 100644 index 000000000..85cb63321 --- /dev/null +++ b/apps/website/components/races/RaceDetailCard.tsx @@ -0,0 +1,44 @@ +'use client'; + +import React from 'react'; +import { Flag } from 'lucide-react'; +import { Card } from '@/ui/Card'; +import { Stack } from '@/ui/Stack'; +import { Heading } from '@/ui/Heading'; +import { Grid } from '@/ui/Grid'; +import { Box } from '@/ui/Box'; +import { Text } from '@/ui/Text'; +import { Icon } from '@/ui/Icon'; + +interface RaceDetailCardProps { + track: string; + car: string; + sessionType: string; + statusLabel: string; + statusColor: string; +} + +export function RaceDetailCard({ track, car, sessionType, statusLabel, statusColor }: RaceDetailCardProps) { + return ( + + + }>Race Details + + + + + + + + + ); +} + +function DetailItem({ label, value, capitalize, color }: { label: string, value: string | number, capitalize?: boolean, color?: string }) { + return ( + + {label} + {value} + + ); +} diff --git a/apps/website/components/races/RaceEntryList.tsx b/apps/website/components/races/RaceEntryList.tsx new file mode 100644 index 000000000..0f5e9fd92 --- /dev/null +++ b/apps/website/components/races/RaceEntryList.tsx @@ -0,0 +1,88 @@ +'use client'; + +import React from 'react'; +import { Users, Zap } from 'lucide-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'; +import { Image } from '@/ui/Image'; +import { Badge } from '@/ui/Badge'; +import { Icon } from '@/ui/Icon'; +import { Surface } from '@/ui/Surface'; +import { CountryFlagDisplay } from '@/lib/display-objects/CountryFlagDisplay'; + +interface Entry { + id: string; + name: string; + avatarUrl: string; + country: string; + rating?: number | null; + isCurrentUser: boolean; +} + +interface RaceEntryListProps { + entries: Entry[]; + onDriverClick: (driverId: string) => void; +} + +export function RaceEntryList({ entries, onDriverClick }: RaceEntryListProps) { + return ( + + + + }>Entry List + {entries.length} drivers + + + {entries.length === 0 ? ( + + + + + No drivers registered yet + + ) : ( + + {entries.map((driver, index) => ( + onDriverClick(driver.id)} + style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '0.75rem', borderRadius: '0.75rem', cursor: 'pointer', transition: 'all 0.2s', backgroundColor: driver.isCurrentUser ? 'rgba(59, 130, 246, 0.1)' : 'transparent', border: driver.isCurrentUser ? '1px solid rgba(59, 130, 246, 0.3)' : '1px solid transparent' }} + > + + {index + 1} + + + + + {driver.name} + + + {CountryFlagDisplay.fromCountryCode(driver.country).toString()} + + + + + + {driver.name} + {driver.isCurrentUser && You} + + {driver.country} + + + {driver.rating != null && ( + + + {driver.rating} + + )} + + ))} + + )} + + + ); +} diff --git a/apps/website/components/races/RaceFilterBar.tsx b/apps/website/components/races/RaceFilterBar.tsx new file mode 100644 index 000000000..fdf77ab10 --- /dev/null +++ b/apps/website/components/races/RaceFilterBar.tsx @@ -0,0 +1,76 @@ +import React from 'react'; +import { Card } from '@/ui/Card'; +import { Button } from '@/ui/Button'; +import { Stack } from '@/ui/Stack'; +import { Select } from '@/ui/Select'; +import { Box } from '@/ui/Box'; +import type { TimeFilter } from '@/templates/RacesTemplate'; + +interface RaceFilterBarProps { + timeFilter: TimeFilter; + setTimeFilter: (filter: TimeFilter) => void; + leagueFilter: string; + setLeagueFilter: (filter: string) => void; + leagues: Array<{ id: string; name: string }>; + onShowMoreFilters: () => void; +} + +export function RaceFilterBar({ + timeFilter, + setTimeFilter, + leagueFilter, + setLeagueFilter, + leagues, + onShowMoreFilters, +}: RaceFilterBarProps) { + const leagueOptions = [ + { value: 'all', label: 'All Leagues' }, + ...leagues.map(l => ({ value: l.id, label: l.name })) + ]; + + return ( + + + {/* Time Filter Tabs */} + + {(['upcoming', 'live', 'past', 'all'] as TimeFilter[]).map(filter => ( + + ))} + + + {/* League Filter */} + + setSearch(e.target.value)} - placeholder="Search countries..." - className="w-full rounded-md border-0 px-4 py-2 pl-9 bg-deep-graphite text-white text-sm placeholder:text-gray-500 focus:outline-none focus:ring-1 focus:ring-primary-blue" - /> -
- - - {/* Country List */} -
- {filteredCountries.length > 0 ? ( - filteredCountries.map((country) => ( - - )) - ) : ( -
- No countries found -
- )} -
- - )} - - {/* Error Message */} - {error && errorMessage && ( -

{errorMessage}

- )} - - ); -} \ No newline at end of file diff --git a/apps/website/components/ui/DurationField.tsx b/apps/website/components/ui/DurationField.tsx deleted file mode 100644 index 3d37658c4..000000000 --- a/apps/website/components/ui/DurationField.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import Input from '@/components/ui/Input'; - -interface DurationFieldProps { - label: string; - value: number | ''; - onChange: (value: number | '') => void; - helperText?: string; - required?: boolean; - disabled?: boolean; - unit?: 'minutes' | 'laps'; - error?: string; -} - -export default function DurationField({ - label, - value, - onChange, - helperText, - required, - disabled, - unit = 'minutes', - error, -}: DurationFieldProps) { - const handleChange = (raw: string) => { - if (raw.trim() === '') { - onChange(''); - return; - } - - const parsed = parseInt(raw, 10); - if (Number.isNaN(parsed) || parsed <= 0) { - onChange(''); - return; - } - - onChange(parsed); - }; - - const unitLabel = unit === 'laps' ? 'laps' : 'min'; - - return ( -
- -
-
- handleChange(e.target.value)} - disabled={disabled} - min={1} - className="pr-16" - error={!!error} - /> -
- {unitLabel} -
- {helperText && ( -

{helperText}

- )} - {error && ( -

{error}

- )} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/ErrorBanner.tsx b/apps/website/components/ui/ErrorBanner.tsx deleted file mode 100644 index ae7998616..000000000 --- a/apps/website/components/ui/ErrorBanner.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * ErrorBanner - UI component for displaying error messages - * - * Pure UI element for error display in templates and components. - * No business logic, just presentation. - */ - -export interface ErrorBannerProps { - message: string; - title?: string; - variant?: 'error' | 'warning' | 'info'; -} - -export function ErrorBanner({ message, title, variant = 'error' }: ErrorBannerProps) { - const baseClasses = 'px-4 py-3 rounded-lg border flex items-start gap-3'; - - const variantClasses = { - error: 'bg-racing-red/10 border-racing-red text-racing-red', - warning: 'bg-yellow-500/10 border-yellow-500 text-yellow-300', - info: 'bg-primary-blue/10 border-primary-blue text-primary-blue', - }; - - return ( -
-
- {title &&
{title}
} -
{message}
-
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/FormField.tsx b/apps/website/components/ui/FormField.tsx deleted file mode 100644 index d9cdc5804..000000000 --- a/apps/website/components/ui/FormField.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import React from 'react'; - -interface FormFieldProps { - label: string; - icon?: React.ElementType; - children: React.ReactNode; - required?: boolean; - error?: string; - hint?: string; -} - -/** - * Form field wrapper with label, optional icon, required indicator, and error/hint display. - * Used for consistent form field layout throughout the app. - */ -export default function FormField({ - label, - icon: Icon, - children, - required = false, - error, - hint, -}: FormFieldProps) { - return ( -
- - {children} - {error && ( -

{error}

- )} - {hint && !error && ( -

{hint}

- )} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/Heading.tsx b/apps/website/components/ui/Heading.tsx deleted file mode 100644 index 173c45b28..000000000 --- a/apps/website/components/ui/Heading.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import React, { ReactNode } from 'react'; - -interface HeadingProps { - level: 1 | 2 | 3; - children: ReactNode; - className?: string; - style?: React.CSSProperties; -} - -export default function Heading({ level, children, className = '', style }: HeadingProps) { - const baseStyles = 'font-bold tracking-tight'; - - const levelStyles = { - 1: 'text-4xl sm:text-6xl', - 2: 'text-3xl sm:text-4xl', - 3: 'text-xl sm:text-2xl' - }; - - const Tag = `h${level}` as keyof React.JSX.IntrinsicElements; - - return ( - - {children} - - ); -} \ No newline at end of file diff --git a/apps/website/components/ui/InfoBanner.tsx b/apps/website/components/ui/InfoBanner.tsx deleted file mode 100644 index ceccce1a0..000000000 --- a/apps/website/components/ui/InfoBanner.tsx +++ /dev/null @@ -1,71 +0,0 @@ -'use client'; - -import React from 'react'; -import { Info, AlertTriangle, CheckCircle, XCircle } from 'lucide-react'; - -type BannerType = 'info' | 'warning' | 'success' | 'error'; - -interface InfoBannerProps { - type?: BannerType; - title?: string; - children: React.ReactNode; - icon?: React.ElementType; -} - -const bannerConfig: Record = { - info: { - icon: Info, - bg: 'bg-iron-gray/30', - border: 'border-charcoal-outline/50', - titleColor: 'text-gray-300', - }, - warning: { - icon: AlertTriangle, - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', - titleColor: 'text-warning-amber', - }, - success: { - icon: CheckCircle, - bg: 'bg-performance-green/10', - border: 'border-performance-green/30', - titleColor: 'text-performance-green', - }, - error: { - icon: XCircle, - bg: 'bg-racing-red/10', - border: 'border-racing-red/30', - titleColor: 'text-racing-red', - }, -}; - -/** - * Info banner component for displaying contextual information, warnings, or notices. - * Used throughout the app for important messages and helper text. - */ -export default function InfoBanner({ - type = 'info', - title, - children, - icon: CustomIcon, -}: InfoBannerProps) { - const config = bannerConfig[type]; - const Icon = CustomIcon || config.icon; - - return ( -
- -
- {title && ( -

{title}

- )} - {children} -
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/Input.tsx b/apps/website/components/ui/Input.tsx deleted file mode 100644 index a055caf01..000000000 --- a/apps/website/components/ui/Input.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { InputHTMLAttributes, ReactNode } from 'react'; - -interface InputProps extends InputHTMLAttributes { - error?: boolean; - errorMessage?: string | undefined; -} - -export default function Input({ - error = false, - errorMessage, - className = '', - ...props -}: InputProps) { - const baseStyles = 'block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6'; - const errorStyles = error ? 'ring-warning-amber' : 'ring-charcoal-outline'; - - return ( -
- - {error && errorMessage && ( -

- {errorMessage} -

- )} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/MockupStack.tsx b/apps/website/components/ui/MockupStack.tsx deleted file mode 100644 index 5523627c1..000000000 --- a/apps/website/components/ui/MockupStack.tsx +++ /dev/null @@ -1,146 +0,0 @@ -'use client'; - -import { motion, useReducedMotion } from 'framer-motion'; -import { ReactNode, useEffect, useState } from 'react'; - -interface MockupStackProps { - children: ReactNode; - index?: number; -} - -export default function MockupStack({ children, index = 0 }: MockupStackProps) { - const shouldReduceMotion = useReducedMotion(); - const [isMounted, setIsMounted] = useState(false); - const [isMobile, setIsMobile] = useState(true); // Default to mobile (no animations) - - useEffect(() => { - setIsMounted(true); - const checkMobile = () => setIsMobile(window.innerWidth < 768); - checkMobile(); - window.addEventListener('resize', checkMobile); - return () => window.removeEventListener('resize', checkMobile); - }, []); - - const seed = index * 1337; - const rotation1 = ((seed * 17) % 80 - 40) / 20; - const rotation2 = ((seed * 23) % 80 - 40) / 20; - - // On mobile or before mount, render without animations - if (!isMounted || isMobile) { - return ( -
-
- -
- -
- {children} -
-
- ); - } - - // Desktop: render with animations - return ( -
- - - - - - - {children} - -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/Modal.tsx b/apps/website/components/ui/Modal.tsx deleted file mode 100644 index 9ac9e7e9b..000000000 --- a/apps/website/components/ui/Modal.tsx +++ /dev/null @@ -1,186 +0,0 @@ -'use client'; - -import { - useEffect, - useRef, - type ReactNode, - type KeyboardEvent as ReactKeyboardEvent, -} from 'react'; - -interface ModalProps { - title: string; - description?: string; - children?: ReactNode; - primaryActionLabel?: string; - secondaryActionLabel?: string; - onPrimaryAction?: () => void | Promise; - onSecondaryAction?: () => void; - onOpenChange?: (open: boolean) => void; - isOpen: boolean; -} - -/** - * Generic, accessible modal component with backdrop, focus management, and semantic structure. - * Controlled via the `isOpen` prop; callers handle URL state and routing. - */ -export default function Modal({ - title, - description, - children, - primaryActionLabel, - secondaryActionLabel, - onPrimaryAction, - onSecondaryAction, - onOpenChange, - isOpen, -}: ModalProps) { - const dialogRef = useRef(null); - const previouslyFocusedElementRef = useRef(null); - - // When the modal opens, remember previous focus and move focus into the dialog - useEffect(() => { - if (isOpen) { - previouslyFocusedElementRef.current = document.activeElement; - const focusable = getFirstFocusable(dialogRef.current); - if (focusable) { - focusable.focus(); - } else if (dialogRef.current) { - dialogRef.current.focus(); - } - return; - } - - // When closing, restore focus - if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) { - previouslyFocusedElementRef.current.focus(); - } - }, [isOpen]); - - // Basic focus trap with keyboard handling (Tab / Shift+Tab, Escape) - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.key === 'Escape') { - if (onOpenChange) { - onOpenChange(false); - } - return; - } - - if (event.key === 'Tab') { - const focusable = getFocusableElements(dialogRef.current); - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1] ?? first; - - if (!first || !last) { - return; - } - - if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } else if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } - } - }; - - const handleBackdropClick = (event: React.MouseEvent) => { - if (event.target === event.currentTarget && onOpenChange) { - onOpenChange(false); - } - }; - - if (!isOpen) { - return null; - } - - return ( -
-
-
- - {description && ( - - )} -
- -
{children}
- - {(primaryActionLabel || secondaryActionLabel) && ( -
- {secondaryActionLabel && ( - - )} - {primaryActionLabel && ( - - )} -
- )} -
-
- ); -} - -function getFocusableElements(root: HTMLElement | null): HTMLElement[] { - if (!root) return []; - const selectors = [ - 'a[href]', - 'button:not([disabled])', - 'textarea:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - '[tabindex]:not([tabindex="-1"])', - ]; - const nodes = Array.from( - root.querySelectorAll(selectors.join(',')), - ); - return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')); -} - -function getFirstFocusable(root: HTMLElement | null): HTMLElement | null { - const elements = getFocusableElements(root); - return elements[0] ?? null; -} \ No newline at end of file diff --git a/apps/website/components/ui/PageHeader.tsx b/apps/website/components/ui/PageHeader.tsx deleted file mode 100644 index 0297c4205..000000000 --- a/apps/website/components/ui/PageHeader.tsx +++ /dev/null @@ -1,44 +0,0 @@ -'use client'; - -import React from 'react'; - -interface PageHeaderProps { - icon: React.ElementType; - title: string; - description?: string; - action?: React.ReactNode; - iconGradient?: string; - iconBorder?: string; -} - -/** - * Page header component with icon, title, description, and optional action. - * Used at the top of pages for consistent page titling. - */ -export default function PageHeader({ - icon: Icon, - title, - description, - action, - iconGradient = 'from-iron-gray to-deep-graphite', - iconBorder = 'border-charcoal-outline', -}: PageHeaderProps) { - return ( -
-
-
-

-
- -
- {title} -

- {description && ( -

{description}

- )} -
- {action} -
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/PlaceholderImage.tsx b/apps/website/components/ui/PlaceholderImage.tsx deleted file mode 100644 index 1b0f5cac3..000000000 --- a/apps/website/components/ui/PlaceholderImage.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { User } from 'lucide-react'; - -export interface PlaceholderImageProps { - size?: number; - className?: string; -} - -/** - * Shared placeholder image component for when no avatar/logo URL is available - */ -export default function PlaceholderImage({ size = 48, className = '' }: PlaceholderImageProps) { - return ( -
- -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/PresetCard.tsx b/apps/website/components/ui/PresetCard.tsx deleted file mode 100644 index 251d16fd7..000000000 --- a/apps/website/components/ui/PresetCard.tsx +++ /dev/null @@ -1,122 +0,0 @@ -'use client'; - -import type { MouseEventHandler, ReactNode } from 'react'; -import Card from './Card'; - -interface PresetCardStat { - label: string; - value: string; -} - -export interface PresetCardProps { - title: string; - subtitle?: string; - primaryTag?: string; - description?: string; - stats?: PresetCardStat[]; - selected?: boolean; - disabled?: boolean; - onSelect?: () => void; - className?: string; - children?: ReactNode; -} - -export default function PresetCard({ - title, - subtitle, - primaryTag, - description, - stats, - selected, - disabled, - onSelect, - className = '', - children, -}: PresetCardProps) { - const isInteractive = typeof onSelect === 'function' && !disabled; - - const handleClick: MouseEventHandler = (event) => { - if (!isInteractive) { - return; - } - event.preventDefault(); - onSelect?.(); - }; - - const baseBorder = selected ? 'border-primary-blue' : 'border-charcoal-outline'; - const baseBg = selected ? 'bg-primary-blue/10' : 'bg-iron-gray'; - const baseRing = selected ? 'ring-2 ring-primary-blue/40' : ''; - const disabledClasses = disabled ? 'opacity-60 cursor-not-allowed' : ''; - const hoverClasses = isInteractive && !disabled ? 'hover:bg-iron-gray/80 hover:scale-[1.01]' : ''; - - const content = ( -
-
-
-
{title}
- {subtitle && ( -
{subtitle}
- )} -
-
- {primaryTag && ( - - {primaryTag} - - )} - {selected && ( - - - Selected - - )} -
-
- - {description && ( -

{description}

- )} - - {children} - - {stats && stats.length > 0 && ( -
-
- {stats.map((stat) => ( -
-
{stat.label}
-
{stat.value}
-
- ))} -
-
- )} -
- ); - - const commonClasses = `${baseBorder} ${baseBg} ${baseRing} ${hoverClasses} ${disabledClasses} ${className}`; - - if (isInteractive) { - return ( - - ); - } - - return ( - } - > - {content} - - ); -} \ No newline at end of file diff --git a/apps/website/components/ui/RangeField.tsx b/apps/website/components/ui/RangeField.tsx deleted file mode 100644 index 1332ff497..000000000 --- a/apps/website/components/ui/RangeField.tsx +++ /dev/null @@ -1,271 +0,0 @@ -'use client'; - -import React, { useCallback, useRef, useState, useEffect } from 'react'; - -interface RangeFieldProps { - label: string; - value: number; - min: number; - max: number; - step?: number; - onChange: (value: number) => void; - helperText?: string; - error?: string | undefined; - disabled?: boolean; - unitLabel?: string; - rangeHint?: string; - /** Show large value display above slider */ - showLargeValue?: boolean; - /** Compact mode - single line */ - compact?: boolean; -} - -export default function RangeField({ - label, - value, - min, - max, - step = 1, - onChange, - helperText, - error, - disabled, - unitLabel = 'min', - rangeHint, - showLargeValue = false, - compact = false, -}: RangeFieldProps) { - const [localValue, setLocalValue] = useState(value); - const [isDragging, setIsDragging] = useState(false); - const sliderRef = useRef(null); - const inputRef = useRef(null); - - // Sync local value with prop when not dragging - useEffect(() => { - if (!isDragging) { - setLocalValue(value); - } - }, [value, isDragging]); - - const clampedValue = Number.isFinite(localValue) - ? Math.min(Math.max(localValue, min), max) - : min; - - const rangePercent = ((clampedValue - min) / Math.max(max - min, 1)) * 100; - - const effectiveRangeHint = - rangeHint ?? (min === 0 ? `Up to ${max} ${unitLabel}` : `${min}–${max} ${unitLabel}`); - - const calculateValueFromPosition = useCallback( - (clientX: number) => { - if (!sliderRef.current) return clampedValue; - const rect = sliderRef.current.getBoundingClientRect(); - const percent = Math.min(Math.max((clientX - rect.left) / rect.width, 0), 1); - const rawValue = min + percent * (max - min); - const steppedValue = Math.round(rawValue / step) * step; - return Math.min(Math.max(steppedValue, min), max); - }, - [min, max, step, clampedValue] - ); - - const handlePointerDown = useCallback( - (e: React.PointerEvent) => { - if (disabled) return; - e.preventDefault(); - setIsDragging(true); - const newValue = calculateValueFromPosition(e.clientX); - setLocalValue(newValue); - onChange(newValue); - (e.target as HTMLElement).setPointerCapture(e.pointerId); - }, - [disabled, calculateValueFromPosition, onChange] - ); - - const handlePointerMove = useCallback( - (e: React.PointerEvent) => { - if (!isDragging || disabled) return; - const newValue = calculateValueFromPosition(e.clientX); - setLocalValue(newValue); - onChange(newValue); - }, - [isDragging, disabled, calculateValueFromPosition, onChange] - ); - - const handlePointerUp = useCallback(() => { - setIsDragging(false); - }, []); - - const handleInputChange = (e: React.ChangeEvent) => { - const raw = e.target.value; - if (raw === '') { - setLocalValue(min); - return; - } - const parsed = parseInt(raw, 10); - if (!Number.isNaN(parsed)) { - const clamped = Math.min(Math.max(parsed, min), max); - setLocalValue(clamped); - onChange(clamped); - } - }; - - const handleInputBlur = () => { - // Ensure value is synced on blur - onChange(clampedValue); - }; - - // Quick preset buttons for common values - const quickPresets = [ - Math.round(min + (max - min) * 0.25), - Math.round(min + (max - min) * 0.5), - Math.round(min + (max - min) * 0.75), - ].filter((v, i, arr) => arr.indexOf(v) === i && v !== clampedValue); - - if (compact) { - return ( -
-
- -
-
- {/* Track background */} -
- {/* Track fill */} -
- {/* Thumb */} -
-
-
- {clampedValue} - {unitLabel} -
-
-
- {error &&

{error}

} -
- ); - } - - return ( -
-
- - {effectiveRangeHint} -
- - {showLargeValue && ( -
- {clampedValue} - {unitLabel} -
- )} - - {/* Custom slider */} -
- {/* Track background */} -
- - {/* Track fill with gradient */} -
- - {/* Tick marks */} -
- {[0, 25, 50, 75, 100].map((tick) => ( -
= tick ? 'bg-white/40' : 'bg-charcoal-outline' - }`} - /> - ))} -
- - {/* Thumb */} -
-
- - {/* Value input and quick presets */} -
-
- - {unitLabel} -
- - {quickPresets.length > 0 && ( -
- {quickPresets.slice(0, 3).map((preset) => ( - - ))} -
- )} -
- - {helperText &&

{helperText}

} - {error &&

{error}

} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/Section.tsx b/apps/website/components/ui/Section.tsx deleted file mode 100644 index 48bc4460d..000000000 --- a/apps/website/components/ui/Section.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import React, { ReactNode } from 'react'; - -interface SectionProps { - variant?: 'default' | 'dark' | 'light'; - children: ReactNode; - className?: string; - id?: string; -} - -export default function Section({ - variant = 'default', - children, - className = '', - id -}: SectionProps) { - const variantStyles = { - default: 'bg-deep-graphite', - dark: 'bg-iron-gray', - light: 'bg-charcoal-outline' - }; - - return ( -
- {children} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/SectionHeader.tsx b/apps/website/components/ui/SectionHeader.tsx deleted file mode 100644 index ed789c6f8..000000000 --- a/apps/website/components/ui/SectionHeader.tsx +++ /dev/null @@ -1,40 +0,0 @@ -'use client'; - -import React from 'react'; - -interface SectionHeaderProps { - icon: React.ElementType; - title: string; - description?: string; - action?: React.ReactNode; - color?: string; -} - -/** - * Section header component with icon, title, optional description and action. - * Used at the top of card sections throughout the app. - */ -export default function SectionHeader({ - icon: Icon, - title, - description, - action, - color = 'text-primary-blue' -}: SectionHeaderProps) { - return ( -
-
-

-
- -
- {title} -

- {description && ( -

{description}

- )} -
- {action} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/SegmentedControl.tsx b/apps/website/components/ui/SegmentedControl.tsx deleted file mode 100644 index 63bcff39b..000000000 --- a/apps/website/components/ui/SegmentedControl.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import { ButtonHTMLAttributes } from 'react'; - -interface SegmentedControlOption { - value: string; - label: string; - description?: string; - disabled?: boolean; -} - -interface SegmentedControlProps { - options: SegmentedControlOption[]; - value: string; - onChange?: (value: string) => void; -} - -export default function SegmentedControl({ - options, - value, - onChange, -}: SegmentedControlProps) { - const handleSelect = (optionValue: string, optionDisabled?: boolean) => { - if (!onChange || optionDisabled) return; - if (optionValue === value) return; - onChange(optionValue); - }; - - return ( -
- {options.map((option) => { - const isSelected = option.value === value; - const baseClasses = - 'flex-1 min-w-[140px] px-3 py-1.5 text-xs font-medium rounded-full transition-colors text-left'; - const selectedClasses = isSelected - ? 'bg-primary-blue text-white' - : 'text-gray-300 hover:text-white hover:bg-charcoal-outline/80'; - const disabledClasses = option.disabled - ? 'opacity-50 cursor-not-allowed hover:bg-transparent hover:text-gray-300' - : ''; - - const buttonProps: ButtonHTMLAttributes = { - type: 'button', - onClick: () => handleSelect(option.value, option.disabled), - 'aria-pressed': isSelected, - disabled: option.disabled, - }; - - return ( - - ); - })} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/StatCard.tsx b/apps/website/components/ui/StatCard.tsx deleted file mode 100644 index 7a0f3d669..000000000 --- a/apps/website/components/ui/StatCard.tsx +++ /dev/null @@ -1,56 +0,0 @@ -'use client'; - -import React from 'react'; -import Card from './Card'; - -interface StatCardProps { - icon: React.ElementType; - label: string; - value: string; - subValue?: string; - color?: string; - bgColor?: string; - trend?: { - value: number; - isPositive: boolean; - }; -} - -/** - * Statistics card component for displaying metrics with icon, label, value, and optional trend. - * Used in dashboards and overview sections. - */ -export default function StatCard({ - icon: Icon, - label, - value, - subValue, - color = 'text-primary-blue', - bgColor = 'bg-primary-blue/10', - trend, -}: StatCardProps) { - return ( - -
-
-
-
- -
- {label} -
-
{value}
- {subValue && ( -
{subValue}
- )} -
- {trend && ( -
- {trend.isPositive ? '↑' : '↓'} - {Math.abs(trend.value)}% -
- )} -
-
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/StatusBadge.tsx b/apps/website/components/ui/StatusBadge.tsx deleted file mode 100644 index 50ef38df6..000000000 --- a/apps/website/components/ui/StatusBadge.tsx +++ /dev/null @@ -1,68 +0,0 @@ -'use client'; - -import React from 'react'; - -type StatusType = 'success' | 'warning' | 'error' | 'info' | 'neutral' | 'pending'; - -interface StatusBadgeProps { - status: StatusType; - label: string; - icon?: React.ElementType; - size?: 'sm' | 'md'; -} - -const statusConfig: Record = { - success: { - color: 'text-performance-green', - bg: 'bg-performance-green/10', - border: 'border-performance-green/30', - }, - warning: { - color: 'text-warning-amber', - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', - }, - error: { - color: 'text-racing-red', - bg: 'bg-racing-red/10', - border: 'border-racing-red/30', - }, - info: { - color: 'text-primary-blue', - bg: 'bg-primary-blue/10', - border: 'border-primary-blue/30', - }, - neutral: { - color: 'text-gray-400', - bg: 'bg-iron-gray', - border: 'border-charcoal-outline', - }, - pending: { - color: 'text-warning-amber', - bg: 'bg-warning-amber/10', - border: 'border-warning-amber/30', - }, -}; - -/** - * Status badge component for displaying status indicators. - * Used for showing status of items like invoices, sponsorships, etc. - */ -export default function StatusBadge({ - status, - label, - icon: Icon, - size = 'sm', -}: StatusBadgeProps) { - const config = statusConfig[status]; - const sizeClasses = size === 'sm' - ? 'px-2 py-0.5 text-xs' - : 'px-3 py-1 text-sm'; - - return ( - - {Icon && } - {label} - - ); -} \ No newline at end of file diff --git a/apps/website/components/ui/TabContent.tsx b/apps/website/components/ui/TabContent.tsx deleted file mode 100644 index 8dd6d29c5..000000000 --- a/apps/website/components/ui/TabContent.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; - -interface TabContentProps { - activeTab: string; - children: React.ReactNode; - className?: string; -} - -export default function TabContent({ activeTab, children, className = '' }: TabContentProps) { - return ( -
- {children} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/TabNavigation.tsx b/apps/website/components/ui/TabNavigation.tsx deleted file mode 100644 index 9945e6437..000000000 --- a/apps/website/components/ui/TabNavigation.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; - -interface Tab { - id: string; - label: string; - icon?: React.ComponentType<{ className?: string }>; -} - -interface TabNavigationProps { - tabs: Tab[]; - activeTab: string; - onTabChange: (tabId: string) => void; - className?: string; -} - -export default function TabNavigation({ tabs, activeTab, onTabChange, className = '' }: TabNavigationProps) { - return ( -
- {tabs.map((tab) => { - const Icon = tab.icon; - return ( - - ); - })} -
- ); -} \ No newline at end of file diff --git a/apps/website/components/ui/Toggle.tsx b/apps/website/components/ui/Toggle.tsx deleted file mode 100644 index 4a0fb14d9..000000000 --- a/apps/website/components/ui/Toggle.tsx +++ /dev/null @@ -1,76 +0,0 @@ -'use client'; - -import React from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; - -interface ToggleProps { - checked: boolean; - onChange: (checked: boolean) => void; - label: string; - description?: string; - disabled?: boolean; -} - -/** - * Toggle switch component with Framer Motion animation. - * Used for boolean settings/preferences. - */ -export default function Toggle({ - checked, - onChange, - label, - description, - disabled = false, -}: ToggleProps) { - const shouldReduceMotion = useReducedMotion(); - - return ( - - ); -} \ No newline at end of file diff --git a/apps/website/eslint-rules/component-classification.js b/apps/website/eslint-rules/component-classification.js index 756dcd12d..729e2101a 100644 --- a/apps/website/eslint-rules/component-classification.js +++ b/apps/website/eslint-rules/component-classification.js @@ -1,29 +1,31 @@ /** - * ESLint rule to suggest proper component classification + * ESLint rule to suggest proper component classification and enforce UI component usage * * Architecture: * - app/ - Pages and layouts only (no business logic) * - components/ - App-level components (can be stateful, can use hooks) * - ui/ - Pure, reusable UI elements (stateless, no hooks) - * - hooks/ - Shared stateful logic * - * This rule provides SUGGESTIONS, not errors, for component placement. + * This rule enforces that components use proper UI elements instead of raw HTML and className. */ module.exports = { meta: { - type: 'suggestion', + type: 'problem', docs: { - description: 'Suggest proper component classification', + description: 'Enforce proper component classification and UI component usage', category: 'Architecture', - recommended: false, + recommended: true, }, - fixable: 'code', + fixable: null, schema: [], messages: { uiShouldBePure: 'This appears to be a pure UI element. Consider moving to ui/ for maximum reusability.', componentShouldBeInComponents: 'This component uses state/hooks. Consider moving to components/.', pureComponentInComponents: 'Pure component in components/. Consider moving to ui/ for better reusability.', + noRawHtml: 'Raw HTML tags are forbidden in components and pages. Use UI components from ui/ instead.', + noClassName: 'The className property is forbidden in components and pages. Use proper component props for styling.', + noStyle: 'The style property is forbidden in components and pages. Use proper component props for styling.', }, }, @@ -33,10 +35,12 @@ module.exports = { const isInComponents = filename.includes('/components/'); const isInApp = filename.includes('/app/'); - if (!isInUi && !isInComponents) return {}; + if (!isInUi && !isInComponents && !isInApp) return {}; return { Program(node) { + if (isInApp) return; // Don't check classification for app/ files + const sourceCode = context.getSourceCode(); const text = sourceCode.getText(); @@ -63,18 +67,64 @@ module.exports = { messageId: 'pureComponentInComponents', }); } - - if (isInComponents && !hasState && !hasEffects && !hasContext) { - // Check if it's mostly just rendering props - const hasManyProps = /\{\s*\.\.\.props\s*\}/.test(text) || - /\{\s*props\./.test(text); - - if (hasManyProps && hasNoLogic) { - context.report({ - loc: { line: 1, column: 0 }, - messageId: 'uiShouldBePure', - }); + }, + + JSXOpeningElement(node) { + if (isInUi) return; // Allow raw HTML and className in ui/ + + let tagName = ''; + if (node.name.type === 'JSXIdentifier') { + tagName = node.name.name; + } else if (node.name.type === 'JSXMemberExpression') { + tagName = node.name.property.name; + } + + if (!tagName) return; + + // 1. Forbid raw HTML tags (lowercase) + if (tagName[0] === tagName[0].toLowerCase()) { + // Special case for html and body in RootLayout + if (isInApp && (tagName === 'html' || tagName === 'body' || tagName === 'head' || tagName === 'meta' || tagName === 'link' || tagName === 'script')) { + return; } + + context.report({ + node, + messageId: 'noRawHtml', + }); + } + + // 2. Forbid className property + const classNameAttr = node.attributes.find( + attr => attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'className' + ); + + if (classNameAttr) { + // Special case for html and body in RootLayout + if (isInApp && (tagName === 'html' || tagName === 'body')) { + return; + } + + context.report({ + node: classNameAttr, + messageId: 'noClassName', + }); + } + + // 3. Forbid style property + const styleAttr = node.attributes.find( + attr => attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'style' + ); + + if (styleAttr) { + context.report({ + node: styleAttr, + messageId: 'noStyle', + }); } }, }; diff --git a/apps/website/eslint-rules/index.js b/apps/website/eslint-rules/index.js index 06089dd21..6ba7255ec 100644 --- a/apps/website/eslint-rules/index.js +++ b/apps/website/eslint-rules/index.js @@ -73,7 +73,7 @@ module.exports = { 'template-no-external-state': templatePurityRules['no-restricted-imports-in-templates'], 'template-no-global-objects': templatePurityRules['no-invalid-template-signature'], 'template-no-mutation-props': templatePurityRules['no-template-helper-exports'], - 'template-no-unsafe-html': templatePurityRules['invalid-template-filename'], + 'template-no-unsafe-html': require('./template-no-unsafe-html'), // Display Object Rules 'display-no-domain-models': displayObjectRules['no-io-in-display-objects'], diff --git a/apps/website/eslint-rules/template-no-unsafe-html.js b/apps/website/eslint-rules/template-no-unsafe-html.js index 1aec96a22..6c0367048 100644 --- a/apps/website/eslint-rules/template-no-unsafe-html.js +++ b/apps/website/eslint-rules/template-no-unsafe-html.js @@ -1,125 +1,80 @@ /** - * ESLint rule to enforce Presenter contract + * ESLint rule to forbid raw HTML and className in templates * - * Enforces that classes ending with "Presenter" must: - * 1. Implement Presenter interface - * 2. Have a present(input) method - * 3. Have 'use client' directive + * Templates must use proper reusable UI components instead of low level html and css. + * To avoid workarounds using `Box` with tailwind classes, the `className` property is forbidden. */ module.exports = { meta: { type: 'problem', docs: { - description: 'Enforce Presenter contract implementation', - category: 'Best Practices', + description: 'Forbid raw HTML and className in templates', + category: 'Architecture', recommended: true, }, fixable: null, schema: [], messages: { - missingImplements: 'Presenter class must implement Presenter interface', - missingPresentMethod: 'Presenter class must have present(input) method', - missingUseClient: 'Presenter must have \'use client\' directive at top-level', + noRawHtml: 'Raw HTML tags are forbidden in templates. Use UI components from ui/ instead.', + noClassName: 'The className property is forbidden in templates. Use proper component props for styling.', + noStyle: 'The style property is forbidden in templates. Use proper component props for styling.', }, }, create(context) { - const sourceCode = context.getSourceCode(); - let hasUseClient = false; - let presenterClassNode = null; - let hasPresentMethod = false; - let hasImplements = false; + const filename = context.getFilename(); + const isInTemplates = filename.includes('/templates/'); + + if (!isInTemplates) return {}; return { - // Check for 'use client' directive - Program(node) { - // Check comments at the top - const comments = sourceCode.getAllComments(); - if (comments.length > 0) { - const firstComment = comments[0]; - if (firstComment.type === 'Line' && firstComment.value.trim() === 'use client') { - hasUseClient = true; - } else if (firstComment.type === 'Block' && firstComment.value.includes('use client')) { - hasUseClient = true; - } + JSXOpeningElement(node) { + let tagName = ''; + if (node.name.type === 'JSXIdentifier') { + tagName = node.name.name; + } else if (node.name.type === 'JSXMemberExpression') { + tagName = node.name.property.name; } - // Also check for 'use client' string literal as first statement - if (node.body.length > 0) { - const firstStmt = node.body[0]; - if (firstStmt && - firstStmt.type === 'ExpressionStatement' && - firstStmt.expression.type === 'Literal' && - firstStmt.expression.value === 'use client') { - hasUseClient = true; - } - } - }, + if (!tagName) return; - // Find Presenter classes - ClassDeclaration(node) { - const className = node.id?.name; - - // Check if this is a Presenter class - if (className && className.endsWith('Presenter')) { - presenterClassNode = node; - - // Check if it implements any interface - if (node.implements && node.implements.length > 0) { - for (const impl of node.implements) { - // Handle GenericTypeAnnotation for Presenter - if (impl.expression.type === 'TSInstantiationExpression') { - const expr = impl.expression.expression; - if (expr.type === 'Identifier' && expr.name === 'Presenter') { - hasImplements = true; - } - } else if (impl.expression.type === 'Identifier') { - // Handle simple Presenter (without generics) - if (impl.expression.name === 'Presenter') { - hasImplements = true; - } - } - } - } - } - }, - - // Check for present method in classes - MethodDefinition(node) { - if (presenterClassNode && - node.key.type === 'Identifier' && - node.key.name === 'present' && - node.parent === presenterClassNode) { - hasPresentMethod = true; - } - }, - - // Report violations at the end - 'Program:exit'() { - if (!presenterClassNode) return; - - if (!hasImplements) { + // 1. Forbid raw HTML tags (lowercase) + if (tagName[0] === tagName[0].toLowerCase()) { context.report({ - node: presenterClassNode, - messageId: 'missingImplements', + node, + messageId: 'noRawHtml', }); } - if (!hasPresentMethod) { + // 2. Forbid className property + const classNameAttr = node.attributes.find( + attr => attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'className' + ); + + if (classNameAttr) { context.report({ - node: presenterClassNode, - messageId: 'missingPresentMethod', + node: classNameAttr, + messageId: 'noClassName', }); } - if (!hasUseClient) { + // 3. Forbid style property + const styleAttr = node.attributes.find( + attr => attr.type === 'JSXAttribute' && + attr.name.type === 'JSXIdentifier' && + attr.name.name === 'style' + ); + + if (styleAttr) { context.report({ - node: presenterClassNode, - messageId: 'missingUseClient', + node: styleAttr, + messageId: 'noStyle', }); } }, }; }, -}; \ No newline at end of file +}; diff --git a/apps/website/eslint-rules/ui-element-purity.js b/apps/website/eslint-rules/ui-element-purity.js index e83a1a7dd..ae9eef216 100644 --- a/apps/website/eslint-rules/ui-element-purity.js +++ b/apps/website/eslint-rules/ui-element-purity.js @@ -1,22 +1,23 @@ /** - * ESLint rule to enforce UI element purity + * ESLint rule to enforce UI element purity and architectural boundaries * * UI elements in ui/ must be: * - Stateless (no useState, useReducer) * - No side effects (no useEffect) * - Pure functions that only render based on props + * - Isolated (cannot import from outside the ui/ directory) * * Rationale: * - ui/ is for reusable, pure presentation elements - * - Stateful logic belongs in components/ or hooks/ - * - This ensures maximum reusability and testability + * - Isolation ensures UI elements don't depend on app-specific logic + * - Raw HTML and className are allowed in ui/ for implementation */ module.exports = { meta: { type: 'problem', docs: { - description: 'Enforce UI elements are pure and stateless', + description: 'Enforce UI elements are pure and isolated', category: 'Architecture', recommended: true, }, @@ -26,6 +27,7 @@ module.exports = { noStateInUi: 'UI elements in ui/ must be stateless. Use components/ for stateful wrappers.', noHooksInUi: 'UI elements must not use hooks. Use components/ or hooks/ for stateful logic.', noSideEffects: 'UI elements must not have side effects. Use components/ for side effect logic.', + noExternalImports: 'UI elements in ui/ cannot import from outside the ui/ directory. Only npm packages and other UI elements are allowed.', }, }, @@ -36,6 +38,38 @@ module.exports = { if (!isInUi) return {}; return { + // Check for imports from outside ui/ + ImportDeclaration(node) { + const importPath = node.source.value; + + // Allow npm packages (don't start with . or @/) + if (!importPath.startsWith('.') && !importPath.startsWith('@/')) { + return; + } + + // Check internal imports + const isInternalUiImport = importPath.startsWith('@/ui/') || + (importPath.startsWith('.') && !importPath.includes('..')); + + // Special case for relative imports that stay within ui/ + let staysInUi = false; + if (importPath.startsWith('.')) { + const path = require('path'); + const absoluteImportPath = path.resolve(path.dirname(filename), importPath); + const uiDir = filename.split('/ui/')[0] + '/ui'; + if (absoluteImportPath.startsWith(uiDir)) { + staysInUi = true; + } + } + + if (!isInternalUiImport && !staysInUi) { + context.report({ + node, + messageId: 'noExternalImports', + }); + } + }, + // Check for 'use client' directive ExpressionStatement(node) { if (node.expression.type === 'Literal' && @@ -77,31 +111,6 @@ module.exports = { }); } }, - - // Check for class components with state - ClassDeclaration(node) { - if (node.superClass && - node.superClass.type === 'Identifier' && - node.superClass.name === 'Component') { - context.report({ - node, - messageId: 'noStateInUi', - }); - } - }, - - // Check for direct state assignment (rare but possible) - AssignmentExpression(node) { - if (node.left.type === 'MemberExpression' && - node.left.property.type === 'Identifier' && - (node.left.property.name === 'state' || - node.left.property.name === 'setState')) { - context.report({ - node, - messageId: 'noStateInUi', - }); - } - }, }; }, }; diff --git a/apps/website/lib/hooks/auth/index.ts b/apps/website/hooks/auth/index.ts similarity index 100% rename from apps/website/lib/hooks/auth/index.ts rename to apps/website/hooks/auth/index.ts diff --git a/apps/website/lib/hooks/auth/useCurrentSession.ts b/apps/website/hooks/auth/useCurrentSession.ts similarity index 100% rename from apps/website/lib/hooks/auth/useCurrentSession.ts rename to apps/website/hooks/auth/useCurrentSession.ts diff --git a/apps/website/lib/hooks/auth/useForgotPassword.ts b/apps/website/hooks/auth/useForgotPassword.ts similarity index 100% rename from apps/website/lib/hooks/auth/useForgotPassword.ts rename to apps/website/hooks/auth/useForgotPassword.ts diff --git a/apps/website/lib/hooks/auth/useLogin.ts b/apps/website/hooks/auth/useLogin.ts similarity index 100% rename from apps/website/lib/hooks/auth/useLogin.ts rename to apps/website/hooks/auth/useLogin.ts diff --git a/apps/website/lib/hooks/auth/useLogout.ts b/apps/website/hooks/auth/useLogout.ts similarity index 100% rename from apps/website/lib/hooks/auth/useLogout.ts rename to apps/website/hooks/auth/useLogout.ts diff --git a/apps/website/lib/hooks/auth/useResetPassword.ts b/apps/website/hooks/auth/useResetPassword.ts similarity index 100% rename from apps/website/lib/hooks/auth/useResetPassword.ts rename to apps/website/hooks/auth/useResetPassword.ts diff --git a/apps/website/lib/hooks/auth/useSignup.ts b/apps/website/hooks/auth/useSignup.ts similarity index 100% rename from apps/website/lib/hooks/auth/useSignup.ts rename to apps/website/hooks/auth/useSignup.ts diff --git a/apps/website/lib/hooks/driver/index.ts b/apps/website/hooks/driver/index.ts similarity index 100% rename from apps/website/lib/hooks/driver/index.ts rename to apps/website/hooks/driver/index.ts diff --git a/apps/website/lib/hooks/driver/useCreateDriver.ts b/apps/website/hooks/driver/useCreateDriver.ts similarity index 100% rename from apps/website/lib/hooks/driver/useCreateDriver.ts rename to apps/website/hooks/driver/useCreateDriver.ts diff --git a/apps/website/lib/hooks/driver/useCurrentDriver.ts b/apps/website/hooks/driver/useCurrentDriver.ts similarity index 100% rename from apps/website/lib/hooks/driver/useCurrentDriver.ts rename to apps/website/hooks/driver/useCurrentDriver.ts diff --git a/apps/website/lib/hooks/driver/useDriverProfile.ts b/apps/website/hooks/driver/useDriverProfile.ts similarity index 100% rename from apps/website/lib/hooks/driver/useDriverProfile.ts rename to apps/website/hooks/driver/useDriverProfile.ts diff --git a/apps/website/lib/hooks/driver/useDriverProfilePageData.ts b/apps/website/hooks/driver/useDriverProfilePageData.ts similarity index 100% rename from apps/website/lib/hooks/driver/useDriverProfilePageData.ts rename to apps/website/hooks/driver/useDriverProfilePageData.ts diff --git a/apps/website/lib/hooks/driver/useFindDriverById.ts b/apps/website/hooks/driver/useFindDriverById.ts similarity index 100% rename from apps/website/lib/hooks/driver/useFindDriverById.ts rename to apps/website/hooks/driver/useFindDriverById.ts diff --git a/apps/website/lib/hooks/driver/useUpdateDriverProfile.ts b/apps/website/hooks/driver/useUpdateDriverProfile.ts similarity index 100% rename from apps/website/lib/hooks/driver/useUpdateDriverProfile.ts rename to apps/website/hooks/driver/useUpdateDriverProfile.ts diff --git a/apps/website/lib/hooks/league/useAllLeagues.ts b/apps/website/hooks/league/useAllLeagues.ts similarity index 100% rename from apps/website/lib/hooks/league/useAllLeagues.ts rename to apps/website/hooks/league/useAllLeagues.ts diff --git a/apps/website/lib/hooks/league/useCreateLeague.ts b/apps/website/hooks/league/useCreateLeague.ts similarity index 100% rename from apps/website/lib/hooks/league/useCreateLeague.ts rename to apps/website/hooks/league/useCreateLeague.ts diff --git a/apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts b/apps/website/hooks/league/useCreateLeagueWithBlockers.ts similarity index 100% rename from apps/website/lib/hooks/league/useCreateLeagueWithBlockers.ts rename to apps/website/hooks/league/useCreateLeagueWithBlockers.ts diff --git a/apps/website/lib/hooks/league/useLeagueAdminStatus.ts b/apps/website/hooks/league/useLeagueAdminStatus.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueAdminStatus.ts rename to apps/website/hooks/league/useLeagueAdminStatus.ts diff --git a/apps/website/lib/hooks/league/useLeagueDetail.ts b/apps/website/hooks/league/useLeagueDetail.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueDetail.ts rename to apps/website/hooks/league/useLeagueDetail.ts diff --git a/apps/website/lib/hooks/league/useLeagueMembershipMutation.ts b/apps/website/hooks/league/useLeagueMembershipMutation.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueMembershipMutation.ts rename to apps/website/hooks/league/useLeagueMembershipMutation.ts diff --git a/apps/website/lib/hooks/league/useLeagueMemberships.ts b/apps/website/hooks/league/useLeagueMemberships.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueMemberships.ts rename to apps/website/hooks/league/useLeagueMemberships.ts diff --git a/apps/website/lib/hooks/league/useLeagueRaces.ts b/apps/website/hooks/league/useLeagueRaces.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueRaces.ts rename to apps/website/hooks/league/useLeagueRaces.ts diff --git a/apps/website/lib/hooks/league/useLeagueRosterAdmin.ts b/apps/website/hooks/league/useLeagueRosterAdmin.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueRosterAdmin.ts rename to apps/website/hooks/league/useLeagueRosterAdmin.ts diff --git a/apps/website/lib/hooks/league/useLeagueSchedule.ts b/apps/website/hooks/league/useLeagueSchedule.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueSchedule.ts rename to apps/website/hooks/league/useLeagueSchedule.ts diff --git a/apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts b/apps/website/hooks/league/useLeagueScheduleAdminPageData.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueScheduleAdminPageData.ts rename to apps/website/hooks/league/useLeagueScheduleAdminPageData.ts diff --git a/apps/website/lib/hooks/league/useLeagueSeasons.ts b/apps/website/hooks/league/useLeagueSeasons.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueSeasons.ts rename to apps/website/hooks/league/useLeagueSeasons.ts diff --git a/apps/website/lib/hooks/league/useLeagueSettings.ts b/apps/website/hooks/league/useLeagueSettings.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueSettings.ts rename to apps/website/hooks/league/useLeagueSettings.ts diff --git a/apps/website/lib/hooks/league/useLeagueSponsorshipsPageData.ts b/apps/website/hooks/league/useLeagueSponsorshipsPageData.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueSponsorshipsPageData.ts rename to apps/website/hooks/league/useLeagueSponsorshipsPageData.ts diff --git a/apps/website/lib/hooks/league/useLeagueStewardingData.ts b/apps/website/hooks/league/useLeagueStewardingData.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueStewardingData.ts rename to apps/website/hooks/league/useLeagueStewardingData.ts diff --git a/apps/website/lib/hooks/league/useLeagueStewardingMutations.ts b/apps/website/hooks/league/useLeagueStewardingMutations.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueStewardingMutations.ts rename to apps/website/hooks/league/useLeagueStewardingMutations.ts diff --git a/apps/website/lib/hooks/league/useLeagueWalletPageData.ts b/apps/website/hooks/league/useLeagueWalletPageData.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueWalletPageData.ts rename to apps/website/hooks/league/useLeagueWalletPageData.ts diff --git a/apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts b/apps/website/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts similarity index 100% rename from apps/website/lib/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts rename to apps/website/hooks/league/useLeagueWalletWithdrawalWithBlockers.ts diff --git a/apps/website/lib/hooks/league/usePenaltyMutation.ts b/apps/website/hooks/league/usePenaltyMutation.ts similarity index 100% rename from apps/website/lib/hooks/league/usePenaltyMutation.ts rename to apps/website/hooks/league/usePenaltyMutation.ts diff --git a/apps/website/lib/hooks/league/useProtestDetail.ts b/apps/website/hooks/league/useProtestDetail.ts similarity index 100% rename from apps/website/lib/hooks/league/useProtestDetail.ts rename to apps/website/hooks/league/useProtestDetail.ts diff --git a/apps/website/lib/hooks/league/useSponsorshipRequests.ts b/apps/website/hooks/league/useSponsorshipRequests.ts similarity index 100% rename from apps/website/lib/hooks/league/useSponsorshipRequests.ts rename to apps/website/hooks/league/useSponsorshipRequests.ts diff --git a/apps/website/lib/hooks/onboarding/useCompleteOnboarding.ts b/apps/website/hooks/onboarding/useCompleteOnboarding.ts similarity index 100% rename from apps/website/lib/hooks/onboarding/useCompleteOnboarding.ts rename to apps/website/hooks/onboarding/useCompleteOnboarding.ts diff --git a/apps/website/lib/hooks/onboarding/useGenerateAvatars.ts b/apps/website/hooks/onboarding/useGenerateAvatars.ts similarity index 100% rename from apps/website/lib/hooks/onboarding/useGenerateAvatars.ts rename to apps/website/hooks/onboarding/useGenerateAvatars.ts diff --git a/apps/website/lib/hooks/race/useAllRacesPageData.ts b/apps/website/hooks/race/useAllRacesPageData.ts similarity index 100% rename from apps/website/lib/hooks/race/useAllRacesPageData.ts rename to apps/website/hooks/race/useAllRacesPageData.ts diff --git a/apps/website/lib/hooks/race/useFileProtest.ts b/apps/website/hooks/race/useFileProtest.ts similarity index 100% rename from apps/website/lib/hooks/race/useFileProtest.ts rename to apps/website/hooks/race/useFileProtest.ts diff --git a/apps/website/lib/hooks/race/useRaceResultsPageData.ts b/apps/website/hooks/race/useRaceResultsPageData.ts similarity index 100% rename from apps/website/lib/hooks/race/useRaceResultsPageData.ts rename to apps/website/hooks/race/useRaceResultsPageData.ts diff --git a/apps/website/lib/hooks/race/useRegisterForRace.ts b/apps/website/hooks/race/useRegisterForRace.ts similarity index 100% rename from apps/website/lib/hooks/race/useRegisterForRace.ts rename to apps/website/hooks/race/useRegisterForRace.ts diff --git a/apps/website/lib/hooks/race/useWithdrawFromRace.ts b/apps/website/hooks/race/useWithdrawFromRace.ts similarity index 100% rename from apps/website/lib/hooks/race/useWithdrawFromRace.ts rename to apps/website/hooks/race/useWithdrawFromRace.ts diff --git a/apps/website/lib/hooks/sponsor/index.ts b/apps/website/hooks/sponsor/index.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/index.ts rename to apps/website/hooks/sponsor/index.ts diff --git a/apps/website/lib/hooks/sponsor/useAvailableLeagues.ts b/apps/website/hooks/sponsor/useAvailableLeagues.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/useAvailableLeagues.ts rename to apps/website/hooks/sponsor/useAvailableLeagues.ts diff --git a/apps/website/lib/hooks/sponsor/useSponsorBilling.ts b/apps/website/hooks/sponsor/useSponsorBilling.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/useSponsorBilling.ts rename to apps/website/hooks/sponsor/useSponsorBilling.ts diff --git a/apps/website/lib/hooks/sponsor/useSponsorDashboard.ts b/apps/website/hooks/sponsor/useSponsorDashboard.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/useSponsorDashboard.ts rename to apps/website/hooks/sponsor/useSponsorDashboard.ts diff --git a/apps/website/lib/hooks/sponsor/useSponsorLeagueDetail.ts b/apps/website/hooks/sponsor/useSponsorLeagueDetail.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/useSponsorLeagueDetail.ts rename to apps/website/hooks/sponsor/useSponsorLeagueDetail.ts diff --git a/apps/website/components/sponsors/useSponsorMode.ts b/apps/website/hooks/sponsor/useSponsorMode.ts similarity index 100% rename from apps/website/components/sponsors/useSponsorMode.ts rename to apps/website/hooks/sponsor/useSponsorMode.ts diff --git a/apps/website/lib/hooks/sponsor/useSponsorSponsorships.ts b/apps/website/hooks/sponsor/useSponsorSponsorships.ts similarity index 100% rename from apps/website/lib/hooks/sponsor/useSponsorSponsorships.ts rename to apps/website/hooks/sponsor/useSponsorSponsorships.ts diff --git a/apps/website/lib/hooks/team/index.ts b/apps/website/hooks/team/index.ts similarity index 100% rename from apps/website/lib/hooks/team/index.ts rename to apps/website/hooks/team/index.ts diff --git a/apps/website/lib/hooks/team/useAllTeams.ts b/apps/website/hooks/team/useAllTeams.ts similarity index 100% rename from apps/website/lib/hooks/team/useAllTeams.ts rename to apps/website/hooks/team/useAllTeams.ts diff --git a/apps/website/lib/hooks/team/useApproveJoinRequest.ts b/apps/website/hooks/team/useApproveJoinRequest.ts similarity index 100% rename from apps/website/lib/hooks/team/useApproveJoinRequest.ts rename to apps/website/hooks/team/useApproveJoinRequest.ts diff --git a/apps/website/lib/hooks/team/useCreateTeam.ts b/apps/website/hooks/team/useCreateTeam.ts similarity index 100% rename from apps/website/lib/hooks/team/useCreateTeam.ts rename to apps/website/hooks/team/useCreateTeam.ts diff --git a/apps/website/lib/hooks/team/useJoinTeam.ts b/apps/website/hooks/team/useJoinTeam.ts similarity index 100% rename from apps/website/lib/hooks/team/useJoinTeam.ts rename to apps/website/hooks/team/useJoinTeam.ts diff --git a/apps/website/lib/hooks/team/useLeaveTeam.ts b/apps/website/hooks/team/useLeaveTeam.ts similarity index 100% rename from apps/website/lib/hooks/team/useLeaveTeam.ts rename to apps/website/hooks/team/useLeaveTeam.ts diff --git a/apps/website/lib/hooks/team/useRejectJoinRequest.ts b/apps/website/hooks/team/useRejectJoinRequest.ts similarity index 100% rename from apps/website/lib/hooks/team/useRejectJoinRequest.ts rename to apps/website/hooks/team/useRejectJoinRequest.ts diff --git a/apps/website/lib/hooks/team/useTeamDetails.ts b/apps/website/hooks/team/useTeamDetails.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamDetails.ts rename to apps/website/hooks/team/useTeamDetails.ts diff --git a/apps/website/lib/hooks/team/useTeamJoinRequests.ts b/apps/website/hooks/team/useTeamJoinRequests.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamJoinRequests.ts rename to apps/website/hooks/team/useTeamJoinRequests.ts diff --git a/apps/website/lib/hooks/team/useTeamMembers.ts b/apps/website/hooks/team/useTeamMembers.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamMembers.ts rename to apps/website/hooks/team/useTeamMembers.ts diff --git a/apps/website/lib/hooks/team/useTeamMembership.ts b/apps/website/hooks/team/useTeamMembership.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamMembership.ts rename to apps/website/hooks/team/useTeamMembership.ts diff --git a/apps/website/lib/hooks/team/useTeamRoster.ts b/apps/website/hooks/team/useTeamRoster.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamRoster.ts rename to apps/website/hooks/team/useTeamRoster.ts diff --git a/apps/website/lib/hooks/team/useTeamStandings.ts b/apps/website/hooks/team/useTeamStandings.ts similarity index 100% rename from apps/website/lib/hooks/team/useTeamStandings.ts rename to apps/website/hooks/team/useTeamStandings.ts diff --git a/apps/website/lib/hooks/team/useUpdateTeam.ts b/apps/website/hooks/team/useUpdateTeam.ts similarity index 100% rename from apps/website/lib/hooks/team/useUpdateTeam.ts rename to apps/website/hooks/team/useUpdateTeam.ts diff --git a/apps/website/lib/hooks/useCapability.ts b/apps/website/hooks/useCapability.ts similarity index 100% rename from apps/website/lib/hooks/useCapability.ts rename to apps/website/hooks/useCapability.ts diff --git a/apps/website/lib/hooks/useDriverLeaderboard.ts b/apps/website/hooks/useDriverLeaderboard.ts similarity index 100% rename from apps/website/lib/hooks/useDriverLeaderboard.ts rename to apps/website/hooks/useDriverLeaderboard.ts diff --git a/apps/website/lib/hooks/useDriverSearch.ts b/apps/website/hooks/useDriverSearch.ts similarity index 100% rename from apps/website/lib/hooks/useDriverSearch.ts rename to apps/website/hooks/useDriverSearch.ts diff --git a/apps/website/lib/hooks/useEffectiveDriverId.ts b/apps/website/hooks/useEffectiveDriverId.ts similarity index 100% rename from apps/website/lib/hooks/useEffectiveDriverId.ts rename to apps/website/hooks/useEffectiveDriverId.ts diff --git a/apps/website/lib/hooks/useEnhancedForm.test.ts b/apps/website/hooks/useEnhancedForm.test.ts similarity index 100% rename from apps/website/lib/hooks/useEnhancedForm.test.ts rename to apps/website/hooks/useEnhancedForm.test.ts diff --git a/apps/website/lib/hooks/useEnhancedForm.ts b/apps/website/hooks/useEnhancedForm.ts similarity index 100% rename from apps/website/lib/hooks/useEnhancedForm.ts rename to apps/website/hooks/useEnhancedForm.ts diff --git a/apps/website/lib/hooks/useLeagueScoringPresets.ts b/apps/website/hooks/useLeagueScoringPresets.ts similarity index 100% rename from apps/website/lib/hooks/useLeagueScoringPresets.ts rename to apps/website/hooks/useLeagueScoringPresets.ts diff --git a/apps/website/lib/hooks/useLeagueWizardService.ts b/apps/website/hooks/useLeagueWizardService.ts similarity index 100% rename from apps/website/lib/hooks/useLeagueWizardService.ts rename to apps/website/hooks/useLeagueWizardService.ts diff --git a/apps/website/lib/hooks/usePenaltyTypesReference.ts b/apps/website/hooks/usePenaltyTypesReference.ts similarity index 100% rename from apps/website/lib/hooks/usePenaltyTypesReference.ts rename to apps/website/hooks/usePenaltyTypesReference.ts diff --git a/apps/website/lib/hooks/useScrollProgress.ts b/apps/website/hooks/useScrollProgress.ts similarity index 100% rename from apps/website/lib/hooks/useScrollProgress.ts rename to apps/website/hooks/useScrollProgress.ts diff --git a/apps/website/lib/api/races/RacesApiClient.ts b/apps/website/lib/api/races/RacesApiClient.ts index c9e8e23e7..b8fe51272 100644 --- a/apps/website/lib/api/races/RacesApiClient.ts +++ b/apps/website/lib/api/races/RacesApiClient.ts @@ -16,8 +16,8 @@ import type { AllRacesPageDTO } from '../../types/generated/AllRacesPageDTO'; import type { FilteredRacesPageDataDTO } from '../../types/tbd/FilteredRacesPageDataDTO'; // Define missing types -type RacesPageDataDTO = { races: RacesPageDataRaceDTO[] }; -type RaceDetailDTO = { +export type RacesPageDataDTO = { races: RacesPageDataRaceDTO[] }; +export type RaceDetailDTO = { race: RaceDetailRaceDTO | null; league: RaceDetailLeagueDTO | null; entryList: RaceDetailEntryDTO[]; @@ -25,7 +25,7 @@ type RaceDetailDTO = { userResult: RaceDetailUserResultDTO | null; error?: string; }; -type ImportRaceResultsSummaryDTO = { +export type ImportRaceResultsSummaryDTO = { success: boolean; raceId: string; driversProcessed: number; diff --git a/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts b/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts index 53ea7c066..8cade32aa 100644 --- a/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts +++ b/apps/website/lib/builders/view-data/RacesViewDataBuilder.ts @@ -1,39 +1,100 @@ -import { RacesViewData, RacesRace } from '@/lib/view-data/races/RacesViewData'; +import type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO'; +import type { RacesViewData, RaceViewData } from '@/lib/view-data/RacesViewData'; -/** - * Races View Data Builder - * - * Transforms API DTO into ViewData for the races template. - * Deterministic, side-effect free. - */ export class RacesViewDataBuilder { - static build(apiDto: any): RacesViewData { - const races = apiDto.races.map((race: any) => ({ - id: race.id, - track: race.track, - car: race.car, - scheduledAt: race.scheduledAt, - status: race.status as 'scheduled' | 'running' | 'completed' | 'cancelled', - sessionType: 'race', - leagueId: race.leagueId, - leagueName: race.leagueName, - strengthOfField: race.strengthOfField ?? undefined, - isUpcoming: race.status === 'scheduled', - isLive: race.status === 'running', - isPast: race.status === 'completed', - })); + static build(apiDto: RacesPageDataDTO): RacesViewData { + const races = apiDto.races.map((race): RaceViewData => { + const scheduledAt = new Date(race.scheduledAt); + + return { + id: race.id, + track: race.track, + car: race.car, + scheduledAt: race.scheduledAt, + scheduledAtLabel: scheduledAt.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }), + timeLabel: scheduledAt.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }), + relativeTimeLabel: this.getRelativeTime(scheduledAt), + status: race.status as RaceViewData['status'], + statusLabel: this.getStatusLabel(race.status), + sessionType: 'Race', + leagueId: race.leagueId, + leagueName: race.leagueName, + strengthOfField: race.strengthOfField ?? null, + isUpcoming: race.isUpcoming, + isLive: race.isLive, + isPast: race.isPast, + }; + }); - const totalCount = races.length; - const scheduledRaces = races.filter((r: RacesRace) => r.isUpcoming); - const runningRaces = races.filter((r: RacesRace) => r.isLive); - const completedRaces = races.filter((r: RacesRace) => r.isPast); + const leagues = Array.from( + new Map( + races + .filter(r => r.leagueId && r.leagueName) + .map(r => [r.leagueId, { id: r.leagueId!, name: r.leagueName! }]) + ).values() + ); + + const groupedRaces = new Map(); + races.forEach((race) => { + const dateKey = race.scheduledAt.split('T')[0]!; + if (!groupedRaces.has(dateKey)) { + groupedRaces.set(dateKey, []); + } + groupedRaces.get(dateKey)!.push(race); + }); + + const racesByDate = Array.from(groupedRaces.entries()).map(([dateKey, dayRaces]) => ({ + dateKey, + dateLabel: dayRaces[0]?.scheduledAtLabel || '', + races: dayRaces, + })); return { races, - totalCount, - scheduledRaces, - runningRaces, - completedRaces, + totalCount: races.length, + scheduledCount: races.filter(r => r.status === 'scheduled').length, + runningCount: races.filter(r => r.status === 'running').length, + completedCount: races.filter(r => r.status === 'completed').length, + leagues, + upcomingRaces: races.filter(r => r.isUpcoming).slice(0, 5), + liveRaces: races.filter(r => r.isLive), + recentResults: races.filter(r => r.isPast).slice(0, 5), + racesByDate, }; } -} \ No newline at end of file + + private static getStatusLabel(status: string): string { + switch (status) { + case 'scheduled': return 'Scheduled'; + case 'running': return 'LIVE'; + case 'completed': return 'Completed'; + case 'cancelled': return 'Cancelled'; + default: return status; + } + } + + private static getRelativeTime(date: Date): string { + const now = new Date(); + const diffMs = date.getTime() - now.getTime(); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffMs < 0) return 'Past'; + if (diffHours < 1) return 'Starting soon'; + if (diffHours < 24) return `In ${diffHours}h`; + if (diffDays === 1) return 'Tomorrow'; + if (diffDays < 7) return `In ${diffDays} days`; + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + }); + } +} diff --git a/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts b/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts index fbc337d7b..b1832fd0b 100644 --- a/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/ProfilePageQuery.ts @@ -27,12 +27,7 @@ export class ProfilePageQuery implements PageQuery> { - const query = new ProfilePageQuery(); - return query.execute(); - } -} \ No newline at end of file +} diff --git a/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts b/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts index 0b9b5c1fe..fd8541bb9 100644 --- a/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts +++ b/apps/website/lib/page-queries/page-queries/TeamsPageQuery.ts @@ -1,39 +1,16 @@ -import type { PageQueryResult } from '@/lib/contracts/page-queries/PageQueryResult'; +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'; - -/** - * TeamsPageDto - Raw serializable data for teams page - * Contains only raw data, no derived/computed properties - */ -export interface TeamsPageDto { - teams: Array<{ - id: string; - name: string; - tag: string; - memberCount: number; - description?: string; - totalWins: number; - totalRaces: number; - performanceLevel: 'beginner' | 'intermediate' | 'advanced' | 'pro'; - isRecruiting: boolean; - specialization?: 'endurance' | 'sprint' | 'mixed'; - region?: string; - languages: string[]; - leagues: string[]; - logoUrl?: string; - rating?: number; - category?: string; - }>; -} +import type { TeamsViewData } from '@/lib/view-data/TeamsViewData'; +import { TeamsViewDataBuilder } from '@/lib/builders/view-data/TeamsViewDataBuilder'; /** * TeamsPageQuery - Server-side composition for teams list page * Manual wiring only; no ContainerManager; no PageDataFetcher - * Returns raw serializable DTO */ -export class TeamsPageQuery { - static async execute(): Promise> { +export class TeamsPageQuery implements PageQuery { + async execute(): Promise> { try { // Manual dependency creation const service = new TeamService(); @@ -42,51 +19,17 @@ export class TeamsPageQuery { const result = await service.getAllTeams(); if (result.isErr()) { - return { status: 'error', errorId: 'TEAMS_FETCH_FAILED' }; + return Result.err(mapToPresentationError(result.getError())); } const teams = result.unwrap(); - if (!teams || teams.length === 0) { - return { status: 'notFound' }; - } + // Transform to ViewData using builder + const viewData = TeamsViewDataBuilder.build({ teams }); - // Transform to raw serializable DTO - const dto: TeamsPageDto = { - teams: teams.map((team: TeamSummaryViewModel) => ({ - id: team.id, - name: team.name, - tag: team.tag, - memberCount: team.memberCount, - description: team.description, - totalWins: team.totalWins, - totalRaces: team.totalRaces, - performanceLevel: team.performanceLevel, - isRecruiting: team.isRecruiting, - specialization: team.specialization, - region: team.region, - languages: team.languages, - leagues: team.leagues, - logoUrl: team.logoUrl, - rating: team.rating, - category: team.category, - })), - }; - - return { status: 'ok', dto }; + return Result.ok(viewData); } catch (error) { - // Handle specific error types - if (error instanceof Error) { - const errorAny = error as { statusCode?: number; message?: string }; - if (errorAny.message?.includes('not found') || errorAny.statusCode === 404) { - return { status: 'notFound' }; - } - if (errorAny.message?.includes('redirect') || errorAny.statusCode === 302) { - return { status: 'redirect', to: '/' }; - } - return { status: 'error', errorId: 'TEAMS_FETCH_FAILED' }; - } - return { status: 'error', errorId: 'UNKNOWN_ERROR' }; + return Result.err('unknown'); } } -} \ No newline at end of file +} diff --git a/apps/website/lib/page-queries/races/RacesPageQuery.ts b/apps/website/lib/page-queries/races/RacesPageQuery.ts index 581bda413..92a4a0e16 100644 --- a/apps/website/lib/page-queries/races/RacesPageQuery.ts +++ b/apps/website/lib/page-queries/races/RacesPageQuery.ts @@ -1,7 +1,7 @@ import { Result } from '@/lib/contracts/Result'; import { PageQuery } from '@/lib/contracts/page-queries/PageQuery'; import { PresentationError, mapToPresentationError } from '@/lib/contracts/page-queries/PresentationError'; -import { RacesViewData } from '@/lib/view-data/races/RacesViewData'; +import { RacesViewData } from '@/lib/view-data/RacesViewData'; import { RacesService } from '@/lib/services/races/RacesService'; import { RacesViewDataBuilder } from '@/lib/builders/view-data/RacesViewDataBuilder'; @@ -27,10 +27,4 @@ export class RacesPageQuery implements PageQuery { const viewData = RacesViewDataBuilder.build(result.unwrap()); return Result.ok(viewData); } - - // Static method to avoid object construction in server code - static async execute(): Promise> { - const query = new RacesPageQuery(); - return await query.execute(); - } -} \ No newline at end of file +} diff --git a/apps/website/lib/services/drivers/DriverProfileService.ts b/apps/website/lib/services/drivers/DriverProfileService.ts index 18c66d3d5..026354a42 100644 --- a/apps/website/lib/services/drivers/DriverProfileService.ts +++ b/apps/website/lib/services/drivers/DriverProfileService.ts @@ -1,28 +1,26 @@ import { DriversApiClient } from '@/lib/api/drivers/DriversApiClient'; import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; -import { isProductionEnvironment } from '@/lib/config/env'; import { Result } from '@/lib/contracts/Result'; import type { Service } from '@/lib/contracts/services/Service'; -import { EnhancedErrorReporter } from '@/lib/infrastructure/EnhancedErrorReporter'; +import { ConsoleErrorReporter } from '@/lib/infrastructure/logging/ConsoleErrorReporter'; import { ConsoleLogger } from '@/lib/infrastructure/logging/ConsoleLogger'; import type { GetDriverProfileOutputDTO } from '@/lib/types/generated/GetDriverProfileOutputDTO'; type DriverProfileServiceError = 'notFound' | 'unauthorized' | 'serverError' | 'unknown'; export class DriverProfileService implements Service { - async getDriverProfile(driverId: string): Promise> { + private apiClient: DriversApiClient; + + constructor() { + const baseUrl = getWebsiteApiBaseUrl(); const logger = new ConsoleLogger(); + const errorReporter = new ConsoleErrorReporter(); + this.apiClient = new DriversApiClient(baseUrl, errorReporter, logger); + } + async getDriverProfile(driverId: string): Promise> { try { - const baseUrl = getWebsiteApiBaseUrl(); - const errorReporter = new EnhancedErrorReporter(logger, { - showUserNotifications: true, - logToConsole: true, - reportToExternal: isProductionEnvironment(), - }); - - const apiClient = new DriversApiClient(baseUrl, errorReporter, logger); - const dto = await apiClient.getDriverProfile(driverId); + const dto = await this.apiClient.getDriverProfile(driverId); if (!dto.currentDriver) { return Result.err('notFound'); @@ -40,8 +38,6 @@ export class DriverProfileService implements Service { return Result.err('notFound'); } - logger.error('DriverProfileService failed', error instanceof Error ? error : undefined, { error: errorAny }); - if (errorAny.statusCode && errorAny.statusCode >= 500) { return Result.err('serverError'); } diff --git a/apps/website/lib/services/races/RacesService.ts b/apps/website/lib/services/races/RacesService.ts index f2f3fecbb..994d27542 100644 --- a/apps/website/lib/services/races/RacesService.ts +++ b/apps/website/lib/services/races/RacesService.ts @@ -1,10 +1,14 @@ import { RacesApiClient } from '@/lib/api/races/RacesApiClient'; import { Result } from '@/lib/contracts/Result'; -import { DomainError } from '@/lib/contracts/services/Service'; +import { DomainError, Service } from '@/lib/contracts/services/Service'; 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 type { RacesPageDataDTO } from '@/lib/types/generated/RacesPageDataDTO'; +import type { RaceDetailDTO } from '@/lib/api/races/RacesApiClient'; +import type { RaceResultsDetailDTO } from '@/lib/types/generated/RaceResultsDetailDTO'; +import type { RaceWithSOFDTO } from '@/lib/types/generated/RaceWithSOFDTO'; /** * Races Service @@ -12,7 +16,7 @@ import { ApiError } from '@/lib/api/base/ApiError'; * Orchestration service for race-related operations. * Returns raw API DTOs. No ViewModels or UX logic. */ -export class RacesService { +export class RacesService implements Service { private apiClient: RacesApiClient; constructor() { @@ -28,21 +32,12 @@ export class RacesService { * Get races page data * Returns races for the main races page */ - async getRacesPageData(): Promise> { + async getRacesPageData(): Promise> { try { const data = await this.apiClient.getPageData(); return Result.ok(data); } catch (error) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: 'Failed to fetch races page data' - }); + return Result.err(this.mapError(error, 'Failed to fetch races page data')); } } @@ -50,21 +45,12 @@ export class RacesService { * Get race detail * Returns detailed information for a specific race */ - async getRaceDetail(raceId: string, driverId: string): Promise> { + async getRaceDetail(raceId: string, driverId: string): Promise> { try { const data = await this.apiClient.getDetail(raceId, driverId); return Result.ok(data); } catch (error) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: 'Failed to fetch race detail' - }); + return Result.err(this.mapError(error, 'Failed to fetch race detail')); } } @@ -72,21 +58,12 @@ export class RacesService { * Get race results detail * Returns results for a specific race */ - async getRaceResultsDetail(raceId: string): Promise> { + async getRaceResultsDetail(raceId: string): Promise> { try { const data = await this.apiClient.getResultsDetail(raceId); return Result.ok(data); } catch (error) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: 'Failed to fetch race results' - }); + return Result.err(this.mapError(error, 'Failed to fetch race results')); } } @@ -94,21 +71,12 @@ export class RacesService { * Get race with strength of field * Returns race data with SOF calculation */ - async getRaceWithSOF(raceId: string): Promise> { + async getRaceWithSOF(raceId: string): Promise> { try { const data = await this.apiClient.getWithSOF(raceId); return Result.ok(data); } catch (error) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: 'Failed to fetch race SOF' - }); + return Result.err(this.mapError(error, 'Failed to fetch race SOF')); } } @@ -116,24 +84,28 @@ export class RacesService { * Get all races for the all races page * Returns all races with pagination support */ - async getAllRacesPageData(): Promise> { + async getAllRacesPageData(): Promise> { try { const data = await this.apiClient.getPageData(); return Result.ok(data); } catch (error) { - if (error instanceof ApiError) { - return Result.err({ - type: this.mapApiErrorType(error.type), - message: error.message - }); - } - return Result.err({ - type: 'unknown', - message: 'Failed to fetch all races' - }); + return Result.err(this.mapError(error, 'Failed to fetch all races')); } } + private mapError(error: unknown, defaultMessage: string): DomainError { + if (error instanceof ApiError) { + return { + type: this.mapApiErrorType(error.type), + message: error.message + }; + } + return { + type: 'unknown', + message: defaultMessage + }; + } + private mapApiErrorType(apiErrorType: string): DomainError['type'] { switch (apiErrorType) { case 'NOT_FOUND': @@ -150,4 +122,4 @@ export class RacesService { return 'unknown'; } } -} \ No newline at end of file +} diff --git a/apps/website/lib/view-data/LeagueAdminScheduleViewData.ts b/apps/website/lib/view-data/LeagueAdminScheduleViewData.ts new file mode 100644 index 000000000..8d8178501 --- /dev/null +++ b/apps/website/lib/view-data/LeagueAdminScheduleViewData.ts @@ -0,0 +1,17 @@ +export interface AdminScheduleRaceData { + id: string; + name: string; + track: string; + car: string; + scheduledAt: string; // ISO string +} + +export interface LeagueAdminScheduleViewData { + published: boolean; + races: AdminScheduleRaceData[]; + seasons: Array<{ + seasonId: string; + name: string; + }>; + seasonId: string; +} diff --git a/apps/website/lib/view-data/LeagueRulebookViewData.ts b/apps/website/lib/view-data/LeagueRulebookViewData.ts new file mode 100644 index 000000000..5c137483f --- /dev/null +++ b/apps/website/lib/view-data/LeagueRulebookViewData.ts @@ -0,0 +1,19 @@ +export interface RulebookScoringConfig { + scoringPresetName: string | null; + gameName: string; + championships: Array<{ + type: string; + sessionTypes: string[]; + pointsPreview: Array<{ + sessionType: string; + position: number; + points: number; + }>; + bonusSummary: string[]; + }>; + dropPolicySummary: string; +} + +export interface LeagueRulebookViewData { + scoringConfig: RulebookScoringConfig | null; +} diff --git a/apps/website/lib/view-data/RacesViewData.ts b/apps/website/lib/view-data/RacesViewData.ts new file mode 100644 index 000000000..e5a91a6e1 --- /dev/null +++ b/apps/website/lib/view-data/RacesViewData.ts @@ -0,0 +1,35 @@ +export interface RaceViewData { + id: string; + track: string; + car: string; + scheduledAt: string; + scheduledAtLabel: string; + timeLabel: string; + relativeTimeLabel: string; + status: 'scheduled' | 'running' | 'completed' | 'cancelled'; + statusLabel: string; + sessionType: string; + leagueId: string | null; + leagueName: string | null; + strengthOfField: number | null; + isUpcoming: boolean; + isLive: boolean; + isPast: boolean; +} + +export interface RacesViewData { + races: RaceViewData[]; + totalCount: number; + scheduledCount: number; + runningCount: number; + completedCount: number; + leagues: Array<{ id: string; name: string }>; + upcomingRaces: RaceViewData[]; + liveRaces: RaceViewData[]; + recentResults: RaceViewData[]; + racesByDate: Array<{ + dateKey: string; + dateLabel: string; + races: RaceViewData[]; + }>; +} diff --git a/apps/website/lib/view-data/leagues/LeagueWalletViewData.ts b/apps/website/lib/view-data/leagues/LeagueWalletViewData.ts index 868ea69f0..34501d3c8 100644 --- a/apps/website/lib/view-data/leagues/LeagueWalletViewData.ts +++ b/apps/website/lib/view-data/leagues/LeagueWalletViewData.ts @@ -1,13 +1,21 @@ +export interface LeagueWalletTransactionViewData { + id: string; + type: 'deposit' | 'withdrawal' | 'sponsorship' | 'prize'; + amount: number; + formattedAmount: string; + amountColor: string; + description: string; + createdAt: string; + formattedDate: string; + status: 'completed' | 'pending' | 'failed'; + statusColor: string; + typeColor: string; +} + export interface LeagueWalletViewData { leagueId: string; balance: number; + formattedBalance: string; currency: string; - transactions: Array<{ - id: string; - type: 'deposit' | 'withdrawal' | 'sponsorship' | 'prize'; - amount: number; - description: string; - createdAt: string; - status: 'completed' | 'pending' | 'failed'; - }>; -} \ No newline at end of file + transactions: LeagueWalletTransactionViewData[]; +} diff --git a/apps/website/tsconfig.json b/apps/website/tsconfig.json index f851d8cfe..9a31a3eae 100644 --- a/apps/website/tsconfig.json +++ b/apps/website/tsconfig.json @@ -76,7 +76,7 @@ "types/", "utilities/", ".next/types/**/*.ts" - ], +, "hooks/sponsor/useSponsorMode.ts" ], "exclude": [ "**/*.test.ts", "**/*.test.tsx", diff --git a/apps/website/ui/Box.tsx b/apps/website/ui/Box.tsx index 5d84c687a..e1514a91b 100644 --- a/apps/website/ui/Box.tsx +++ b/apps/website/ui/Box.tsx @@ -45,9 +45,9 @@ export const Box = forwardRef(( maxWidth, ...props }: BoxProps & ComponentPropsWithoutRef, - ref: ForwardedRef + ref: ForwardedRef ) => { - const Tag = (as as any) || 'div'; + const Tag = (as as ElementType) || 'div'; const spacingMap: Record = { 0: '0', 0.5: '0.5', 1: '1', 1.5: '1.5', 2: '2', 2.5: '2.5', 3: '3', 3.5: '3.5', 4: '4', @@ -81,10 +81,10 @@ export const Box = forwardRef(( className ].filter(Boolean).join(' '); - const style = maxWidth ? { maxWidth, ...((props as any).style || {}) } : (props as any).style; + const style = maxWidth ? { maxWidth, ...((props as Record).style as object || {}) } : (props as Record).style; return ( - + } className={classes} {...props} style={style as React.CSSProperties}> {children} ); diff --git a/apps/website/ui/CountryFlag.tsx b/apps/website/ui/CountryFlag.tsx index 3a2c52002..2bafce063 100644 --- a/apps/website/ui/CountryFlag.tsx +++ b/apps/website/ui/CountryFlag.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState } from 'react'; +import React from 'react'; // ISO 3166-1 alpha-2 country code to full country name mapping const countryNames: Record = { @@ -78,8 +78,6 @@ export function CountryFlag({ className = '', showTooltip = true }: CountryFlagProps) { - const [showTooltipState, setShowTooltipState] = useState(false); - const sizeClasses = { sm: 'text-xs', md: 'text-sm', @@ -92,17 +90,9 @@ export function CountryFlag({ return ( setShowTooltipState(true)} - onMouseLeave={() => setShowTooltipState(false)} title={showTooltip ? countryName : undefined} > {flag} - {showTooltip && showTooltipState && ( - - {countryName} - - - )} ); } diff --git a/apps/website/ui/Modal.tsx b/apps/website/ui/Modal.tsx index 72c77517f..ad7c9efa7 100644 --- a/apps/website/ui/Modal.tsx +++ b/apps/website/ui/Modal.tsx @@ -1,8 +1,6 @@ 'use client'; import React, { - useEffect, - useRef, type ReactNode, type KeyboardEvent as ReactKeyboardEvent, } from 'react'; @@ -34,26 +32,6 @@ export function Modal({ onOpenChange, isOpen, }: ModalProps) { - const dialogRef = useRef(null); - const previouslyFocusedElementRef = useRef(null); - - useEffect(() => { - if (isOpen) { - previouslyFocusedElementRef.current = document.activeElement; - const focusable = getFirstFocusable(dialogRef.current); - if (focusable) { - focusable.focus(); - } else if (dialogRef.current) { - dialogRef.current.focus(); - } - return; - } - - if (!isOpen && previouslyFocusedElementRef.current instanceof HTMLElement) { - previouslyFocusedElementRef.current.focus(); - } - }, [isOpen]); - const handleKeyDown = (event: ReactKeyboardEvent) => { if (event.key === 'Escape') { if (onOpenChange) { @@ -61,26 +39,6 @@ export function Modal({ } return; } - - if (event.key === 'Tab') { - const focusable = getFocusableElements(dialogRef.current); - if (focusable.length === 0) return; - - const first = focusable[0]; - const last = focusable[focusable.length - 1] ?? first; - - if (!first || !last) { - return; - } - - if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } else if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } - } }; const handleBackdropClick = (event: React.MouseEvent) => { @@ -104,7 +62,6 @@ export function Modal({ onClick={handleBackdropClick} > @@ -162,24 +119,3 @@ export function Modal({ ); } - -function getFocusableElements(root: HTMLElement | null): HTMLElement[] { - if (!root) return []; - const selectors = [ - 'a[href]', - 'button:not([disabled])', - 'textarea:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - '[tabindex]:not([tabindex="-1"])', - ]; - const nodes = Array.from( - root.querySelectorAll(selectors.join(',')), - ); - return nodes.filter((el) => !el.hasAttribute('disabled') && !el.getAttribute('aria-hidden')); -} - -function getFirstFocusable(root: HTMLElement | null): HTMLElement | null { - const elements = getFocusableElements(root); - return elements[0] ?? null; -} diff --git a/apps/website/ui/Select.tsx b/apps/website/ui/Select.tsx index fdc1ad138..26e4acde3 100644 --- a/apps/website/ui/Select.tsx +++ b/apps/website/ui/Select.tsx @@ -1,11 +1,11 @@ -import React, { ChangeEvent } from 'react'; +import React, { ChangeEvent, SelectHTMLAttributes } from 'react'; interface SelectOption { value: string; label: string; } -interface SelectProps extends React.SelectHTMLAttributes { +interface SelectProps extends SelectHTMLAttributes { id?: string; 'aria-label'?: string; value?: string; diff --git a/apps/website/ui/Toggle.tsx b/apps/website/ui/Toggle.tsx index b43d4fa50..2f184b39c 100644 --- a/apps/website/ui/Toggle.tsx +++ b/apps/website/ui/Toggle.tsx @@ -1,7 +1,4 @@ -'use client'; - -import React from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; +import { motion } from 'framer-motion'; import { Box } from './Box'; import { Text } from './Text'; @@ -20,8 +17,6 @@ export function Toggle({ description, disabled = false, }: ToggleProps) { - const shouldReduceMotion = useReducedMotion(); - return (