diff --git a/apps/website/app/auth/forgot-password/page.tsx b/apps/website/app/auth/forgot-password/page.tsx index e3c5e8f85..89666c6e5 100644 --- a/apps/website/app/auth/forgot-password/page.tsx +++ b/apps/website/app/auth/forgot-password/page.tsx @@ -3,6 +3,7 @@ import { useState, useEffect, FormEvent, type ChangeEvent } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/auth/AuthContext'; +import { useForgotPassword } from '@/hooks/auth/useForgotPassword'; import Link from 'next/link'; import { motion } from 'framer-motion'; import { @@ -33,7 +34,6 @@ export default function ForgotPasswordPage() { const router = useRouter(); const { session } = useAuth(); - const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({}); const [success, setSuccess] = useState(null); const [formData, setFormData] = useState({ @@ -60,34 +60,40 @@ export default function ForgotPasswordPage() { return Object.keys(newErrors).length === 0; }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (loading) return; - - if (!validateForm()) return; - - setLoading(true); - setErrors({}); - setSuccess(null); - - try { - const { ServiceFactory } = await import('@/lib/services/ServiceFactory'); - const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); - const authService = serviceFactory.createAuthService(); - const result = await authService.forgotPassword({ email: formData.email }); - + // Use forgot password mutation hook + const forgotPasswordMutation = useForgotPassword({ + onSuccess: (result) => { setSuccess({ message: result.message, magicLink: result.magicLink, }); - } catch (error) { + }, + onError: (error) => { setErrors({ - submit: error instanceof Error ? error.message : 'Failed to send reset link. Please try again.', + submit: error.message || 'Failed to send reset link. Please try again.', }); - setLoading(false); + }, + }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (forgotPasswordMutation.isPending) return; + + if (!validateForm()) return; + + setErrors({}); + setSuccess(null); + + try { + await forgotPasswordMutation.mutateAsync({ email: formData.email }); + } catch (error) { + // Error handling is done in the mutation's onError callback } }; + // Loading state from mutation + const loading = forgotPasswordMutation.isPending; + return (
{/* Background Pattern */} diff --git a/apps/website/app/auth/login/page.tsx b/apps/website/app/auth/login/page.tsx index cda40e709..52aedaed6 100644 --- a/apps/website/app/auth/login/page.tsx +++ b/apps/website/app/auth/login/page.tsx @@ -20,6 +20,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; +import { useLogin } from '@/hooks/auth/useLogin'; import AuthWorkflowMockup from '@/components/auth/AuthWorkflowMockup'; import UserRolesPreview from '@/components/auth/UserRolesPreview'; import { EnhancedFormError, FormErrorSummary } from '@/components/errors/EnhancedFormError'; @@ -37,6 +38,21 @@ export default function LoginPage() { const [showErrorDetails, setShowErrorDetails] = useState(false); const [hasInsufficientPermissions, setHasInsufficientPermissions] = useState(false); + // Use login mutation hook + const loginMutation = useLogin({ + onSuccess: async () => { + // Refresh session in context so header updates immediately + await refreshSession(); + router.push(returnTo); + }, + onError: (error) => { + // Show error details toggle in development + if (process.env.NODE_ENV === 'development') { + setShowErrorDetails(true); + } + }, + }); + // Check if user is already authenticated useEffect(() => { console.log('[LoginPage] useEffect running', { @@ -84,10 +100,6 @@ export default function LoginPage() { validate: validateLoginForm, component: 'LoginPage', onSubmit: async (values) => { - const { ServiceFactory } = await import('@/lib/services/ServiceFactory'); - const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); - const authService = serviceFactory.createAuthService(); - // Log the attempt for debugging logErrorWithContext( { message: 'Login attempt', values: { ...values, password: '[REDACTED]' } }, @@ -98,21 +110,14 @@ export default function LoginPage() { } ); - await authService.login({ + await loginMutation.mutateAsync({ email: values.email, password: values.password, rememberMe: values.rememberMe, }); - - // Refresh session in context so header updates immediately - await refreshSession(); - router.push(returnTo); }, onError: (error, values) => { - // Show error details toggle in development - if (process.env.NODE_ENV === 'development') { - setShowErrorDetails(true); - } + // Error handling is done in the mutation's onError callback }, onSuccess: () => { // Reset error details on success diff --git a/apps/website/app/auth/reset-password/page.tsx b/apps/website/app/auth/reset-password/page.tsx index 94a145d6a..8b1579efe 100644 --- a/apps/website/app/auth/reset-password/page.tsx +++ b/apps/website/app/auth/reset-password/page.tsx @@ -20,6 +20,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; +import { useResetPassword } from '@/hooks/auth/useResetPassword'; interface FormErrors { newPassword?: string; @@ -52,7 +53,6 @@ export default function ResetPasswordPage() { const searchParams = useSearchParams(); const { session } = useAuth(); - const [loading, setLoading] = useState(false); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); const [errors, setErrors] = useState({}); @@ -112,34 +112,40 @@ export default function ResetPasswordPage() { return Object.keys(newErrors).length === 0; }; + // Use reset password mutation hook + const resetPasswordMutation = useResetPassword({ + onSuccess: (result) => { + setSuccess(result.message); + }, + onError: (error) => { + setErrors({ + submit: error.message || 'Failed to reset password. Please try again.', + }); + }, + }); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (loading) return; + if (resetPasswordMutation.isPending) return; if (!validateForm()) return; - setLoading(true); setErrors({}); setSuccess(null); try { - const { ServiceFactory } = await import('@/lib/services/ServiceFactory'); - const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); - const authService = serviceFactory.createAuthService(); - const result = await authService.resetPassword({ + await resetPasswordMutation.mutateAsync({ token, newPassword: formData.newPassword, }); - - setSuccess(result.message); } catch (error) { - setErrors({ - submit: error instanceof Error ? error.message : 'Failed to reset password. Please try again.', - }); - setLoading(false); + // Error handling is done in the mutation's onError callback } }; + // Loading state from mutation + const loading = resetPasswordMutation.isPending; + return (
{/* Background Pattern */} diff --git a/apps/website/app/auth/signup/page.tsx b/apps/website/app/auth/signup/page.tsx index b6bb872d8..4d75d02da 100644 --- a/apps/website/app/auth/signup/page.tsx +++ b/apps/website/app/auth/signup/page.tsx @@ -28,6 +28,7 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import { useAuth } from '@/lib/auth/AuthContext'; +import { useSignup } from '@/hooks/auth/useSignup'; interface FormErrors { firstName?: string; @@ -94,7 +95,6 @@ export default function SignupPage() { const { refreshSession, session } = useAuth(); const returnTo = searchParams.get('returnTo') ?? '/onboarding'; - const [loading, setLoading] = useState(false); const [checkingAuth, setCheckingAuth] = useState(true); const [showPassword, setShowPassword] = useState(false); const [showConfirmPassword, setShowConfirmPassword] = useState(false); @@ -198,29 +198,9 @@ export default function SignupPage() { return Object.keys(newErrors).length === 0; }; - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - if (loading) return; - - if (!validateForm()) return; - - setLoading(true); - setErrors({}); - - try { - const { ServiceFactory } = await import('@/lib/services/ServiceFactory'); - const serviceFactory = new ServiceFactory(process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'); - const authService = serviceFactory.createAuthService(); - - // Combine first and last name into display name - const displayName = `${formData.firstName} ${formData.lastName}`.trim(); - - await authService.signup({ - email: formData.email, - password: formData.password, - displayName, - }); - + // Use signup mutation hook + const signupMutation = useSignup({ + onSuccess: async () => { // Refresh session in context so header updates immediately try { await refreshSession(); @@ -229,14 +209,39 @@ export default function SignupPage() { } // Always redirect to dashboard after signup router.push('/dashboard'); - } catch (error) { + }, + onError: (error) => { setErrors({ - submit: error instanceof Error ? error.message : 'Signup failed. Please try again.', + submit: error.message || 'Signup failed. Please try again.', }); - setLoading(false); + }, + }); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (signupMutation.isPending) return; + + if (!validateForm()) return; + + setErrors({}); + + try { + // Combine first and last name into display name + const displayName = `${formData.firstName} ${formData.lastName}`.trim(); + + await signupMutation.mutateAsync({ + email: formData.email, + password: formData.password, + displayName, + }); + } catch (error) { + // Error handling is done in the mutation's onError callback } }; + // Loading state from mutation + const loading = signupMutation.isPending; + // Show loading while checking auth if (checkingAuth) { return ( diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index f02093a32..db00b7a52 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -1,48 +1,40 @@ 'use client'; -import React from 'react'; +import { + Activity, + Award, + Calendar, + ChevronRight, + Clock, + Flag, + Medal, + Play, + Star, + Target, + Trophy, + UserPlus, + Users, +} from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; -import { - Calendar, - Trophy, - Users, - Star, - Clock, - Flag, - ChevronRight, - Target, - Award, - Activity, - Play, - Medal, - UserPlus, -} from 'lucide-react'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { StatCard } from '@/components/dashboard/StatCard'; -import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem'; -import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem'; -import { FriendItem } from '@/components/dashboard/FriendItem'; import { FeedItemRow } from '@/components/dashboard/FeedItemRow'; +import { FriendItem } from '@/components/dashboard/FriendItem'; +import { LeagueStandingItem } from '@/components/dashboard/LeagueStandingItem'; +import { StatCard } from '@/components/dashboard/StatCard'; +import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem'; +import Button from '@/components/ui/Button'; +import Card from '@/components/ui/Card'; import { getCountryFlag } from '@/lib/utilities/country'; import { getGreeting, timeUntil } from '@/lib/utilities/time'; -// Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { EmptyState } from '@/components/shared/state/EmptyState'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDashboardOverview } from '@/hooks/dashboard/useDashboardOverview'; +import { DashboardOverviewViewModel } from '@/lib/view-models/DashboardOverviewViewModel'; export default function DashboardPage() { - const { dashboardService } = useServices(); - - const { data: dashboardData, isLoading, error, retry } = useDataFetching({ - queryKey: ['dashboardOverview'], - queryFn: () => dashboardService.getDashboardOverview(), - }); + const { data: dashboardData, isLoading, error, retry } = useDashboardOverview(); return ( - {(data) => { - const currentDriver = data.currentDriver; - const nextRace = data.nextRace; - const upcomingRaces = data.upcomingRaces; - const leagueStandingsSummaries = data.leagueStandings; - const feedSummary = { items: data.feedItems }; - const friends = data.friends; - const activeLeaguesCount = data.activeLeaguesCount; + {(data: DashboardOverviewViewModel) => { + // StateContainer ensures data is non-null when this renders + const dashboardData = data!; + const currentDriver = dashboardData.currentDriver; + const nextRace = dashboardData.nextRace; + const upcomingRaces = dashboardData.upcomingRaces; + const leagueStandingsSummaries = dashboardData.leagueStandings; + const feedSummary = { items: dashboardData.feedItems }; + const friends = dashboardData.friends; + const activeLeaguesCount = dashboardData.activeLeaguesCount; const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver; return (
- {/* Hero Section */} -
- {/* Background Pattern */} -
-
-
-
- -
-
- {/* Welcome Message */} -
-
-
-
- {currentDriver.name} -
+ {/* Hero Section */} +
+ {/* Background Pattern */} +
+
+
-
-
-
-

{getGreeting()},

-

- {currentDriver.name} - {getCountryFlag(currentDriver.country)} -

-
-
- - {rating} -
-
- - #{globalRank} -
- {totalRaces} races completed -
-
-
- {/* Quick Actions */} -
- - - - - - -
-
- - {/* Quick Stats Row */} -
- - - - -
-
-
- - {/* Main Content */} -
-
- {/* Left Column - Main Content */} -
- {/* Next Race Card */} - {nextRace && ( - -
-
-
-
- - Next Race -
- {nextRace.isMyLeague && ( - - Your League - - )} -
- -
-
-

{nextRace.track}

-

{nextRace.car}

-
- - - {nextRace.scheduledAt.toLocaleDateString('en-US', { - weekday: 'long', - month: 'short', - day: 'numeric', - })} - - - - {nextRace.scheduledAt.toLocaleTimeString('en-US', { - hour: '2-digit', - minute: '2-digit', - })} - +
+
+ {/* Welcome Message */} +
+
+
+
+ {currentDriver.name} +
+
+
+
+
+

{getGreeting()},

+

+ {currentDriver.name} + {getCountryFlag(currentDriver.country)} +

+
+
+ + {rating} +
+
+ + #{globalRank} +
+ {totalRaces} races completed +
- -
-
-

Starts in

-

{timeUntil(nextRace.scheduledAt)}

-
- + + {/* Quick Actions */} +
+ + + +
-
- - )} - {/* League Standings Preview */} - {leagueStandingsSummaries.length > 0 && ( - -
-

- - Your Championship Standings -

- - View all - + {/* Quick Stats Row */} +
+ + + + +
-
- {leagueStandingsSummaries.map(({ leagueId, leagueName, position, points, totalDrivers }) => ( - - ))} -
-
- )} +
- {/* Activity Feed */} - -
-

- - Recent Activity -

-
- {feedSummary.items.length > 0 ? ( -
- {feedSummary.items.slice(0, 5).map((item) => ( - - ))} -
- ) : ( -
- -

No activity yet

-

Join leagues and add friends to see activity here

-
- )} -
- + {/* Main Content */} +
+
+ {/* Left Column - Main Content */} +
+ {/* Next Race Card */} + {nextRace && ( + +
+
+
+
+ + Next Race +
+ {nextRace.isMyLeague && ( + + Your League + + )} +
+ +
+
+

{nextRace.track}

+

{nextRace.car}

+
+ + + {nextRace.scheduledAt.toLocaleDateString('en-US', { + weekday: 'long', + month: 'short', + day: 'numeric', + })} + + + + {nextRace.scheduledAt.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + })} + +
+
+ +
+
+

Starts in

+

{timeUntil(nextRace.scheduledAt)}

+
+ + + +
+
+
+ + )} - {/* Right Column - Sidebar */} -
- {/* Upcoming Races */} - -
-

- - Upcoming Races -

- - View all - -
- {upcomingRaces.length > 0 ? ( -
- {upcomingRaces.slice(0, 5).map((race) => ( - - ))} -
- ) : ( -

No upcoming races

- )} -
+ {/* League Standings Preview */} + {leagueStandingsSummaries.length > 0 && ( + +
+

+ + Your Championship Standings +

+ + View all + +
+
+ {leagueStandingsSummaries.map((summary: any) => ( + + ))} +
+
+ )} - {/* Friends */} - -
-

- - Friends -

- {friends.length} friends -
- {friends.length > 0 ? ( -
- {friends.slice(0, 6).map((friend) => ( - - ))} - {friends.length > 6 && ( - - +{friends.length - 6} more - - )} + {/* Activity Feed */} + +
+

+ + Recent Activity +

+
+ {feedSummary.items.length > 0 ? ( +
+ {feedSummary.items.slice(0, 5).map((item: any) => ( + + ))} +
+ ) : ( +
+ +

No activity yet

+

Join leagues and add friends to see activity here

+
+ )} +
+
+ + {/* Right Column - Sidebar */} +
+ {/* Upcoming Races */} + +
+

+ + Upcoming Races +

+ + View all + +
+ {upcomingRaces.length > 0 ? ( +
+ {upcomingRaces.slice(0, 5).map((race: any) => ( + + ))} +
+ ) : ( +

No upcoming races

+ )} +
+ + {/* Friends */} + +
+

+ + Friends +

+ {friends.length} friends +
+ {friends.length > 0 ? ( +
+ {friends.slice(0, 6).map((friend: any) => ( + + ))} + {friends.length > 6 && ( + + +{friends.length - 6} more + + )} +
+ ) : ( +
+ +

No friends yet

+ + + +
+ )} +
+
- ) : ( -
- -

No friends yet

- - - -
- )} - -
-
-
-
- ); - }} -
- ); -} + +
+ ); + }} + + ); +} \ No newline at end of file diff --git a/apps/website/app/drivers/DriversInteractive.tsx b/apps/website/app/drivers/DriversInteractive.tsx index 0c7e39541..8877a1aba 100644 --- a/apps/website/app/drivers/DriversInteractive.tsx +++ b/apps/website/app/drivers/DriversInteractive.tsx @@ -1,29 +1,23 @@ 'use client'; -import { useRouter } from 'next/navigation'; -import { DriversTemplate } from '@/templates/DriversTemplate'; import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; +import { DriversTemplate } from '@/templates/DriversTemplate'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard'; import { Users } from 'lucide-react'; export function DriversInteractive() { - const router = useRouter(); - const { driverService } = useServices(); - const { data: viewModel, isLoading: loading, error, retry } = useDataFetching({ - queryKey: ['driverLeaderboard'], - queryFn: () => driverService.getDriverLeaderboard(), - }); + const { data: viewModel, isLoading: loading, error, retry } = useDriverLeaderboard(); const drivers = viewModel?.drivers || []; const totalRaces = viewModel?.totalRaces || 0; const totalWins = viewModel?.totalWins || 0; const activeCount = viewModel?.activeCount || 0; + // TODO this should not be done in a page, thats part of the service?? // Transform data for template const driverViewModels = drivers.map((driver, index) => new DriverLeaderboardItemViewModel(driver, index + 1) @@ -45,7 +39,7 @@ export function DriversInteractive() { } }} > - {(leaderboardData) => ( + {() => ( ('overview'); const [friendRequestSent, setFriendRequestSent] = useState(false); const isSponsorMode = useSponsorMode(); - // Fetch driver profile - const { data: driverProfile, isLoading, error, retry } = useDataFetching({ + // Fetch driver profile using React-Query + const { data: driverProfile, isLoading, error, refetch } = useQuery({ queryKey: ['driverProfile', driverId], queryFn: () => driverService.getDriverProfile(driverId), }); - // Fetch team memberships - const { data: allTeamMemberships } = useDataFetching({ + // Fetch team memberships using React-Query + const { data: allTeamMemberships } = useQuery({ queryKey: ['driverTeamMemberships', driverId], queryFn: async () => { if (!driverProfile?.currentDriver) return []; @@ -100,38 +99,71 @@ export function DriverProfileInteractive() { /> ) : null; + // Loading state + if (isLoading) { + return ( +
+
+
+

Loading driver profile...

+
+
+ ); + } + + // Error state + if (error) { + return ( +
+
+
⚠️
+

Error loading driver profile

+

{error.message}

+ +
+
+ ); + } + + // Empty state + if (!driverProfile) { + return ( +
+
+
+ +
+

Driver not found

+

The driver profile may not exist or you may not have access

+ +
+
+ ); + } + return ( - - {(profileData) => ( - - )} - + ); - } \ No newline at end of file +} \ No newline at end of file diff --git a/apps/website/app/layout.tsx b/apps/website/app/layout.tsx index 7e7fe7171..ac801757b 100644 --- a/apps/website/app/layout.tsx +++ b/apps/website/app/layout.tsx @@ -5,7 +5,7 @@ import NotificationProvider from '@/components/notifications/NotificationProvide import { AuthProvider } from '@/lib/auth/AuthContext'; import { FeatureFlagService } from '@/lib/feature/FeatureFlagService'; import { FeatureFlagProvider } from '@/lib/feature/FeatureFlagProvider'; -import { ServiceProvider } from '@/lib/services/ServiceProvider'; +import { ContainerProvider } from '@/lib/di/providers/ContainerProvider'; import { initializeGlobalErrorHandling } from '@/lib/infrastructure/GlobalErrorHandler'; import { initializeApiLogger } from '@/lib/infrastructure/ApiRequestLogger'; import { Metadata, Viewport } from 'next'; @@ -80,7 +80,7 @@ export default async function RootLayout({ - + @@ -114,7 +114,7 @@ export default async function RootLayout({ - + ); diff --git a/apps/website/app/leaderboards/LeaderboardsStatic.tsx b/apps/website/app/leaderboards/LeaderboardsStatic.tsx index 96727638e..478c81439 100644 --- a/apps/website/app/leaderboards/LeaderboardsStatic.tsx +++ b/apps/website/app/leaderboards/LeaderboardsStatic.tsx @@ -6,24 +6,16 @@ import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLea import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard'; +import { useAllTeams } from '@/hooks/team/useAllTeams'; import { Trophy } from 'lucide-react'; export default function LeaderboardsStatic() { const router = useRouter(); - const { driverService, teamService } = useServices(); - const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDataFetching({ - queryKey: ['driverLeaderboard'], - queryFn: () => driverService.getDriverLeaderboard(), - }); - - const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useDataFetching({ - queryKey: ['allTeams'], - queryFn: () => teamService.getAllTeams(), - }); + const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDriverLeaderboard(); + const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useAllTeams(); const handleDriverClick = (driverId: string) => { router.push(`/drivers/${driverId}`); diff --git a/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx b/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx index d1afdb233..1ebfb1ad0 100644 --- a/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx +++ b/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx @@ -6,9 +6,8 @@ import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverLeaderboard } from '@/hooks/driver/useDriverLeaderboard'; import { Users } from 'lucide-react'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; @@ -16,17 +15,13 @@ type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; export default function DriverRankingsStatic() { const router = useRouter(); - const { driverService } = useServices(); const [searchQuery, setSearchQuery] = useState(''); const [selectedSkill, setSelectedSkill] = useState<'all' | SkillLevel>('all'); const [sortBy, setSortBy] = useState('rank'); const [showFilters, setShowFilters] = useState(false); - const { data: driverData, isLoading, error, retry } = useDataFetching({ - queryKey: ['driverLeaderboard'], - queryFn: () => driverService.getDriverLeaderboard(), - }); + const { data: driverData, isLoading, error, retry } = useDriverLeaderboard(); const handleDriverClick = (driverId: string) => { if (driverId.startsWith('demo-')) return; diff --git a/apps/website/app/leagues/LeaguesInteractive.tsx b/apps/website/app/leagues/LeaguesInteractive.tsx index e400538ea..9b208c0de 100644 --- a/apps/website/app/leagues/LeaguesInteractive.tsx +++ b/apps/website/app/leagues/LeaguesInteractive.tsx @@ -3,22 +3,17 @@ import { useState, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { LeaguesTemplate } from '@/templates/LeaguesTemplate'; -import { useServices } from '@/lib/services/ServiceProvider'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; +import { useAllLeagues } from '@/hooks/league/useAllLeagues'; import { Trophy } from 'lucide-react'; export default function LeaguesInteractive() { const router = useRouter(); - const { leagueService } = useServices(); - const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({ - queryKey: ['allLeagues'], - queryFn: () => leagueService.getAllLeagues(), - }); + const { data: realLeagues = [], isLoading: loading, error, retry } = useAllLeagues(); const handleLeagueClick = (leagueId: string) => { router.push(`/leagues/${leagueId}`); diff --git a/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx b/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx index 643e0baaf..7e97fc7e6 100644 --- a/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx +++ b/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx @@ -1,16 +1,17 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate'; -import { useServices } from '@/lib/services/ServiceProvider'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard'; import EndRaceModal from '@/components/leagues/EndRaceModal'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; +import { useLeagueDetailWithSponsors } from '@/hooks/league/useLeagueDetailWithSponsors'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_MEMBERSHIP_SERVICE_TOKEN, RACE_SERVICE_TOKEN } from '@/lib/di/tokens'; import { Trophy } from 'lucide-react'; export default function LeagueDetailInteractive() { @@ -18,17 +19,15 @@ export default function LeagueDetailInteractive() { const params = useParams(); const leagueId = params.id as string; const isSponsor = useSponsorMode(); - const { leagueService, leagueMembershipService, raceService } = useServices(); + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); + const raceService = useInject(RACE_SERVICE_TOKEN); const [endRaceModalRaceId, setEndRaceModalRaceId] = useState(null); const currentDriverId = useEffectiveDriverId(); const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - const { data: viewModel, isLoading, error, retry } = useDataFetching({ - queryKey: ['leagueDetailPage', leagueId], - queryFn: () => leagueService.getLeagueDetailPageData(leagueId), - }); + const { data: viewModel, isLoading, error, retry } = useLeagueDetailWithSponsors(leagueId); const handleMembershipChange = () => { retry(); @@ -82,7 +81,7 @@ export default function LeagueDetailInteractive() { {(leagueData) => ( <> (null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - async function loadLeague() { - try { - const leagueDetailData = await leagueService.getLeagueDetail(leagueId, currentDriverId); - - setLeagueDetail(leagueDetailData); - } catch (error) { - console.error('Failed to load league:', error); - } finally { - setLoading(false); - } - } - - loadLeague(); - }, [leagueId, currentDriverId, leagueService]); + const { data: leagueDetail, isLoading: loading } = useLeagueDetail(leagueId, currentDriverId); if (loading) { return ( diff --git a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx index 9ed2ded6b..76b84c5bc 100644 --- a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx +++ b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import '@testing-library/jest-dom'; import type { Mocked } from 'vitest'; import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel'; @@ -21,15 +22,70 @@ vi.mock('next/navigation', () => ({ useParams: () => ({ id: 'league-1' }), })); -vi.mock('@/lib/services/ServiceProvider', async (importOriginal) => { - const actual = (await importOriginal()) as object; - return { - ...actual, - useServices: () => ({ - leagueService: mockLeagueService, - }), - }; -}); +// Mock data storage +let mockJoinRequests: any[] = []; +let mockMembers: any[] = []; + +// Mock the new DI hooks +vi.mock('@/hooks/league/useLeagueRosterAdmin', () => ({ + useLeagueRosterJoinRequests: (leagueId: string) => ({ + data: [...mockJoinRequests], + isLoading: false, + isError: false, + isSuccess: true, + refetch: vi.fn(), + }), + useLeagueRosterMembers: (leagueId: string) => ({ + data: [...mockMembers], + isLoading: false, + isError: false, + isSuccess: true, + refetch: vi.fn(), + }), + useApproveJoinRequest: () => ({ + mutate: (params: any) => { + // Remove from join requests + mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId); + }, + mutateAsync: async (params: any) => { + mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId); + return { success: true }; + }, + isPending: false, + }), + useRejectJoinRequest: () => ({ + mutate: (params: any) => { + mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId); + }, + mutateAsync: async (params: any) => { + mockJoinRequests = mockJoinRequests.filter(req => req.id !== params.joinRequestId); + return { success: true }; + }, + isPending: false, + }), + useUpdateMemberRole: () => ({ + mutate: (params: any) => { + const member = mockMembers.find(m => m.driverId === params.driverId); + if (member) member.role = params.role; + }, + mutateAsync: async (params: any) => { + const member = mockMembers.find(m => m.driverId === params.driverId); + if (member) member.role = params.role; + return { success: true }; + }, + isPending: false, + }), + useRemoveMember: () => ({ + mutate: (params: any) => { + mockMembers = mockMembers.filter(m => m.driverId !== params.driverId); + }, + mutateAsync: async (params: any) => { + mockMembers = mockMembers.filter(m => m.driverId !== params.driverId); + return { success: true }; + }, + isPending: false, + }), +})); function makeJoinRequest(overrides: Partial = {}): LeagueAdminRosterJoinRequestViewModel { return { @@ -55,6 +111,10 @@ function makeMember(overrides: Partial = {}): describe('RosterAdminPage', () => { beforeEach(() => { + // Reset mock data + mockJoinRequests = []; + mockMembers = []; + mockLeagueService = { getAdminRosterJoinRequests: vi.fn(), getAdminRosterMembers: vi.fn(), @@ -76,8 +136,9 @@ describe('RosterAdminPage', () => { makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'admin' }), ]; - mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue(joinRequests); - mockLeagueService.getAdminRosterMembers.mockResolvedValue(members); + // Set mock data for hooks + mockJoinRequests = joinRequests; + mockMembers = members; render(); @@ -91,9 +152,8 @@ describe('RosterAdminPage', () => { }); it('approves a join request and removes it from the pending list', async () => { - mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })]); - mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]); - mockLeagueService.approveJoinRequest.mockResolvedValue({ success: true } as any); + mockJoinRequests = [makeJoinRequest({ id: 'jr-1', driverName: 'Driver One' })]; + mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]; render(); @@ -101,19 +161,14 @@ describe('RosterAdminPage', () => { fireEvent.click(screen.getByTestId('join-request-jr-1-approve')); - await waitFor(() => { - expect(mockLeagueService.approveJoinRequest).toHaveBeenCalledWith('league-1', 'jr-1'); - }); - await waitFor(() => { expect(screen.queryByText('Driver One')).not.toBeInTheDocument(); }); }); it('rejects a join request and removes it from the pending list', async () => { - mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })]); - mockLeagueService.getAdminRosterMembers.mockResolvedValue([makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]); - mockLeagueService.rejectJoinRequest.mockResolvedValue({ success: true } as any); + mockJoinRequests = [makeJoinRequest({ id: 'jr-2', driverName: 'Driver Two' })]; + mockMembers = [makeMember({ driverId: 'driver-10', driverName: 'Member Ten' })]; render(); @@ -121,21 +176,14 @@ describe('RosterAdminPage', () => { fireEvent.click(screen.getByTestId('join-request-jr-2-reject')); - await waitFor(() => { - expect(mockLeagueService.rejectJoinRequest).toHaveBeenCalledWith('league-1', 'jr-2'); - }); - await waitFor(() => { expect(screen.queryByText('Driver Two')).not.toBeInTheDocument(); }); }); it('changes a member role via service and updates the displayed role', async () => { - mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]); - mockLeagueService.getAdminRosterMembers.mockResolvedValue([ - makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' }), - ]); - mockLeagueService.updateMemberRole.mockResolvedValue({ success: true } as any); + mockJoinRequests = []; + mockMembers = [makeMember({ driverId: 'driver-11', driverName: 'Member Eleven', role: 'member' })]; render(); @@ -146,21 +194,14 @@ describe('RosterAdminPage', () => { fireEvent.change(roleSelect, { target: { value: 'admin' } }); - await waitFor(() => { - expect(mockLeagueService.updateMemberRole).toHaveBeenCalledWith('league-1', 'driver-11', 'admin'); - }); - await waitFor(() => { expect((screen.getByLabelText('Role for Member Eleven') as HTMLSelectElement).value).toBe('admin'); }); }); it('removes a member via service and removes them from the list', async () => { - mockLeagueService.getAdminRosterJoinRequests.mockResolvedValue([]); - mockLeagueService.getAdminRosterMembers.mockResolvedValue([ - makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' }), - ]); - mockLeagueService.removeMember.mockResolvedValue({ success: true } as any); + mockJoinRequests = []; + mockMembers = [makeMember({ driverId: 'driver-12', driverName: 'Member Twelve', role: 'member' })]; render(); @@ -168,10 +209,6 @@ describe('RosterAdminPage', () => { fireEvent.click(screen.getByTestId('member-driver-12-remove')); - await waitFor(() => { - expect(mockLeagueService.removeMember).toHaveBeenCalledWith('league-1', 'driver-12'); - }); - await waitFor(() => { expect(screen.queryByText('Member Twelve')).not.toBeInTheDocument(); }); diff --git a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx index c1180dc5f..e9c6bf8ec 100644 --- a/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx +++ b/apps/website/app/leagues/[id]/roster/admin/RosterAdminPage.tsx @@ -1,12 +1,17 @@ 'use client'; import Card from '@/components/ui/Card'; -import type { LeagueAdminRosterJoinRequestViewModel } from '@/lib/view-models/LeagueAdminRosterJoinRequestViewModel'; -import type { LeagueAdminRosterMemberViewModel } from '@/lib/view-models/LeagueAdminRosterMemberViewModel'; import type { MembershipRole } from '@/lib/types/MembershipRole'; -import { useServices } from '@/lib/services/ServiceProvider'; import { useParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +import { useMemo } from 'react'; +import { + useLeagueRosterJoinRequests, + useLeagueRosterMembers, + useApproveJoinRequest, + useRejectJoinRequest, + useUpdateMemberRole, + useRemoveMember, +} from '@/hooks/league/useLeagueRosterAdmin'; const ROLE_OPTIONS: MembershipRole[] = ['owner', 'admin', 'steward', 'member']; @@ -14,56 +19,56 @@ export function RosterAdminPage() { const params = useParams(); const leagueId = params.id as string; - const { leagueService } = useServices(); + // Fetch data using React-Query + DI + const { + data: joinRequests = [], + isLoading: loadingJoinRequests, + refetch: refetchJoinRequests, + } = useLeagueRosterJoinRequests(leagueId); - const [loading, setLoading] = useState(true); - const [joinRequests, setJoinRequests] = useState([]); - const [members, setMembers] = useState([]); + const { + data: members = [], + isLoading: loadingMembers, + refetch: refetchMembers, + } = useLeagueRosterMembers(leagueId); - const loadRoster = async () => { - setLoading(true); - try { - const [requestsVm, membersVm] = await Promise.all([ - leagueService.getAdminRosterJoinRequests(leagueId), - leagueService.getAdminRosterMembers(leagueId), - ]); - setJoinRequests(requestsVm); - setMembers(membersVm); - } finally { - setLoading(false); - } - }; + const loading = loadingJoinRequests || loadingMembers; - useEffect(() => { - void loadRoster(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [leagueId]); + // Mutations + const approveMutation = useApproveJoinRequest({ + onSuccess: () => refetchJoinRequests(), + }); + + const rejectMutation = useRejectJoinRequest({ + onSuccess: () => refetchJoinRequests(), + }); + + const updateRoleMutation = useUpdateMemberRole({ + onError: () => refetchMembers(), // Refetch on error to restore state + }); + + const removeMemberMutation = useRemoveMember({ + onSuccess: () => refetchMembers(), + }); const pendingCountLabel = useMemo(() => { return joinRequests.length === 1 ? '1 request' : `${joinRequests.length} requests`; }, [joinRequests.length]); const handleApprove = async (joinRequestId: string) => { - await leagueService.approveJoinRequest(leagueId, joinRequestId); - setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId)); + await approveMutation.mutateAsync({ leagueId, joinRequestId }); }; const handleReject = async (joinRequestId: string) => { - await leagueService.rejectJoinRequest(leagueId, joinRequestId); - setJoinRequests((prev) => prev.filter((r) => r.id !== joinRequestId)); + await rejectMutation.mutateAsync({ leagueId, joinRequestId }); }; const handleRoleChange = async (driverId: string, newRole: MembershipRole) => { - setMembers((prev) => prev.map((m) => (m.driverId === driverId ? { ...m, role: newRole } : m))); - const result = await leagueService.updateMemberRole(leagueId, driverId, newRole); - if (!result.success) { - await loadRoster(); - } + await updateRoleMutation.mutateAsync({ leagueId, driverId, role: newRole }); }; const handleRemove = async (driverId: string) => { - await leagueService.removeMember(leagueId, driverId); - setMembers((prev) => prev.filter((m) => m.driverId !== driverId)); + await removeMemberMutation.mutateAsync({ leagueId, driverId }); }; return ( diff --git a/apps/website/app/leagues/[id]/rulebook/LeagueRulebookInteractive.tsx b/apps/website/app/leagues/[id]/rulebook/LeagueRulebookInteractive.tsx index 4b6346baa..5dda98b78 100644 --- a/apps/website/app/leagues/[id]/rulebook/LeagueRulebookInteractive.tsx +++ b/apps/website/app/leagues/[id]/rulebook/LeagueRulebookInteractive.tsx @@ -3,14 +3,15 @@ import { useState, useEffect } from 'react'; import { useParams } from 'next/navigation'; import { LeagueRulebookTemplate } from '@/templates/LeagueRulebookTemplate'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; export default function LeagueRulebookInteractive() { const params = useParams(); const leagueId = params.id as string; - const { leagueService } = useServices(); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); const [viewModel, setViewModel] = useState(null); const [loading, setLoading] = useState(true); diff --git a/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx b/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx index 5a37a86b3..e3f1b250b 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx +++ b/apps/website/app/leagues/[id]/schedule/admin/page.test.tsx @@ -1,8 +1,14 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { render, screen, fireEvent, waitFor, cleanup } from '@testing-library/react'; +import '@testing-library/jest-dom'; import LeagueAdminSchedulePage from './page'; +// Mock useEffectiveDriverId +vi.mock('@/hooks/useEffectiveDriverId', () => ({ + useEffectiveDriverId: () => 'driver-1', +})); + type SeasonSummaryViewModel = { seasonId: string; name: string; @@ -82,8 +88,42 @@ const mockServices = { }, }; -vi.mock('@/lib/services/ServiceProvider', () => ({ - useServices: () => mockServices, +// Mock useInject to return mocked services +vi.mock('@/lib/di/hooks/useInject', () => ({ + useInject: (token: symbol) => { + const tokenStr = token.toString(); + if (tokenStr.includes('LEAGUE_SERVICE_TOKEN')) { + return { + getLeagueSeasonSummaries: mockGetLeagueSeasonSummaries, + getAdminSchedule: mockGetAdminSchedule, + publishAdminSchedule: mockPublishAdminSchedule, + unpublishAdminSchedule: mockUnpublishAdminSchedule, + createAdminScheduleRace: mockCreateAdminScheduleRace, + updateAdminScheduleRace: mockUpdateAdminScheduleRace, + deleteAdminScheduleRace: mockDeleteAdminScheduleRace, + }; + } + if (tokenStr.includes('LEAGUE_MEMBERSHIP_SERVICE_TOKEN')) { + return { + fetchLeagueMemberships: mockFetchLeagueMemberships, + getMembership: mockGetMembership, + }; + } + return {}; + }, +})); + +// Mock the static LeagueMembershipService for LeagueMembershipUtility +vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({ + LeagueMembershipService: { + getMembership: mockGetMembership, + fetchLeagueMemberships: mockFetchLeagueMemberships, + setLeagueMemberships: vi.fn(), + clearLeagueMemberships: vi.fn(), + getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()), + getAllMembershipsForDriver: vi.fn(() => []), + getLeagueMembers: vi.fn(() => []), + }, })); function createAdminScheduleViewModel(overrides: Partial = {}): AdminScheduleViewModel { @@ -114,6 +154,7 @@ describe('LeagueAdminSchedulePage', () => { mockFetchLeagueMemberships.mockReset(); mockGetMembership.mockReset(); + // Set up default mock implementations mockFetchLeagueMemberships.mockResolvedValue([]); mockGetMembership.mockReturnValue({ role: 'admin' }); }); diff --git a/apps/website/app/leagues/[id]/schedule/admin/page.tsx b/apps/website/app/leagues/[id]/schedule/admin/page.tsx index acc4d1959..6f55adfcd 100644 --- a/apps/website/app/leagues/[id]/schedule/admin/page.tsx +++ b/apps/website/app/leagues/[id]/schedule/admin/page.tsx @@ -4,7 +4,8 @@ import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import type { LeagueAdminScheduleViewModel } from '@/lib/view-models/LeagueAdminScheduleViewModel'; import type { LeagueSeasonSummaryViewModel } from '@/lib/view-models/LeagueSeasonSummaryViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { useParams } from 'next/navigation'; import { useEffect, useMemo, useState } from 'react'; @@ -14,7 +15,8 @@ export default function LeagueAdminSchedulePage() { const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { leagueService, leagueMembershipService } = useServices(); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); const [isAdmin, setIsAdmin] = useState(false); const [membershipLoading, setMembershipLoading] = useState(true); diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index 5457fff5d..79a35df14 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -4,39 +4,29 @@ import { ReadonlyLeagueInfo } from '@/components/leagues/ReadonlyLeagueInfo'; import LeagueOwnershipTransfer from '@/components/leagues/LeagueOwnershipTransfer'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel'; -import { AlertTriangle, Settings } from 'lucide-react'; import { useParams, useRouter } from 'next/navigation'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; +import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; +import { useLeagueSettings } from '@/hooks/league/useLeagueSettings'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SETTINGS_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { AlertTriangle, Settings } from 'lucide-react'; export default function LeagueSettingsPage() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { leagueMembershipService, leagueSettingsService } = useServices(); + const leagueSettingsService = useInject(LEAGUE_SETTINGS_SERVICE_TOKEN); const router = useRouter(); - // Check admin status - const { data: isAdmin, isLoading: adminLoading } = useDataFetching({ - queryKey: ['leagueMembership', leagueId, currentDriverId], - queryFn: async () => { - const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; - }, - }); + // Check admin status using DI + React-Query + const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId); - // Load settings (only if admin) - const { data: settings, isLoading: settingsLoading, error, retry } = useDataFetching({ - queryKey: ['leagueSettings', leagueId], - queryFn: () => leagueSettingsService.getLeagueSettings(leagueId), - enabled: !!isAdmin, - }); + // Load settings (only if admin) using DI + React-Query + const { data: settings, isLoading: settingsLoading, error, retry } = useLeagueSettings(leagueId, { enabled: !!isAdmin }); const handleTransferOwnership = async (newOwnerId: string) => { try { @@ -100,10 +90,10 @@ export default function LeagueSettingsPage() { {/* READONLY INFORMATION SECTION - Compact */}
- + @@ -112,4 +102,4 @@ export default function LeagueSettingsPage() { )} ); -} \ No newline at end of file +} diff --git a/apps/website/app/leagues/[id]/sponsorships/page.tsx b/apps/website/app/leagues/[id]/sponsorships/page.tsx index 9f45c9b1d..3139f734b 100644 --- a/apps/website/app/leagues/[id]/sponsorships/page.tsx +++ b/apps/website/app/leagues/[id]/sponsorships/page.tsx @@ -4,7 +4,8 @@ import { LeagueSponsorshipsSection } from '@/components/leagues/LeagueSponsorshi import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LeaguePageDetailViewModel } from '@/lib/view-models/LeaguePageDetailViewModel'; import { AlertTriangle, Building } from 'lucide-react'; import { useParams } from 'next/navigation'; @@ -14,7 +15,8 @@ export default function LeagueSponsorshipsPage() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { leagueService, leagueMembershipService } = useServices(); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); const [league, setLeague] = useState(null); const [isAdmin, setIsAdmin] = useState(false); diff --git a/apps/website/app/leagues/[id]/standings/LeagueStandingsInteractive.tsx b/apps/website/app/leagues/[id]/standings/LeagueStandingsInteractive.tsx index 2ac664a8f..ebe1dc19d 100644 --- a/apps/website/app/leagues/[id]/standings/LeagueStandingsInteractive.tsx +++ b/apps/website/app/leagues/[id]/standings/LeagueStandingsInteractive.tsx @@ -5,7 +5,8 @@ import { useParams } from 'next/navigation'; import { LeagueStandingsTemplate } from '@/templates/LeagueStandingsTemplate'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { LeagueMembership } from '@/lib/types/LeagueMembership'; import { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import type { StandingEntryViewModel } from '@/lib/view-models/StandingEntryViewModel'; @@ -14,7 +15,7 @@ export default function LeagueStandingsInteractive() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { leagueService } = useServices(); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); const [standings, setStandings] = useState([]); const [drivers, setDrivers] = useState([]); diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index dd609a6e4..20d629ac0 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -6,9 +6,9 @@ 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 { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { LeagueStewardingViewModel } from '@/lib/view-models/LeagueStewardingViewModel'; +import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver'; +import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; +import { useLeagueStewardingData } from '@/hooks/league/useLeagueStewardingData'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; import { AlertCircle, @@ -25,15 +25,14 @@ import { useParams } from 'next/navigation'; import { useMemo, useState } from 'react'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; export default function LeagueStewardingPage() { const params = useParams(); const leagueId = params.id as string; - const currentDriverId = useEffectiveDriverId(); - const { leagueStewardingService, leagueMembershipService } = useServices(); + const { data: currentDriver } = useCurrentDriver(); + const currentDriverId = currentDriver?.id; const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); const [selectedProtest, setSelectedProtest] = useState(null); @@ -41,28 +40,10 @@ export default function LeagueStewardingPage() { const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); // Check admin status - const { data: isAdmin, isLoading: adminLoading } = useDataFetching({ - queryKey: ['leagueMembership', leagueId, currentDriverId], - queryFn: async () => { - const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId); - return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; - }, - }); + const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || ''); // Load stewarding data (only if admin) - const { data: stewardingData, isLoading: dataLoading, error, retry } = useDataFetching({ - queryKey: ['leagueStewarding', leagueId], - queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId), - enabled: !!isAdmin, - onSuccess: (data) => { - // Auto-expand races with pending protests - const racesWithPending = new Set(); - data.pendingRaces.forEach(race => { - racesWithPending.add(race.race.id); - }); - setExpandedRaces(racesWithPending); - }, - }); + const { data: stewardingData, isLoading: dataLoading, error, retry } = useLeagueStewardingData(leagueId); // Filter races based on active tab const filteredRaces = useMemo(() => { @@ -75,13 +56,6 @@ export default function LeagueStewardingPage() { penaltyValue: number, stewardNotes: string ) => { - await leagueStewardingService.reviewProtest({ - protestId, - stewardId: currentDriverId, - decision: 'uphold', - decisionNotes: stewardNotes, - }); - // Find the protest to get details for penalty let foundProtest: any | undefined; stewardingData?.racesWithData.forEach(raceData => { @@ -91,16 +65,24 @@ export default function LeagueStewardingPage() { }); if (foundProtest) { - await leagueStewardingService.applyPenalty({ - raceId: foundProtest.raceId, - driverId: foundProtest.accusedDriverId, - stewardId: currentDriverId, - type: penaltyType, - value: penaltyValue, - reason: foundProtest.incident.description, - protestId, - notes: stewardNotes, - }); + // TODO: Implement protest review and penalty application + // await leagueStewardingService.reviewProtest({ + // protestId, + // stewardId: currentDriverId, + // decision: 'uphold', + // decisionNotes: stewardNotes, + // }); + + // await leagueStewardingService.applyPenalty({ + // raceId: foundProtest.raceId, + // driverId: foundProtest.accusedDriverId, + // stewardId: currentDriverId, + // type: penaltyType, + // value: penaltyValue, + // reason: foundProtest.incident.description, + // protestId, + // notes: stewardNotes, + // }); } // Retry to refresh data @@ -108,12 +90,13 @@ export default function LeagueStewardingPage() { }; const handleRejectProtest = async (protestId: string, stewardNotes: string) => { - await leagueStewardingService.reviewProtest({ - protestId, - stewardId: currentDriverId, - decision: 'dismiss', - decisionNotes: stewardNotes, - }); + // TODO: Implement protest rejection + // await leagueStewardingService.reviewProtest({ + // protestId, + // stewardId: currentDriverId, + // decision: 'dismiss', + // decisionNotes: stewardNotes, + // }); // Retry to refresh data await retry(); @@ -185,245 +168,249 @@ export default function LeagueStewardingPage() { } }} > - {(data) => ( -
- -
-
-

Stewarding

-

- Quick overview of protests and penalties across all races -

-
-
- - {/* Stats summary */} - - - {/* Tab navigation */} -
-
- - -
-
- - {/* Content */} - {filteredRaces.length === 0 ? ( -
-
- + {(data) => { + if (!data) return null; + + return ( +
+ +
+
+

Stewarding

+

+ Quick overview of protests and penalties across all races +

-

- {activeTab === 'pending' ? 'All Clear!' : 'No History Yet'} -

-

- {activeTab === 'pending' - ? 'No pending protests to review' - : 'No resolved protests or penalties'} -

- ) : ( -
- {filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => { - const isExpanded = expandedRaces.has(race.id); - const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests; - - return ( -
- {/* Race Header */} - - {/* Expanded Content */} - {isExpanded && ( -
- {displayProtests.length === 0 && penalties.length === 0 ? ( -

No items to display

- ) : ( - <> - {displayProtests.map((protest) => { - const protester = data.driverMap[protest.protestingDriverId]; - const accused = data.driverMap[protest.accusedDriverId]; - const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)); - const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review'); - - return ( -
-
-
-
- - - {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} - - {getStatusBadge(protest.status)} - {isUrgent && ( - - - {daysSinceFiled}d old + {/* Stats summary */} + + + {/* Tab navigation */} +
+
+ + +
+
+ + {/* Content */} + {filteredRaces.length === 0 ? ( +
+
+ +
+

+ {activeTab === 'pending' ? 'All Clear!' : 'No History Yet'} +

+

+ {activeTab === 'pending' + ? 'No pending protests to review' + : 'No resolved protests or penalties'} +

+
+ ) : ( +
+ {filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => { + const isExpanded = expandedRaces.has(race.id); + const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests; + + return ( +
+ {/* Race Header */} + + + {/* Expanded Content */} + {isExpanded && ( +
+ {displayProtests.length === 0 && penalties.length === 0 ? ( +

No items to display

+ ) : ( + <> + {displayProtests.map((protest) => { + const protester = data.driverMap[protest.protestingDriverId]; + const accused = data.driverMap[protest.accusedDriverId]; + const daysSinceFiled = Math.floor((Date.now() - new Date(protest.filedAt).getTime()) / (1000 * 60 * 60 * 24)); + const isUrgent = daysSinceFiled > 2 && (protest.status === 'pending' || protest.status === 'under_review'); + + return ( +
+
+
+
+ + + {protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'} - )} -
-
- Lap {protest.incident.lap} - - Filed {new Date(protest.filedAt).toLocaleDateString()} - {protest.proofVideoUrl && ( - <> - - -
+
+ Lap {protest.incident.lap} + + Filed {new Date(protest.filedAt).toLocaleDateString()} + {protest.proofVideoUrl && ( + <> + + + + + )} +
+

+ {protest.incident.description} +

+ {protest.decisionNotes && ( +
+

+ Steward: {protest.decisionNotes} +

+
)}
-

- {protest.incident.description} -

- {protest.decisionNotes && ( -
-

- Steward: {protest.decisionNotes} -

-
+ {(protest.status === 'pending' || protest.status === 'under_review') && ( + + + )}
- {(protest.status === 'pending' || protest.status === 'under_review') && ( - - - - )}
-
- ); - })} + ); + })} - {activeTab === 'history' && penalties.map((penalty) => { - const driver = data.driverMap[penalty.driverId]; - return ( -
-
-
- -
-
-
- {driver?.name || 'Unknown'} - - {penalty.type.replace('_', ' ')} + {activeTab === 'history' && penalties.map((penalty) => { + const driver = data.driverMap[penalty.driverId]; + return ( +
+
+
+ +
+
+
+ {driver?.name || 'Unknown'} + + {penalty.type.replace('_', ' ')} + +
+

{penalty.reason}

+
+
+ + {penalty.type === 'time_penalty' && `+${penalty.value}s`} + {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} + {penalty.type === 'points_deduction' && `-${penalty.value} pts`} + {penalty.type === 'disqualification' && 'DSQ'} + {penalty.type === 'warning' && 'Warning'} + {penalty.type === 'license_points' && `${penalty.value} LP`}
-

{penalty.reason}

-
-
- - {penalty.type === 'time_penalty' && `+${penalty.value}s`} - {penalty.type === 'grid_penalty' && `+${penalty.value} grid`} - {penalty.type === 'points_deduction' && `-${penalty.value} pts`} - {penalty.type === 'disqualification' && 'DSQ'} - {penalty.type === 'warning' && 'Warning'} - {penalty.type === 'license_points' && `${penalty.value} LP`} -
-
- ); - })} - - )} -
- )} -
- ); - })} -
+ ); + })} + + )} +
+ )} +
+ ); + })} +
+ )} + + + {activeTab === 'history' && ( + setShowQuickPenaltyModal(true)} /> )} - - {activeTab === 'history' && ( - setShowQuickPenaltyModal(true)} /> - )} + {selectedProtest && ( + setSelectedProtest(null)} + onAccept={handleAcceptProtest} + onReject={handleRejectProtest} + /> + )} - {selectedProtest && ( - setSelectedProtest(null)} - onAccept={handleAcceptProtest} - onReject={handleRejectProtest} - /> - )} - - {showQuickPenaltyModal && stewardingData && ( - setShowQuickPenaltyModal(false)} - adminId={currentDriverId} - races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))} - /> - )} -
- )} + {showQuickPenaltyModal && stewardingData && ( + setShowQuickPenaltyModal(false)} + adminId={currentDriverId || ''} + races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))} + /> + )} +
+ ); + }} ); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx index 5998490fd..5ad3256b7 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.test.tsx @@ -5,6 +5,11 @@ import '@testing-library/jest-dom'; import ProtestReviewPage from './page'; +// Mock useEffectiveDriverId +vi.mock('@/hooks/useEffectiveDriverId', () => ({ + useEffectiveDriverId: () => 'driver-1', +})); + // Mocks for Next.js navigation const mockPush = vi.fn(); @@ -24,22 +29,56 @@ const mockGetProtestDetailViewModel = vi.fn(); const mockFetchLeagueMemberships = vi.fn(); const mockGetMembership = vi.fn(); -vi.mock('@/lib/services/ServiceProvider', () => ({ - useServices: () => ({ - leagueStewardingService: { - getProtestDetailViewModel: mockGetProtestDetailViewModel, - }, - protestService: { - applyPenalty: vi.fn(), - requestDefense: vi.fn(), - }, - leagueMembershipService: { - fetchLeagueMemberships: mockFetchLeagueMemberships, - getMembership: mockGetMembership, - }, +// Mock useLeagueAdminStatus hook +vi.mock('@/hooks/league/useLeagueAdminStatus', () => ({ + useLeagueAdminStatus: (leagueId: string, driverId: string) => ({ + data: mockGetMembership.mock.results[0]?.value ? + (mockGetMembership.mock.results[0].value.role === 'admin' || mockGetMembership.mock.results[0].value.role === 'owner') : false, + isLoading: false, + isError: false, + isSuccess: true, + refetch: vi.fn(), }), })); +// Mock useProtestDetail hook +vi.mock('@/hooks/league/useProtestDetail', () => ({ + useProtestDetail: (leagueId: string, protestId: string, enabled: boolean = true) => ({ + data: mockGetProtestDetailViewModel.mock.results[0]?.value || null, + isLoading: false, + isError: false, + isSuccess: !!mockGetProtestDetailViewModel.mock.results[0]?.value, + refetch: vi.fn(), + retry: vi.fn(), + }), +})); + +// Mock useInject for protest service +vi.mock('@/lib/di/hooks/useInject', () => ({ + useInject: (token: symbol) => { + if (token.toString().includes('PROTEST_SERVICE_TOKEN')) { + return { + applyPenalty: vi.fn(), + requestDefense: vi.fn(), + }; + } + return {}; + }, +})); + +// Mock the static LeagueMembershipService for LeagueRoleUtility +vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({ + LeagueMembershipService: { + getMembership: mockGetMembership, + fetchLeagueMemberships: mockFetchLeagueMemberships, + setLeagueMemberships: vi.fn(), + clearLeagueMemberships: vi.fn(), + getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()), + getAllMembershipsForDriver: vi.fn(() => []), + getLeagueMembers: vi.fn(() => []), + }, +})); + const mockIsLeagueAdminOrHigherRole = vi.fn(); vi.mock('@/lib/utilities/LeagueRoleUtility', () => ({ @@ -56,6 +95,7 @@ describe('ProtestReviewPage', () => { mockGetMembership.mockReset(); mockIsLeagueAdminOrHigherRole.mockReset(); + // Set up default mock implementations mockFetchLeagueMemberships.mockResolvedValue(undefined); mockGetMembership.mockReturnValue({ role: 'admin' }); mockIsLeagueAdminOrHigherRole.mockReturnValue(true); 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 9b420189d..25d4165f7 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -4,7 +4,8 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { PROTEST_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { ProtestDetailViewModel } from '@/lib/view-models/ProtestDetailViewModel'; import { ProtestDriverViewModel } from '@/lib/view-models/ProtestDriverViewModel'; import { ProtestDecisionCommandModel } from '@/lib/command-models/protests/ProtestDecisionCommandModel'; @@ -35,9 +36,10 @@ import { useParams, useRouter } from 'next/navigation'; import { useMemo, useState } from 'react'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; +import { useLeagueAdminStatus } from '@/hooks/league/useLeagueAdminStatus'; +import { useProtestDetail } from '@/hooks/league/useProtestDetail'; // Timeline event types interface TimelineEvent { @@ -108,7 +110,7 @@ export default function ProtestReviewPage() { const leagueId = params.id as string; const protestId = params.protestId as string; const currentDriverId = useEffectiveDriverId(); - const { leagueStewardingService, protestService, leagueMembershipService } = useServices(); + const protestService = useInject(PROTEST_SERVICE_TOKEN); // Decision state const [showDecisionPanel, setShowDecisionPanel] = useState(false); @@ -119,28 +121,19 @@ export default function ProtestReviewPage() { const [submitting, setSubmitting] = useState(false); const [newComment, setNewComment] = useState(''); - // Check admin status - const { data: isAdmin, isLoading: adminLoading } = useDataFetching({ - queryKey: ['leagueMembership', leagueId, currentDriverId], - queryFn: async () => { - await leagueMembershipService.fetchLeagueMemberships(leagueId); - const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; - }, - }); + // Check admin status using hook + const { data: isAdmin, isLoading: adminLoading } = useLeagueAdminStatus(leagueId, currentDriverId || ''); - // Load protest detail - const { data: detail, isLoading: detailLoading, error, retry } = useDataFetching({ - queryKey: ['protestDetail', leagueId, protestId], - queryFn: () => leagueStewardingService.getProtestDetailViewModel(leagueId, protestId), - enabled: !!isAdmin, - onSuccess: (protestDetail) => { - if (protestDetail.initialPenaltyType) { - setPenaltyType(protestDetail.initialPenaltyType); - setPenaltyValue(protestDetail.initialPenaltyValue); - } - }, - }); + // Load protest detail using hook + const { data: detail, isLoading: detailLoading, error, retry } = useProtestDetail(leagueId, protestId, isAdmin || false); + + // Set initial penalty values when data loads + useMemo(() => { + if (detail?.initialPenaltyType) { + setPenaltyType(detail.initialPenaltyType); + setPenaltyValue(detail.initialPenaltyValue); + } + }, [detail]); const penaltyTypes = useMemo(() => { const referenceItems = detail?.penaltyTypes ?? []; @@ -315,6 +308,8 @@ export default function ProtestReviewPage() { }} > {(protestDetail) => { + if (!protestDetail) return null; + const protest = protestDetail.protest; const race = protestDetail.race; const protestingDriver = protestDetail.protestingDriver; diff --git a/apps/website/app/leagues/[id]/wallet/page.tsx b/apps/website/app/leagues/[id]/wallet/page.tsx index 1a5c76ece..b34345d65 100644 --- a/apps/website/app/leagues/[id]/wallet/page.tsx +++ b/apps/website/app/leagues/[id]/wallet/page.tsx @@ -5,7 +5,8 @@ import { useParams } from 'next/navigation'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import TransactionRow from '@/components/leagues/TransactionRow'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_WALLET_SERVICE_TOKEN } from '@/lib/di/tokens'; import { LeagueWalletViewModel } from '@/lib/view-models/LeagueWalletViewModel'; import { Wallet, @@ -20,7 +21,7 @@ import { export default function LeagueWalletPage() { const params = useParams(); - const { leagueWalletService } = useServices(); + const leagueWalletService = useInject(LEAGUE_WALLET_SERVICE_TOKEN); const [wallet, setWallet] = useState(null); const [withdrawAmount, setWithdrawAmount] = useState(''); const [showWithdrawModal, setShowWithdrawModal] = useState(false); diff --git a/apps/website/app/onboarding/page.tsx b/apps/website/app/onboarding/page.tsx index cf611318c..25a379ae7 100644 --- a/apps/website/app/onboarding/page.tsx +++ b/apps/website/app/onboarding/page.tsx @@ -7,22 +7,18 @@ import OnboardingWizard from '@/components/onboarding/OnboardingWizard'; import { useAuth } from '@/lib/auth/AuthContext'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; +import { useCurrentDriver } from '@/hooks/driver/useCurrentDriver'; import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; -import { useServices } from '@/lib/services/ServiceProvider'; - + export default function OnboardingPage() { const router = useRouter(); const { session } = useAuth(); - const { driverService } = useServices(); // Check if user is logged in const shouldRedirectToLogin = !session; - // Fetch current driver data - const { data: driver, isLoading } = useDataFetching({ - queryKey: ['currentDriver'], - queryFn: () => driverService.getCurrentDriver(), + // Fetch current driver data using DI + React-Query + const { data: driver, isLoading } = useCurrentDriver({ enabled: !!session, }); @@ -59,4 +55,4 @@ export default function OnboardingPage() {
); - } \ No newline at end of file +} \ No newline at end of file diff --git a/apps/website/app/profile/leagues/page.tsx b/apps/website/app/profile/leagues/page.tsx index cd172b841..baec96c8f 100644 --- a/apps/website/app/profile/leagues/page.tsx +++ b/apps/website/app/profile/leagues/page.tsx @@ -3,7 +3,8 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LEAGUE_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { LeagueMembership } from '@/lib/types/LeagueMembership'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; import Link from 'next/link'; @@ -20,7 +21,8 @@ export default function ManageLeaguesPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const effectiveDriverId = useEffectiveDriverId(); - const { leagueService, leagueMembershipService } = useServices(); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); useEffect(() => { let cancelled = false; diff --git a/apps/website/app/profile/page.tsx b/apps/website/app/profile/page.tsx index 966c9c1fb..bd06960d7 100644 --- a/apps/website/app/profile/page.tsx +++ b/apps/website/app/profile/page.tsx @@ -7,7 +7,9 @@ import Button from '@/components/ui/Button'; import Card from '@/components/ui/Card'; import Heading from '@/components/ui/Heading'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverProfile } from '@/hooks/driver/useDriverProfile'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { DRIVER_SERVICE_TOKEN, MEDIA_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { DriverProfileAchievementViewModel, DriverProfileSocialHandleViewModel, @@ -16,9 +18,7 @@ import type { import { getMediaUrl } from '@/lib/utilities/media'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper'; import { Activity, Award, @@ -263,17 +263,14 @@ export default function ProfilePage() { const searchParams = useSearchParams(); const tabParam = searchParams.get('tab') as ProfileTab | null; - const { driverService, mediaService } = useServices(); + const driverService = useInject(DRIVER_SERVICE_TOKEN); + const mediaService = useInject(MEDIA_SERVICE_TOKEN); const effectiveDriverId = useEffectiveDriverId(); const isOwnProfile = true; // This page is always your own profile - // Shared state components - const { data: profileData, isLoading: loading, error, retry } = useDataFetching({ - queryKey: ['driverProfile', effectiveDriverId], - queryFn: () => driverService.getDriverProfile(effectiveDriverId), - enabled: !!effectiveDriverId, - }); + // Use React-Query hook for profile data + const { data: profileData, isLoading: loading, error, retry } = useDriverProfile(effectiveDriverId || ''); const [editMode, setEditMode] = useState(false); const [activeTab, setActiveTab] = useState(tabParam || 'overview'); diff --git a/apps/website/app/profile/sponsorship-requests/page.tsx b/apps/website/app/profile/sponsorship-requests/page.tsx index 3bc4c81c3..97bc10778 100644 --- a/apps/website/app/profile/sponsorship-requests/page.tsx +++ b/apps/website/app/profile/sponsorship-requests/page.tsx @@ -8,7 +8,8 @@ import { useCallback, useEffect, useState } from 'react'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { SPONSORSHIP_SERVICE_TOKEN, DRIVER_SERVICE_TOKEN, LEAGUE_SERVICE_TOKEN, TEAM_SERVICE_TOKEN, LEAGUE_MEMBERSHIP_SERVICE_TOKEN } from '@/lib/di/tokens'; import { SponsorshipRequestViewModel } from '@/lib/view-models/SponsorshipRequestViewModel'; import { AlertTriangle, Building, ChevronRight, Handshake, Trophy, User, Users } from 'lucide-react'; import Link from 'next/link'; @@ -23,7 +24,11 @@ interface EntitySection { export default function SponsorshipRequestsPage() { const currentDriverId = useEffectiveDriverId(); - const { sponsorshipService, driverService, leagueService, teamService, leagueMembershipService } = useServices(); + const sponsorshipService = useInject(SPONSORSHIP_SERVICE_TOKEN); + const driverService = useInject(DRIVER_SERVICE_TOKEN); + const leagueService = useInject(LEAGUE_SERVICE_TOKEN); + const teamService = useInject(TEAM_SERVICE_TOKEN); + const leagueMembershipService = useInject(LEAGUE_MEMBERSHIP_SERVICE_TOKEN); const [sections, setSections] = useState([]); const [loading, setLoading] = useState(true); diff --git a/apps/website/app/races/RacesInteractive.tsx b/apps/website/app/races/RacesInteractive.tsx index a8dbbf2ec..df18d5625 100644 --- a/apps/website/app/races/RacesInteractive.tsx +++ b/apps/website/app/races/RacesInteractive.tsx @@ -3,7 +3,10 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { RacesTemplate, TimeFilter, RaceStatusFilter } from '@/templates/RacesTemplate'; -import { useRacesPageData, useRegisterForRace, useWithdrawFromRace, useCancelRace } from '@/hooks/useRaceService'; +import { useRacesPageData } from '@/hooks/race/useRacesPageData'; +import { useRegisterForRace } from '@/hooks/race/useRegisterForRace'; +import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace'; +import { useCancelRace } from '@/hooks/race/useCancelRace'; import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; diff --git a/apps/website/app/races/RacesStatic.tsx b/apps/website/app/races/RacesStatic.tsx index d48fe0902..7534392d6 100644 --- a/apps/website/app/races/RacesStatic.tsx +++ b/apps/website/app/races/RacesStatic.tsx @@ -1,16 +1,19 @@ import { RacesTemplate } from '@/templates/RacesTemplate'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { ContainerManager } from '@/lib/di/container'; +import { RACE_SERVICE_TOKEN } from '@/lib/di/tokens'; import type { RaceListItemViewModel } from '@/lib/view-models/RaceListItemViewModel'; +import type { RaceService } from '@/lib/services/races/RaceService'; // This is a server component that fetches data and passes it to the template export async function RacesStatic() { - const { raceService } = useServices(); + const container = ContainerManager.getInstance().getContainer(); + const raceService = container.get(RACE_SERVICE_TOKEN); // Fetch race data server-side const pageData = await raceService.getRacesPageData(); // Extract races from the response - const races = pageData.races.map(race => ({ + const races = pageData.races.map((race: RaceListItemViewModel) => ({ id: race.id, track: race.track, car: race.car, @@ -27,7 +30,7 @@ export async function RacesStatic() { // Transform the categorized races as well const transformRaces = (raceList: RaceListItemViewModel[]) => - raceList.map(race => ({ + raceList.map((race: RaceListItemViewModel) => ({ id: race.id, track: race.track, car: race.car, diff --git a/apps/website/app/races/[id]/RaceDetailInteractive.tsx b/apps/website/app/races/[id]/RaceDetailInteractive.tsx index 22fa2ca8a..6081e6b03 100644 --- a/apps/website/app/races/[id]/RaceDetailInteractive.tsx +++ b/apps/website/app/races/[id]/RaceDetailInteractive.tsx @@ -3,21 +3,18 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate'; -import { - useRegisterForRace, - useWithdrawFromRace, - useCancelRace, - useCompleteRace, - useReopenRace -} from '@/hooks/useRaceService'; +import { useRegisterForRace } from '@/hooks/race/useRegisterForRace'; +import { useWithdrawFromRace } from '@/hooks/race/useWithdrawFromRace'; +import { useCancelRace } from '@/hooks/race/useCancelRace'; +import { useCompleteRace } from '@/hooks/race/useCompleteRace'; +import { useReopenRace } from '@/hooks/race/useReopenRace'; import { useLeagueMembership } from '@/hooks/useLeagueMembershipService'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useRaceDetail } from '@/hooks/race/useRaceDetail'; import { Flag } from 'lucide-react'; export function RaceDetailInteractive() { @@ -25,13 +22,9 @@ export function RaceDetailInteractive() { const params = useParams(); const raceId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { raceService } = useServices(); - // Fetch data using new hook - const { data: viewModel, isLoading, error, retry } = useDataFetching({ - queryKey: ['raceDetail', raceId, currentDriverId], - queryFn: () => raceService.getRaceDetail(raceId, currentDriverId), - }); + // Fetch data using DI + React-Query + const { data: viewModel, isLoading, error, retry } = useRaceDetail(raceId, currentDriverId); // Fetch membership const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId); diff --git a/apps/website/app/races/[id]/page.test.tsx b/apps/website/app/races/[id]/page.test.tsx index 9440727ce..5b303c655 100644 --- a/apps/website/app/races/[id]/page.test.tsx +++ b/apps/website/app/races/[id]/page.test.tsx @@ -39,23 +39,66 @@ vi.mock('@/components/sponsors/SponsorInsightsCard', () => ({ useSponsorMode: () => false, })); -// Mock services hook to provide raceService and leagueMembershipService +// Mock the new DI hooks const mockGetRaceDetails = vi.fn(); const mockReopenRace = vi.fn(); const mockFetchLeagueMemberships = vi.fn(); const mockGetMembership = vi.fn(); -vi.mock('@/lib/services/ServiceProvider', () => ({ - useServices: () => ({ - raceService: { - getRaceDetails: mockGetRaceDetails, - reopenRace: mockReopenRace, - // other methods are not used in this test - }, - leagueMembershipService: { - fetchLeagueMemberships: mockFetchLeagueMemberships, - getMembership: mockGetMembership, - }, +// Mock race detail hook +vi.mock('@/hooks/race/useRaceDetail', () => ({ + useRaceDetail: (raceId: string, driverId: string) => ({ + data: mockGetRaceDetails.mock.results[0]?.value || null, + isLoading: false, + isError: false, + isSuccess: !!mockGetRaceDetails.mock.results[0]?.value, + refetch: vi.fn(), + retry: vi.fn(), + }), +})); + +// Mock reopen race hook +vi.mock('@/hooks/race/useReopenRace', () => ({ + useReopenRace: () => ({ + mutateAsync: mockReopenRace, + mutate: mockReopenRace, + isPending: false, + isLoading: false, + }), +})); + +// Mock league membership service static method +vi.mock('@/lib/services/leagues/LeagueMembershipService', () => ({ + LeagueMembershipService: { + getMembership: mockGetMembership, + fetchLeagueMemberships: mockFetchLeagueMemberships, + setLeagueMemberships: vi.fn(), + clearLeagueMemberships: vi.fn(), + getCachedMembershipsIterator: vi.fn(() => [][Symbol.iterator]()), + getAllMembershipsForDriver: vi.fn(() => []), + getLeagueMembers: vi.fn(() => []), + }, +})); + +// Mock league membership hook (if used by component) +vi.mock('@/hooks/league/useLeagueMemberships', () => ({ + useLeagueMemberships: (leagueId: string, currentUserId: string) => ({ + data: mockFetchLeagueMemberships.mock.results[0]?.value || null, + isLoading: false, + isError: false, + isSuccess: !!mockFetchLeagueMemberships.mock.results[0]?.value, + refetch: vi.fn(), + }), +})); + +// Mock the useLeagueMembership hook that the component imports +vi.mock('@/hooks/useLeagueMembershipService', () => ({ + useLeagueMembership: (leagueId: string, driverId: string) => ({ + data: mockGetMembership.mock.results[0]?.value || null, + isLoading: false, + isError: false, + isSuccess: !!mockGetMembership.mock.results[0]?.value, + refetch: vi.fn(), }), })); @@ -122,7 +165,7 @@ describe('RaceDetailPage - Re-open Race behavior', () => { mockGetMembership.mockReset(); mockIsOwnerOrAdmin.mockReset(); - // Set up default mock implementations for services + // Set up default mock implementations mockFetchLeagueMemberships.mockResolvedValue(undefined); mockGetMembership.mockReturnValue({ role: 'owner' }); // Return owner role by default }); @@ -131,8 +174,9 @@ describe('RaceDetailPage - Re-open Race behavior', () => { mockIsOwnerOrAdmin.mockReturnValue(true); const viewModel = createViewModel('completed'); - // Mock the service to return the right data - mockGetRaceDetails.mockResolvedValue(viewModel); + // Mock the hooks to return the right data + mockGetRaceDetails.mockReturnValue(viewModel); + mockGetMembership.mockReturnValue({ role: 'owner' }); mockReopenRace.mockResolvedValue(undefined); const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); @@ -162,7 +206,8 @@ describe('RaceDetailPage - Re-open Race behavior', () => { mockIsOwnerOrAdmin.mockReturnValue(false); const viewModel = createViewModel('completed'); - mockGetRaceDetails.mockResolvedValue(viewModel); + mockGetRaceDetails.mockReturnValue(viewModel); + mockGetMembership.mockReturnValue({ role: 'member' }); renderWithQueryClient(); @@ -178,7 +223,8 @@ describe('RaceDetailPage - Re-open Race behavior', () => { mockIsOwnerOrAdmin.mockReturnValue(true); const viewModel = createViewModel('scheduled'); - mockGetRaceDetails.mockResolvedValue(viewModel); + mockGetRaceDetails.mockReturnValue(viewModel); + mockGetMembership.mockReturnValue({ role: 'owner' }); renderWithQueryClient(); diff --git a/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx b/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx index 95796f288..7ce7cdcb3 100644 --- a/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx +++ b/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx @@ -3,14 +3,14 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate'; -import { useLeagueMembership } from '@/hooks/useLeagueMembershipService'; +import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useRaceResultsDetail } from '@/hooks/race/useRaceResultsDetail'; +import { useRaceWithSOF } from '@/hooks/race/useRaceWithSOF'; import { Trophy } from 'lucide-react'; export function RaceResultsInteractive() { @@ -18,22 +18,13 @@ export function RaceResultsInteractive() { const params = useParams(); const raceId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { raceResultsService, raceService } = useServices(); - // Fetch data using new hook - const { data: raceData, isLoading, error, retry } = useDataFetching({ - queryKey: ['raceResultsDetail', raceId, currentDriverId], - queryFn: () => raceResultsService.getResultsDetail(raceId, currentDriverId), - }); - - // Fetch SOF data - const { data: sofData } = useDataFetching({ - queryKey: ['raceWithSOF', raceId], - queryFn: () => raceResultsService.getWithSOF(raceId), - }); + // Fetch data using existing hooks + const { data: raceData, isLoading, error, retry } = useRaceResultsDetail(raceId, currentDriverId); + const { data: sofData } = useRaceWithSOF(raceId); // Fetch membership - const { data: membership } = useLeagueMembership(raceData?.league?.id || '', currentDriverId); + const { data: membershipsData } = useLeagueMemberships(raceData?.league?.id || '', currentDriverId || ''); // UI State const [importing, setImporting] = useState(false); @@ -42,7 +33,8 @@ export function RaceResultsInteractive() { const [showImportForm, setShowImportForm] = useState(false); const raceSOF = sofData?.strengthOfField || null; - const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; + const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId); + const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false; // Transform data for template const results = raceData?.results.map(result => ({ @@ -142,4 +134,4 @@ export function RaceResultsInteractive() { )} ); - } \ No newline at end of file +} \ No newline at end of file diff --git a/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx b/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx index eadff0595..c55dbccca 100644 --- a/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx +++ b/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx @@ -3,14 +3,13 @@ import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate'; -import { useLeagueMembership } from '@/hooks/useLeagueMembershipService'; +import { useLeagueMemberships } from '@/hooks/league/useLeagueMemberships'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useRaceStewardingData } from '@/hooks/race/useRaceStewardingData'; import { Gavel } from 'lucide-react'; export function RaceStewardingInteractive() { @@ -18,21 +17,18 @@ export function RaceStewardingInteractive() { const params = useParams(); const raceId = params.id as string; const currentDriverId = useEffectiveDriverId(); - const { raceStewardingService } = useServices(); - // Fetch data using new hook - const { data: stewardingData, isLoading, error, retry } = useDataFetching({ - queryKey: ['raceStewardingData', raceId, currentDriverId], - queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId), - }); + // Fetch data using existing hooks + const { data: stewardingData, isLoading, error, retry } = useRaceStewardingData(raceId, currentDriverId); // Fetch membership - const { data: membership } = useLeagueMembership(stewardingData?.league?.id || '', currentDriverId); + const { data: membershipsData } = useLeagueMemberships(stewardingData?.league?.id || '', currentDriverId || ''); // UI State const [activeTab, setActiveTab] = useState('pending'); - const isAdmin = membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; + const currentMembership = membershipsData?.memberships.find(m => m.driverId === currentDriverId); + const isAdmin = currentMembership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(currentMembership.role) : false; // Actions const handleBack = () => { @@ -88,4 +84,4 @@ export function RaceStewardingInteractive() { )} ); - } \ No newline at end of file +} \ No newline at end of file diff --git a/apps/website/app/sponsor/billing/page.tsx b/apps/website/app/sponsor/billing/page.tsx index 030859619..c378b8437 100644 --- a/apps/website/app/sponsor/billing/page.tsx +++ b/apps/website/app/sponsor/billing/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { motion, useReducedMotion } from 'framer-motion'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; @@ -10,8 +10,9 @@ import StatusBadge from '@/components/ui/StatusBadge'; import InfoBanner from '@/components/ui/InfoBanner'; import PageHeader from '@/components/ui/PageHeader'; import { siteConfig } from '@/lib/siteConfig'; -import { BillingViewModel } from '@/lib/view-models/BillingViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useSponsorBilling } from '@/hooks/sponsor/useSponsorBilling'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { SPONSOR_SERVICE_TOKEN } from '@/lib/di/tokens'; import { CreditCard, DollarSign, @@ -260,29 +261,12 @@ function InvoiceRow({ invoice, index }: { invoice: any; index: number }) { export default function SponsorBillingPage() { const shouldReduceMotion = useReducedMotion(); - const { sponsorService } = useServices(); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); + const sponsorService = useInject(SPONSOR_SERVICE_TOKEN); const [showAllInvoices, setShowAllInvoices] = useState(false); - useEffect(() => { - const loadBilling = async () => { - try { - const billingData = await sponsorService.getBilling('demo-sponsor-1'); - setData(new BillingViewModel(billingData)); - } catch (err) { - console.error('Error loading billing data:', err); - setError('Failed to load billing data'); - } finally { - setLoading(false); - } - }; + const { data: billingData, isLoading, error, retry } = useSponsorBilling('demo-sponsor-1'); - loadBilling(); - }, []); - - if (loading) { + if (isLoading) { return (
@@ -293,16 +277,23 @@ export default function SponsorBillingPage() { ); } - if (error || !data) { + if (error || !billingData) { return (
-

{error || 'No billing data available'}

+

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

+ {error && ( + + )}
); } + const data = billingData; + const handleSetDefault = (methodId: string) => { // In a real app, this would call an API console.log('Setting default payment method:', methodId); diff --git a/apps/website/app/sponsor/campaigns/page.tsx b/apps/website/app/sponsor/campaigns/page.tsx index bb8183145..ed0bc6974 100644 --- a/apps/website/app/sponsor/campaigns/page.tsx +++ b/apps/website/app/sponsor/campaigns/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import Link from 'next/link'; @@ -8,13 +8,12 @@ import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import StatusBadge from '@/components/ui/StatusBadge'; import InfoBanner from '@/components/ui/InfoBanner'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { SponsorSponsorshipsViewModel } from '@/lib/view-models/SponsorSponsorshipsViewModel'; -import { - Megaphone, - Trophy, - Users, - Eye, +import { useSponsorSponsorships } from '@/hooks/sponsor/useSponsorSponsorships'; +import { + Megaphone, + Trophy, + Users, + Eye, Calendar, ExternalLink, Plus, @@ -364,37 +363,15 @@ export default function SponsorCampaignsPage() { const router = useRouter(); const searchParams = useSearchParams(); const shouldReduceMotion = useReducedMotion(); - const { sponsorService } = useServices(); const initialType = (searchParams.get('type') as SponsorshipType) || 'all'; const [typeFilter, setTypeFilter] = useState(initialType); const [statusFilter, setStatusFilter] = useState('all'); const [searchQuery, setSearchQuery] = useState(''); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - useEffect(() => { - const loadSponsorships = async () => { - try { - const sponsorshipsData = await sponsorService.getSponsorSponsorships('demo-sponsor-1'); - if (sponsorshipsData) { - setData(sponsorshipsData); - } else { - setError('Failed to load sponsorships data'); - } - } catch (err) { - console.error('Error loading sponsorships:', err); - setError('Failed to load sponsorships data'); - } finally { - setLoading(false); - } - }; + const { data: sponsorshipsData, isLoading, error, retry } = useSponsorSponsorships('demo-sponsor-1'); - loadSponsorships(); - }, []); - - if (loading) { + if (isLoading) { return (
@@ -405,16 +382,23 @@ export default function SponsorCampaignsPage() { ); } - if (error || !data) { + if (error || !sponsorshipsData) { return (
-

{error || 'No sponsorships data available'}

+

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

+ {error && ( + + )}
); } + const data = sponsorshipsData; + // Filter sponsorships const filteredSponsorships = data.sponsorships.filter(s => { if (typeFilter !== 'all' && s.type !== typeFilter) return false; @@ -443,17 +427,6 @@ export default function SponsorCampaignsPage() { platform: data.sponsorships.filter(s => s.type === 'platform').length, }; - if (loading) { - return ( -
-
-
-

Loading sponsorships...

-
-
- ); - } - return (
{/* Header */} diff --git a/apps/website/app/sponsor/dashboard/page.tsx b/apps/website/app/sponsor/dashboard/page.tsx index 8c43c40d0..404bf0235 100644 --- a/apps/website/app/sponsor/dashboard/page.tsx +++ b/apps/website/app/sponsor/dashboard/page.tsx @@ -2,7 +2,6 @@ export const dynamic = 'force-dynamic'; -import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { motion, useReducedMotion, AnimatePresence } from 'framer-motion'; import Card from '@/components/ui/Card'; @@ -38,69 +37,46 @@ import { RefreshCw } from 'lucide-react'; import Link from 'next/link'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { SponsorDashboardViewModel } from '@/lib/view-models/SponsorDashboardViewModel'; - - - - +import { useInject } from '@/lib/di/hooks/useInject'; +import { SPONSOR_SERVICE_TOKEN, POLICY_SERVICE_TOKEN } from '@/lib/di/tokens'; +import { enhanceQueryResult } from '@/lib/di/hooks/useReactQueryWithApiError'; export default function SponsorDashboardPage() { const shouldReduceMotion = useReducedMotion(); - const { sponsorService, policyService } = useServices(); - const [timeRange, setTimeRange] = useState<'7d' | '30d' | '90d' | 'all'>('30d'); - const [loading, setLoading] = useState(true); - const [data, setData] = useState(null); - const [error, setError] = useState(null); + const sponsorService = useInject(SPONSOR_SERVICE_TOKEN); + const policyService = useInject(POLICY_SERVICE_TOKEN); - const { - data: policySnapshot, - isLoading: policyLoading, - isError: policyError, - } = useQuery({ + const policyQuery = useQuery({ queryKey: ['policySnapshot'], queryFn: () => policyService.getSnapshot(), staleTime: 60_000, gcTime: 5 * 60_000, }); + const enhancedPolicyQuery = enhanceQueryResult(policyQuery); + const policySnapshot = enhancedPolicyQuery.data; + const policyLoading = enhancedPolicyQuery.isLoading; + const policyError = enhancedPolicyQuery.error; + const sponsorPortalState = policySnapshot ? policyService.getCapabilityState(policySnapshot, 'sponsors.portal') : null; - useEffect(() => { - if (policyLoading) { - return; - } + const dashboardQuery = useQuery({ + queryKey: ['sponsorDashboard', 'demo-sponsor-1', sponsorPortalState], + queryFn: () => sponsorService.getSponsorDashboard('demo-sponsor-1'), + enabled: !!policySnapshot && sponsorPortalState === 'enabled', + staleTime: 300_000, + gcTime: 10 * 60_000, + }); - if (policyError || sponsorPortalState !== 'enabled') { - setError( - sponsorPortalState === 'coming_soon' - ? 'Sponsor portal is coming soon.' - : 'Sponsor portal is currently unavailable.', - ); - setLoading(false); - return; - } + const enhancedDashboardQuery = enhanceQueryResult(dashboardQuery); + const dashboardData = enhancedDashboardQuery.data; + const dashboardLoading = enhancedDashboardQuery.isLoading; + const dashboardError = enhancedDashboardQuery.error; - const loadDashboard = async () => { - try { - const dashboardData = await sponsorService.getSponsorDashboard('demo-sponsor-1'); - if (dashboardData) { - setData(dashboardData); - } else { - setError('Failed to load dashboard data'); - } - } catch (err) { - console.error('Error loading dashboard:', err); - setError('Failed to load dashboard data'); - } finally { - setLoading(false); - } - }; - - void loadDashboard(); - }, [policyLoading, policyError, sponsorPortalState, sponsorService]); + const loading = policyLoading || dashboardLoading; + const error = policyError || dashboardError || (sponsorPortalState !== 'enabled' && sponsorPortalState !== null); if (loading) { return ( @@ -113,36 +89,31 @@ export default function SponsorDashboardPage() { ); } - if (error || !data) { + if (error || !dashboardData) { + const errorMessage = sponsorPortalState === 'coming_soon' + ? 'Sponsor portal is coming soon.' + : sponsorPortalState === 'disabled' + ? 'Sponsor portal is currently unavailable.' + : 'Failed to load dashboard data'; + return (
-

{error || 'No dashboard data available'}

+

{errorMessage}

); } - const categoryData = data.categoryData; - - if (loading) { - return ( -
-
- -

Loading dashboard...

-
-
- ); - } + const categoryData = dashboardData.categoryData; return (
{/* Header */}
-

Sponsor Dashboard

-

Welcome back, {data.sponsorName}

+

Sponsor Dashboard

+

Welcome back, {dashboardData.sponsorName}

{/* Time Range Selector */} @@ -150,9 +121,9 @@ export default function SponsorDashboardPage() { {(['7d', '30d', '90d', 'all'] as const).map((range) => (
{/* Leagues */} - {data.sponsorships.leagues.map((league) => ( + {dashboardData.sponsorships.leagues.map((league: any) => (
( + {dashboardData.sponsorships.teams.map((team: any) => (
@@ -340,7 +311,7 @@ export default function SponsorDashboardPage() { ))} {/* Drivers */} - {data.sponsorships.drivers.slice(0, 2).map((driver) => ( + {dashboardData.sponsorships.drivers.slice(0, 2).map((driver: any) => (
@@ -371,15 +342,15 @@ export default function SponsorDashboardPage() { {/* Upcoming Events */}
-

+

Upcoming Sponsored Events -

+
- {data.sponsorships.races.length > 0 ? ( + {dashboardData.sponsorships.races.length > 0 ? (
- {data.sponsorships.races.map((race) => ( + {dashboardData.sponsorships.races.map((race: any) => (
@@ -448,14 +419,14 @@ export default function SponsorDashboardPage() { {/* Renewal Alerts */} - {data.upcomingRenewals.length > 0 && ( + {dashboardData.upcomingRenewals.length > 0 && (

Upcoming Renewals

- {data.upcomingRenewals.map((renewal) => ( + {dashboardData.upcomingRenewals.map((renewal: any) => ( ))}
@@ -466,7 +437,7 @@ export default function SponsorDashboardPage() {

Recent Activity

- {data.recentActivity.map((activity) => ( + {dashboardData.recentActivity.map((activity: any) => ( ))}
@@ -481,16 +452,16 @@ export default function SponsorDashboardPage() {
Active Sponsorships - {data.activeSponsorships} + {dashboardData.activeSponsorships}
Total Investment - {data.formattedTotalInvestment} + {dashboardData.formattedTotalInvestment}
Cost per 1K Views - {data.costPerThousandViews} + {dashboardData.costPerThousandViews}
diff --git a/apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx b/apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx new file mode 100644 index 000000000..4fe7beb0c --- /dev/null +++ b/apps/website/app/sponsor/leagues/SponsorLeaguesInteractive.tsx @@ -0,0 +1,439 @@ +'use client'; + +import { useState } from 'react'; +import { motion, useReducedMotion } from 'framer-motion'; +import Link from 'next/link'; +import Card from '@/components/ui/Card'; +import Button from '@/components/ui/Button'; +import { siteConfig } from '@/lib/siteConfig'; +import { useAvailableLeagues } from '@/hooks/sponsor/useAvailableLeagues'; +import { + Trophy, + Users, + Eye, + Search, + Star, + ChevronRight, + Filter, + Car, + Flag, + TrendingUp, + CheckCircle2, + Clock, + Megaphone, + ArrowUpDown +} from 'lucide-react'; + +interface AvailableLeague { + id: string; + name: string; + game: string; + drivers: number; + avgViewsPerRace: number; + mainSponsorSlot: { available: boolean; price: number }; + secondarySlots: { available: number; total: number; price: number }; + rating: number; + tier: 'premium' | 'standard' | 'starter'; + nextRace?: string; + seasonStatus: 'active' | 'upcoming' | 'completed'; + description: string; +} + +type SortOption = 'rating' | 'drivers' | 'price' | 'views'; +type TierFilter = 'all' | 'premium' | 'standard' | 'starter'; +type AvailabilityFilter = 'all' | 'main' | 'secondary'; + +function LeagueCard({ league, index }: { league: any; index: number }) { + const shouldReduceMotion = useReducedMotion(); + + const tierConfig = { + premium: { + bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5', + border: 'border-yellow-500/30', + badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', + icon: '⭐' + }, + standard: { + bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5', + border: 'border-primary-blue/30', + badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', + icon: '🏆' + }, + starter: { + bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5', + border: 'border-gray-500/30', + badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30', + icon: '🚀' + }, + }; + + const statusConfig = { + active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' }, + upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' }, + completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' }, + }; + + const config = league.tierConfig; + const status = league.statusConfig; + + return ( + + +
+ {/* Header */} +
+
+
+ + {config.icon} {league.tier} + + + {status.label} + +
+

{league.name}

+

{league.game}

+
+
+ + {league.rating} +
+
+ + {/* Description */} +

{league.description}

+ + {/* Stats Grid */} +
+
+
{league.drivers}
+
Drivers
+
+
+
{league.formattedAvgViews}
+
Avg Views
+
+
+
{league.formattedCpm}
+
CPM
+
+
+ + {/* Next Race */} + {league.nextRace && ( +
+ + Next: + {league.nextRace} +
+ )} + + {/* Sponsorship Slots */} +
+
+
+
+ Main Sponsor +
+
+ {league.mainSponsorSlot.available ? ( + ${league.mainSponsorSlot.price}/season + ) : ( + + Filled + + )} +
+
+
+
+
0 ? 'bg-performance-green' : 'bg-racing-red'}`} /> + Secondary Slots +
+
+ {league.secondarySlots.available > 0 ? ( + + {league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price} + + ) : ( + + Full + + )} +
+
+
+ + {/* Actions */} +
+ + + + {(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && ( + + + + )} +
+
+ + + ); +} + +export default function SponsorLeaguesInteractive() { + const shouldReduceMotion = useReducedMotion(); + const { data, isLoading, isError, error } = useAvailableLeagues(); + const [searchQuery, setSearchQuery] = useState(''); + const [tierFilter, setTierFilter] = useState('all'); + const [availabilityFilter, setAvailabilityFilter] = useState('all'); + const [sortBy, setSortBy] = useState('rating'); + + if (isLoading) { + return ( +
+
+
+

Loading leagues...

+
+
+ ); + } + + if (isError || !data) { + return ( +
+
+

{error?.message || 'No leagues data available'}

+
+
+ ); + } + + // Filter and sort leagues + const filteredLeagues = data.leagues + .filter(league => { + if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { + return false; + } + if (tierFilter !== 'all' && league.tier !== tierFilter) { + return false; + } + if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) { + return false; + } + if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) { + return false; + } + return true; + }) + .sort((a, b) => { + switch (sortBy) { + case 'rating': return b.rating - a.rating; + case 'drivers': return b.drivers - a.drivers; + case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price; + case 'views': return b.avgViewsPerRace - a.avgViewsPerRace; + default: return 0; + } + }); + + // Calculate summary stats + const stats = { + total: data.leagues.length, + mainAvailable: data.leagues.filter(l => l.mainSponsorSlot.available).length, + secondaryAvailable: data.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0), + totalDrivers: data.leagues.reduce((sum, l) => sum + l.drivers, 0), + avgCpm: Math.round( + data.leagues.reduce((sum, l) => sum + l.cpm, 0) / data.leagues.length + ), + }; + + return ( +
+ {/* Breadcrumb */} +
+ Dashboard + + Browse Leagues +
+ + {/* Header */} +
+

+ + League Sponsorship Marketplace +

+

+ Discover racing leagues looking for sponsors. All prices shown exclude VAT. +

+
+ + {/* Stats Overview */} +
+ + +
{stats.total}
+
Leagues
+
+
+ + +
{stats.mainAvailable}
+
Main Slots
+
+
+ + +
{stats.secondaryAvailable}
+
Secondary Slots
+
+
+ + +
{stats.totalDrivers}
+
Total Drivers
+
+
+ + +
${stats.avgCpm}
+
Avg CPM
+
+
+
+ + {/* Filters */} +
+ {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" + /> +
+ + {/* Tier Filter */} + + + {/* Availability Filter */} + + + {/* Sort */} + +
+ + {/* Results Count */} +
+

+ Showing {filteredLeagues.length} of {data.leagues.length} leagues +

+
+ + + + + + +
+
+ + {/* League Grid */} + {filteredLeagues.length > 0 ? ( +
+ {filteredLeagues.map((league, index) => ( + + ))} +
+ ) : ( + + +

No leagues found

+

Try adjusting your filters to see more results

+ +
+ )} + + {/* Platform Fee Notice */} +
+
+ +
+

Platform Fee

+

+ A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} +

+
+
+
+
+ ); +} \ No newline at end of file diff --git a/apps/website/app/sponsor/leagues/[id]/page.tsx b/apps/website/app/sponsor/leagues/[id]/page.tsx index 4ad74c17f..3b2715b34 100644 --- a/apps/website/app/sponsor/leagues/[id]/page.tsx +++ b/apps/website/app/sponsor/leagues/[id]/page.tsx @@ -1,14 +1,13 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useParams, useSearchParams } from 'next/navigation'; import { motion, useReducedMotion } from 'framer-motion'; import Link from 'next/link'; import Card from '@/components/ui/Card'; import Button from '@/components/ui/Button'; import { siteConfig } from '@/lib/siteConfig'; -import { LeagueDetailViewModel } from '@/lib/view-models/LeagueDetailViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useSponsorLeagueDetail } from '@/hooks/sponsor/useSponsorLeagueDetail'; import { Trophy, Users, @@ -39,36 +38,14 @@ export default function SponsorLeagueDetailPage() { const searchParams = useSearchParams(); const shouldReduceMotion = useReducedMotion(); - const { sponsorService } = useServices(); - + const leagueId = params.id as string; const showSponsorAction = searchParams.get('action') === 'sponsor'; const [activeTab, setActiveTab] = useState(showSponsorAction ? 'sponsor' : 'overview'); const [selectedTier, setSelectedTier] = useState<'main' | 'secondary'>('main'); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const leagueId = params.id as string; + const { data: leagueData, isLoading, error, retry } = useSponsorLeagueDetail(leagueId); - useEffect(() => { - const loadLeagueDetail = async () => { - try { - const leagueData = await sponsorService.getLeagueDetail(leagueId); - setData(new LeagueDetailViewModel(leagueData)); - } catch (err) { - console.error('Error loading league detail:', err); - setError('Failed to load league detail'); - } finally { - setLoading(false); - } - }; - - if (leagueId) { - loadLeagueDetail(); - } - }, [leagueId, sponsorService]); - - if (loading) { + if (isLoading) { return (
@@ -79,16 +56,22 @@ export default function SponsorLeagueDetailPage() { ); } - if (error || !data) { + if (error || !leagueData) { return (
-

{error || 'No league data available'}

+

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

+ {error && ( + + )}
); } + const data = leagueData; const league = data.league; const config = league.tierConfig; diff --git a/apps/website/app/sponsor/leagues/page.tsx b/apps/website/app/sponsor/leagues/page.tsx index fc155c9e8..b830ae84d 100644 --- a/apps/website/app/sponsor/leagues/page.tsx +++ b/apps/website/app/sponsor/leagues/page.tsx @@ -1,460 +1,3 @@ -'use client'; +import SponsorLeaguesInteractive from './SponsorLeaguesInteractive'; -import { useState, useEffect } from 'react'; -import { motion, useReducedMotion } from 'framer-motion'; -import Link from 'next/link'; -import Card from '@/components/ui/Card'; -import Button from '@/components/ui/Button'; -import { siteConfig } from '@/lib/siteConfig'; -import { AvailableLeaguesViewModel } from '@/lib/view-models/AvailableLeaguesViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; -import { - Trophy, - Users, - Eye, - Search, - Star, - ChevronRight, - Filter, - Car, - Flag, - TrendingUp, - CheckCircle2, - Clock, - Megaphone, - ArrowUpDown -} from 'lucide-react'; - -interface AvailableLeague { - id: string; - name: string; - game: string; - drivers: number; - avgViewsPerRace: number; - mainSponsorSlot: { available: boolean; price: number }; - secondarySlots: { available: number; total: number; price: number }; - rating: number; - tier: 'premium' | 'standard' | 'starter'; - nextRace?: string; - seasonStatus: 'active' | 'upcoming' | 'completed'; - description: string; -} - - -type SortOption = 'rating' | 'drivers' | 'price' | 'views'; -type TierFilter = 'all' | 'premium' | 'standard' | 'starter'; -type AvailabilityFilter = 'all' | 'main' | 'secondary'; - -function LeagueCard({ league, index }: { league: any; index: number }) { - const shouldReduceMotion = useReducedMotion(); - - const tierConfig = { - premium: { - bg: 'bg-gradient-to-br from-yellow-500/10 to-amber-500/5', - border: 'border-yellow-500/30', - badge: 'bg-yellow-500/20 text-yellow-400 border-yellow-500/30', - icon: '⭐' - }, - standard: { - bg: 'bg-gradient-to-br from-primary-blue/10 to-cyan-500/5', - border: 'border-primary-blue/30', - badge: 'bg-primary-blue/20 text-primary-blue border-primary-blue/30', - icon: '🏆' - }, - starter: { - bg: 'bg-gradient-to-br from-gray-500/10 to-slate-500/5', - border: 'border-gray-500/30', - badge: 'bg-gray-500/20 text-gray-400 border-gray-500/30', - icon: '🚀' - }, - }; - - const statusConfig = { - active: { color: 'text-performance-green', bg: 'bg-performance-green/10', label: 'Active Season' }, - upcoming: { color: 'text-warning-amber', bg: 'bg-warning-amber/10', label: 'Starting Soon' }, - completed: { color: 'text-gray-400', bg: 'bg-gray-400/10', label: 'Season Ended' }, - }; - - const config = league.tierConfig; - const status = league.statusConfig; - - return ( - - -
- {/* Header */} -
-
-
- - {config.icon} {league.tier} - - - {status.label} - -
-

{league.name}

-

{league.game}

-
-
- - {league.rating} -
-
- - {/* Description */} -

{league.description}

- - {/* Stats Grid */} -
-
-
{league.drivers}
-
Drivers
-
-
-
{league.formattedAvgViews}
-
Avg Views
-
-
-
{league.formattedCpm}
-
CPM
-
-
- - {/* Next Race */} - {league.nextRace && ( -
- - Next: - {league.nextRace} -
- )} - - {/* Sponsorship Slots */} -
-
-
-
- Main Sponsor -
-
- {league.mainSponsorSlot.available ? ( - ${league.mainSponsorSlot.price}/season - ) : ( - - Filled - - )} -
-
-
-
-
0 ? 'bg-performance-green' : 'bg-racing-red'}`} /> - Secondary Slots -
-
- {league.secondarySlots.available > 0 ? ( - - {league.secondarySlots.available}/{league.secondarySlots.total} @ ${league.secondarySlots.price} - - ) : ( - - Full - - )} -
-
-
- - {/* Actions */} -
- - - - {(league.mainSponsorSlot.available || league.secondarySlots.available > 0) && ( - - - - )} -
-
- - - ); -} - -export default function SponsorLeaguesPage() { - const shouldReduceMotion = useReducedMotion(); - const { sponsorService } = useServices(); - const [searchQuery, setSearchQuery] = useState(''); - const [tierFilter, setTierFilter] = useState('all'); - const [availabilityFilter, setAvailabilityFilter] = useState('all'); - const [sortBy, setSortBy] = useState('rating'); - const [data, setData] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - useEffect(() => { - const loadLeagues = async () => { - try { - const leaguesData = await sponsorService.getAvailableLeagues(); - setData(new AvailableLeaguesViewModel(leaguesData)); - } catch (err) { - console.error('Error loading leagues:', err); - setError('Failed to load leagues data'); - } finally { - setLoading(false); - } - }; - - loadLeagues(); - }, [sponsorService]); - - if (loading) { - return ( -
-
-
-

Loading leagues...

-
-
- ); - } - - if (error || !data) { - return ( -
-
-

{error || 'No leagues data available'}

-
-
- ); - } - - // Filter and sort leagues - const filteredLeagues = data.leagues - .filter(league => { - if (searchQuery && !league.name.toLowerCase().includes(searchQuery.toLowerCase())) { - return false; - } - if (tierFilter !== 'all' && league.tier !== tierFilter) { - return false; - } - if (availabilityFilter === 'main' && !league.mainSponsorSlot.available) { - return false; - } - if (availabilityFilter === 'secondary' && league.secondarySlots.available === 0) { - return false; - } - return true; - }) - .sort((a, b) => { - switch (sortBy) { - case 'rating': return b.rating - a.rating; - case 'drivers': return b.drivers - a.drivers; - case 'price': return a.mainSponsorSlot.price - b.mainSponsorSlot.price; - case 'views': return b.avgViewsPerRace - a.avgViewsPerRace; - default: return 0; - } - }); - - // Calculate summary stats - const stats = { - total: data.leagues.length, - mainAvailable: data.leagues.filter(l => l.mainSponsorSlot.available).length, - secondaryAvailable: data.leagues.reduce((sum, l) => sum + l.secondarySlots.available, 0), - totalDrivers: data.leagues.reduce((sum, l) => sum + l.drivers, 0), - avgCpm: Math.round( - data.leagues.reduce((sum, l) => sum + l.cpm, 0) / data.leagues.length - ), - }; - - return ( -
- {/* Breadcrumb */} -
- Dashboard - - Browse Leagues -
- - {/* Header */} -
-

- - League Sponsorship Marketplace -

-

- Discover racing leagues looking for sponsors. All prices shown exclude VAT. -

-
- - {/* Stats Overview */} -
- - -
{stats.total}
-
Leagues
-
-
- - -
{stats.mainAvailable}
-
Main Slots
-
-
- - -
{stats.secondaryAvailable}
-
Secondary Slots
-
-
- - -
{stats.totalDrivers}
-
Total Drivers
-
-
- - -
${stats.avgCpm}
-
Avg CPM
-
-
-
- - {/* Filters */} -
- {/* Search */} -
- - setSearchQuery(e.target.value)} - className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-charcoal-outline bg-iron-gray text-white placeholder-gray-500 focus:border-primary-blue focus:outline-none" - /> -
- - {/* Tier Filter */} - - - {/* Availability Filter */} - - - {/* Sort */} - -
- - {/* Results Count */} -
-

- Showing {filteredLeagues.length} of {data.leagues.length} leagues -

-
- - - - - - -
-
- - {/* League Grid */} - {filteredLeagues.length > 0 ? ( -
- {filteredLeagues.map((league, index) => ( - - ))} -
- ) : ( - - -

No leagues found

-

Try adjusting your filters to see more results

- -
- )} - - {/* Platform Fee Notice */} -
-
- -
-

Platform Fee

-

- A {siteConfig.fees.platformFeePercent}% platform fee applies to all sponsorship payments. {siteConfig.fees.description} -

-
-
-
-
- ); -} +export default SponsorLeaguesInteractive; diff --git a/apps/website/app/teams/TeamsInteractive.tsx b/apps/website/app/teams/TeamsInteractive.tsx index 0e7e786d9..80563991a 100644 --- a/apps/website/app/teams/TeamsInteractive.tsx +++ b/apps/website/app/teams/TeamsInteractive.tsx @@ -17,9 +17,8 @@ import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useAllTeams } from '@/hooks/team/useAllTeams'; type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; @@ -27,12 +26,8 @@ const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner export default function TeamsInteractive() { const router = useRouter(); - const { teamService } = useServices(); - const { data: teams = [], isLoading: loading, error, retry } = useDataFetching({ - queryKey: ['allTeams'], - queryFn: () => teamService.getAllTeams(), - }); + const { data: teams = [], isLoading: loading, error, retry } = useAllTeams(); const [searchQuery, setSearchQuery] = useState(''); const [showCreateForm, setShowCreateForm] = useState(false); diff --git a/apps/website/app/teams/[id]/TeamDetailInteractive.tsx b/apps/website/app/teams/[id]/TeamDetailInteractive.tsx index aadb274e4..b13637923 100644 --- a/apps/website/app/teams/[id]/TeamDetailInteractive.tsx +++ b/apps/website/app/teams/[id]/TeamDetailInteractive.tsx @@ -1,14 +1,16 @@ 'use client'; -import { useState, useCallback } from 'react'; +import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import TeamDetailTemplate from '@/templates/TeamDetailTemplate'; -import { useServices } from '@/lib/services/ServiceProvider'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; // Shared state components -import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; import { StateContainer } from '@/components/shared/state/StateContainer'; +import { useTeamDetails } from '@/hooks/team/useTeamDetails'; +import { useTeamMembers } from '@/hooks/team/useTeamMembers'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { TEAM_SERVICE_TOKEN } from '@/lib/di/tokens'; import { Users } from 'lucide-react'; type Tab = 'overview' | 'roster' | 'standings' | 'admin'; @@ -16,27 +18,21 @@ type Tab = 'overview' | 'roster' | 'standings' | 'admin'; export default function TeamDetailInteractive() { const params = useParams(); const teamId = params.id as string; - const { teamService } = useServices(); const router = useRouter(); const currentDriverId = useEffectiveDriverId(); + const teamService = useInject(TEAM_SERVICE_TOKEN); const [activeTab, setActiveTab] = useState('overview'); - // Fetch team details - const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({ - queryKey: ['teamDetails', teamId, currentDriverId], - queryFn: () => teamService.getTeamDetails(teamId, currentDriverId), - }); + // Fetch team details using DI + React-Query + const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useTeamDetails(teamId, currentDriverId); - // Fetch team members - const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useDataFetching({ - queryKey: ['teamMembers', teamId, currentDriverId], - queryFn: async () => { - if (!teamDetails?.ownerId) return []; - return teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId); - }, - enabled: !!teamDetails?.ownerId, - }); + // Fetch team members using DI + React-Query + const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useTeamMembers( + teamId, + currentDriverId, + teamDetails?.ownerId || '' + ); const isLoading = teamLoading || membersLoading; const error = teamError || membersError; @@ -126,7 +122,7 @@ export default function TeamDetailInteractive() { > {(teamData) => ( ({}); const [formData, setFormData] = useState({ @@ -50,37 +49,37 @@ export default function CreateDriverForm() { const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (loading) return; + if (createDriverMutation.isPending) return; const isValid = await validateForm(); if (!isValid) return; - setLoading(true); - - try { - const bio = formData.bio.trim(); + const bio = formData.bio.trim(); + const displayName = formData.name.trim(); + const parts = displayName.split(' ').filter(Boolean); + const firstName = parts[0] ?? displayName; + const lastName = parts.slice(1).join(' ') || 'Driver'; - const displayName = formData.name.trim(); - const parts = displayName.split(' ').filter(Boolean); - const firstName = parts[0] ?? displayName; - const lastName = parts.slice(1).join(' ') || 'Driver'; - - await driverService.completeDriverOnboarding({ + createDriverMutation.mutate( + { firstName, lastName, displayName, country: formData.country.trim().toUpperCase(), ...(bio ? { bio } : {}), - }); - - router.push('/profile'); - router.refresh(); - } catch (error) { - setErrors({ - submit: error instanceof Error ? error.message : 'Failed to create profile' - }); - setLoading(false); - } + }, + { + onSuccess: () => { + router.push('/profile'); + router.refresh(); + }, + onError: (error) => { + setErrors({ + submit: error instanceof Error ? error.message : 'Failed to create profile' + }); + }, + } + ); }; return ( @@ -98,7 +97,7 @@ export default function CreateDriverForm() { error={!!errors.name} errorMessage={errors.name} placeholder="Alex Vermeer" - disabled={loading} + disabled={createDriverMutation.isPending} />
@@ -114,7 +113,7 @@ export default function CreateDriverForm() { error={!!errors.name} errorMessage={errors.name} placeholder="Alex Vermeer" - disabled={loading} + disabled={createDriverMutation.isPending} />
@@ -131,7 +130,7 @@ export default function CreateDriverForm() { errorMessage={errors.country} placeholder="NL" maxLength={3} - disabled={loading} + disabled={createDriverMutation.isPending} />

Use ISO 3166-1 alpha-2 or alpha-3 code

@@ -147,7 +146,7 @@ export default function CreateDriverForm() { placeholder="Tell us about yourself..." maxLength={500} rows={4} - disabled={loading} + disabled={createDriverMutation.isPending} className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none" />

@@ -167,10 +166,10 @@ export default function CreateDriverForm() { diff --git a/apps/website/components/drivers/DriverProfile.tsx b/apps/website/components/drivers/DriverProfile.tsx index 260af14fd..4ceb3e207 100644 --- a/apps/website/components/drivers/DriverProfile.tsx +++ b/apps/website/components/drivers/DriverProfile.tsx @@ -8,8 +8,7 @@ import ProfileStats from './ProfileStats'; import CareerHighlights from './CareerHighlights'; import DriverRankings from './DriverRankings'; import PerformanceMetrics from './PerformanceMetrics'; -import { useEffect, useState } from 'react'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useDriverProfile } from '@/hooks/driver/useDriverProfile'; interface DriverProfileProps { driver: DriverViewModel; @@ -25,42 +24,29 @@ interface DriverTeamViewModel { } export default function DriverProfile({ driver, isOwnProfile = false, onEditClick }: DriverProfileProps) { - const { driverService } = useServices(); - const [profileData, setProfileData] = useState(null); - const [teamData, setTeamData] = useState(null); + const { data: profileData, isLoading } = useDriverProfile(driver.id); - useEffect(() => { - const load = async () => { - try { - // Load driver profile - const profile = await driverService.getDriverProfile(driver.id); - - // Extract stats from profile - if (profile.stats) { - setProfileData(profile.stats); - } - - // Load team data if available - if (profile.teamMemberships && profile.teamMemberships.length > 0) { - const currentTeam = profile.teamMemberships.find(m => m.isCurrent) || profile.teamMemberships[0]; - if (currentTeam) { - setTeamData({ - team: { - name: currentTeam.teamName, - tag: currentTeam.teamTag ?? '' - } - }); - } - } - } catch (error) { - console.error('Failed to load driver profile data:', error); + // Extract team data from profile + const teamData: DriverTeamViewModel | null = (() => { + if (!profileData?.teamMemberships || profileData.teamMemberships.length === 0) { + return null; + } + + const currentTeam = profileData.teamMemberships.find(m => m.isCurrent) || profileData.teamMemberships[0]; + if (!currentTeam) { + return null; + } + + return { + team: { + name: currentTeam.teamName, + tag: currentTeam.teamTag ?? '' } }; - void load(); - }, [driver.id, driverService]); + })(); - const driverStats = profileData; - const globalRank = profileData?.overallRank ?? null; + const driverStats = profileData?.stats ?? null; + const globalRank = driverStats?.overallRank ?? null; const totalDrivers = 1000; // Placeholder const performanceStats = driverStats ? { diff --git a/apps/website/components/drivers/ProfileStats.tsx b/apps/website/components/drivers/ProfileStats.tsx index f7bc7234a..051117c06 100644 --- a/apps/website/components/drivers/ProfileStats.tsx +++ b/apps/website/components/drivers/ProfileStats.tsx @@ -1,9 +1,9 @@ 'use client'; +import { useDriverProfile } from '@/hooks/driver'; +import { useMemo } from 'react'; import Card from '../ui/Card'; import RankBadge from './RankBadge'; -import { useMemo } from 'react'; -import { useDriverProfile } from '@/hooks/useDriverService'; interface ProfileStatsProps { driverId?: string; @@ -206,35 +206,4 @@ export default function ProfileStats({ driverId, stats }: ProfileStatsProps) { ); } -function PerformanceRow({ label, races, wins, podiums, avgFinish }: { - label: string; - races: number; - wins: number; - podiums: number; - avgFinish: number; -}) { - const winRate = ((wins / races) * 100).toFixed(0); - - return ( -

-
-
{label}
-
{races} races
-
-
-
-
Wins
-
{wins} ({winRate}%)
-
-
-
Podiums
-
{podiums}
-
-
-
Avg
-
{avgFinish.toFixed(1)}
-
-
-
- ); -} + diff --git a/apps/website/components/landing/AlternatingSection.tsx b/apps/website/components/landing/AlternatingSection.tsx index 7a157626d..3143b3412 100644 --- a/apps/website/components/landing/AlternatingSection.tsx +++ b/apps/website/components/landing/AlternatingSection.tsx @@ -1,9 +1,9 @@ 'use client'; -import { useRef } from 'react'; import Container from '@/components/ui/Container'; import Heading from '@/components/ui/Heading'; -import { useParallax } from '../../hooks/useScrollProgress'; +import { useParallax } from '@/hooks/useScrollProgress'; +import { useRef } from 'react'; interface AlternatingSectionProps { heading: string; diff --git a/apps/website/components/landing/EmailCapture.tsx b/apps/website/components/landing/EmailCapture.tsx index a7d262ffa..2d68d59ee 100644 --- a/apps/website/components/landing/EmailCapture.tsx +++ b/apps/website/components/landing/EmailCapture.tsx @@ -2,7 +2,8 @@ import { useState, FormEvent } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { LANDING_SERVICE_TOKEN } from '@/lib/di/tokens'; type FeedbackState = | { type: 'idle' } @@ -14,7 +15,7 @@ type FeedbackState = export default function EmailCapture() { const [email, setEmail] = useState(''); const [feedback, setFeedback] = useState({ type: 'idle' }); - const { landingService } = useServices(); + const landingService = useInject(LANDING_SERVICE_TOKEN); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); diff --git a/apps/website/components/leagues/CreateLeagueForm.tsx b/apps/website/components/leagues/CreateLeagueForm.tsx index 22aa81cdc..8752f99d5 100644 --- a/apps/website/components/leagues/CreateLeagueForm.tsx +++ b/apps/website/components/leagues/CreateLeagueForm.tsx @@ -4,8 +4,10 @@ import { useState, FormEvent } from 'react'; import { useRouter } from 'next/navigation'; import Input from '../ui/Input'; import Button from '../ui/Button'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useCreateLeague } from '@/hooks/league/useCreateLeague'; import { useAuth } from '@/lib/auth/AuthContext'; +import { useInject } from '@/lib/di/hooks/useInject'; +import { DRIVER_SERVICE_TOKEN } from '@/lib/di/tokens'; interface FormErrors { name?: string; @@ -17,7 +19,6 @@ interface FormErrors { export default function CreateLeagueForm() { const router = useRouter(); - const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({}); const [formData, setFormData] = useState({ @@ -51,12 +52,13 @@ export default function CreateLeagueForm() { }; const { session } = useAuth(); - const { driverService, leagueService } = useServices(); + const driverService = useInject(DRIVER_SERVICE_TOKEN); + const createLeagueMutation = useCreateLeague(); const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (loading) return; + if (createLeagueMutation.isPending) return; if (!validateForm()) return; @@ -65,15 +67,12 @@ export default function CreateLeagueForm() { return; } - setLoading(true); - try { // Get current driver const currentDriver = await driverService.getDriverProfile(session.user.userId); if (!currentDriver) { setErrors({ submit: 'No driver profile found. Please create a profile first.' }); - setLoading(false); return; } @@ -85,14 +84,13 @@ export default function CreateLeagueForm() { ownerId: session.user.userId, }; - const result = await leagueService.createLeague(input); + const result = await createLeagueMutation.mutateAsync(input); router.push(`/leagues/${result.leagueId}`); router.refresh(); } catch (error) { setErrors({ submit: error instanceof Error ? error.message : 'Failed to create league' }); - setLoading(false); } }; @@ -112,7 +110,7 @@ export default function CreateLeagueForm() { errorMessage={errors.name} placeholder="European GT Championship" maxLength={100} - disabled={loading} + disabled={createLeagueMutation.isPending} />

{formData.name.length}/100 @@ -130,7 +128,7 @@ export default function CreateLeagueForm() { placeholder="Weekly GT3 racing with professional drivers" maxLength={500} rows={4} - disabled={loading} + disabled={createLeagueMutation.isPending} className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline placeholder:text-gray-500 focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6 resize-none" />

@@ -149,7 +147,7 @@ export default function CreateLeagueForm() { id="pointsSystem" value={formData.pointsSystem} onChange={(e) => setFormData({ ...formData, pointsSystem: e.target.value as 'f1-2024' | 'indycar' })} - disabled={loading} + disabled={createLeagueMutation.isPending} className="block w-full rounded-md border-0 px-4 py-3 bg-iron-gray text-white shadow-sm ring-1 ring-inset ring-charcoal-outline focus:ring-2 focus:ring-inset focus:ring-primary-blue transition-all duration-150 sm:text-sm sm:leading-6" > @@ -170,7 +168,7 @@ export default function CreateLeagueForm() { errorMessage={errors.sessionDuration} min={1} max={240} - disabled={loading} + disabled={createLeagueMutation.isPending} />

@@ -183,10 +181,10 @@ export default function CreateLeagueForm() { diff --git a/apps/website/components/leagues/JoinLeagueButton.tsx b/apps/website/components/leagues/JoinLeagueButton.tsx index 8bc067d99..c39967e55 100644 --- a/apps/website/components/leagues/JoinLeagueButton.tsx +++ b/apps/website/components/leagues/JoinLeagueButton.tsx @@ -3,7 +3,7 @@ import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import { getMembership } from '@/lib/leagueMembership'; import { useState } from 'react'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useLeagueMembershipMutation } from '@/hooks/league/useLeagueMembershipMutation'; import Button from '../ui/Button'; interface JoinLeagueButtonProps { @@ -18,16 +18,16 @@ export default function JoinLeagueButton({ onMembershipChange, }: JoinLeagueButtonProps) { const currentDriverId = useEffectiveDriverId(); - const membership = getMembership(leagueId, currentDriverId); - const { leagueMembershipService } = useServices(); + const membership = currentDriverId ? getMembership(leagueId, currentDriverId) : null; + const { joinLeague, leaveLeague } = useLeagueMembershipMutation(); - const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [showConfirmDialog, setShowConfirmDialog] = useState(false); const [dialogAction, setDialogAction] = useState<'join' | 'leave' | 'request'>('join'); const handleJoin = async () => { - setLoading(true); + if (!currentDriverId) return; + setError(null); try { if (isInviteOnly) { @@ -36,33 +36,30 @@ export default function JoinLeagueButton({ ); } - await leagueMembershipService.joinLeague(leagueId, currentDriverId); + await joinLeague.mutateAsync({ leagueId, driverId: currentDriverId }); onMembershipChange?.(); setShowConfirmDialog(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to join league'); - } finally { - setLoading(false); } }; const handleLeave = async () => { - setLoading(true); + if (!currentDriverId) return; + setError(null); try { if (membership?.role === 'owner') { throw new Error('League owner cannot leave the league'); } - await leagueMembershipService.leaveLeague(leagueId, currentDriverId); + await leaveLeague.mutateAsync({ leagueId, driverId: currentDriverId }); onMembershipChange?.(); setShowConfirmDialog(false); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to leave league'); - } finally { - setLoading(false); } }; @@ -93,7 +90,7 @@ export default function JoinLeagueButton({ return 'danger'; }; - const isDisabled = membership?.role === 'owner' || loading; + const isDisabled = membership?.role === 'owner' || joinLeague.isPending || leaveLeague.isPending; return ( <> @@ -109,7 +106,7 @@ export default function JoinLeagueButton({ disabled={isDisabled} className="w-full" > - {loading ? 'Processing...' : getButtonText()} + {(joinLeague.isPending || leaveLeague.isPending) ? 'Processing...' : getButtonText()} {error && ( @@ -142,15 +139,15 @@ export default function JoinLeagueButton({ @@ -214,9 +211,9 @@ export default function QuickPenaltyModal({ raceId, drivers, onClose, preSelecte type="submit" variant="primary" className="flex-1" - disabled={loading || !selectedRaceId || !selectedDriver || !infractionType || !severity} + disabled={penaltyMutation.isPending || !selectedRaceId || !selectedDriver || !infractionType || !severity} > - {loading ? 'Applying...' : 'Apply Penalty'} + {penaltyMutation.isPending ? 'Applying...' : 'Apply Penalty'}
diff --git a/apps/website/components/leagues/ScheduleRaceForm.tsx b/apps/website/components/leagues/ScheduleRaceForm.tsx index 6f55cb6ca..1fd4b15fe 100644 --- a/apps/website/components/leagues/ScheduleRaceForm.tsx +++ b/apps/website/components/leagues/ScheduleRaceForm.tsx @@ -1,10 +1,10 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useRouter } from 'next/navigation'; import Button from '../ui/Button'; import Input from '../ui/Input'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useAllLeagues } from '@/hooks/league/useAllLeagues'; import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel'; interface ScheduleRaceFormData { @@ -35,10 +35,7 @@ export default function ScheduleRaceForm({ onCancel }: ScheduleRaceFormProps) { const router = useRouter(); - const { leagueService, raceService } = useServices(); - const [leagues, setLeagues] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); + const { data: leagues = [], isLoading, error } = useAllLeagues(); const [formData, setFormData] = useState({ leagueId: preSelectedLeagueId || '', @@ -51,18 +48,6 @@ export default function ScheduleRaceForm({ const [validationErrors, setValidationErrors] = useState>({}); - useEffect(() => { - const loadLeagues = async () => { - try { - const allLeagues = await leagueService.getAllLeagues(); - setLeagues(allLeagues); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load leagues'); - } - }; - void loadLeagues(); - }, [leagueService]); - const validateForm = (): boolean => { const errors: Record = {}; @@ -107,9 +92,6 @@ export default function ScheduleRaceForm({ return; } - setLoading(true); - setError(null); - try { // Create race using the race service // Note: This assumes the race service has a create method @@ -137,9 +119,8 @@ export default function ScheduleRaceForm({ router.push(`/races/${createdRace.id}`); } } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to create race'); - } finally { - setLoading(false); + // Error handling is now done through the component state + console.error('Failed to create race:', err); } }; @@ -160,7 +141,7 @@ export default function ScheduleRaceForm({
{error && (
- {error} + {error.message}
)} @@ -310,10 +291,10 @@ export default function ScheduleRaceForm({ {onCancel && ( @@ -321,7 +302,7 @@ export default function ScheduleRaceForm({ type="button" variant="secondary" onClick={onCancel} - disabled={loading} + disabled={isLoading} > Cancel diff --git a/apps/website/components/onboarding/OnboardingWizard.tsx b/apps/website/components/onboarding/OnboardingWizard.tsx index 39bc84acd..add29143c 100644 --- a/apps/website/components/onboarding/OnboardingWizard.tsx +++ b/apps/website/components/onboarding/OnboardingWizard.tsx @@ -22,7 +22,10 @@ import Button from '@/components/ui/Button'; import Input from '@/components/ui/Input'; import Heading from '@/components/ui/Heading'; import CountrySelect from '@/components/ui/CountrySelect'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useAuth } from '@/lib/auth/AuthContext'; +import { useCompleteOnboarding } from '@/hooks/onboarding/useCompleteOnboarding'; +import { useGenerateAvatars } from '@/hooks/onboarding/useGenerateAvatars'; +import { useValidateFacePhoto } from '@/hooks/onboarding/useValidateFacePhoto'; // ============================================================================ // TYPES @@ -163,9 +166,8 @@ function StepIndicator({ currentStep }: { currentStep: number }) { export default function OnboardingWizard() { const router = useRouter(); const fileInputRef = useRef(null); - const { onboardingService, sessionService } = useServices(); + const { session } = useAuth(); const [step, setStep] = useState(1); - const [loading, setLoading] = useState(false); const [errors, setErrors] = useState({}); // Form state @@ -270,6 +272,19 @@ export default function OnboardingWizard() { reader.readAsDataURL(file); }; + const validateFacePhotoMutation = useValidateFacePhoto({ + onSuccess: () => { + setAvatarInfo(prev => ({ ...prev, isValidating: false })); + }, + onError: (error) => { + setErrors(prev => ({ + ...prev, + facePhoto: error.message || 'Face validation failed' + })); + setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false })); + }, + }); + const validateFacePhoto = async (photoData: string) => { setAvatarInfo(prev => ({ ...prev, isValidating: true })); setErrors(prev => { @@ -278,7 +293,7 @@ export default function OnboardingWizard() { }); try { - const result = await onboardingService.validateFacePhoto(photoData); + const result = await validateFacePhotoMutation.mutateAsync(photoData); if (!result.isValid) { setErrors(prev => ({ @@ -286,8 +301,6 @@ export default function OnboardingWizard() { facePhoto: result.errorMessage || 'Face validation failed' })); setAvatarInfo(prev => ({ ...prev, facePhoto: null, isValidating: false })); - } else { - setAvatarInfo(prev => ({ ...prev, isValidating: false })); } } catch (error) { // For now, just accept the photo if validation fails @@ -295,31 +308,8 @@ export default function OnboardingWizard() { } }; - const generateAvatars = async () => { - if (!avatarInfo.facePhoto) { - setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); - return; - } - - setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); - setErrors(prev => { - const { avatar, ...rest } = prev; - return rest; - }); - - try { - // Get current user ID from session - const session = await sessionService.getSession(); - if (!session?.user?.userId) { - throw new Error('User not authenticated'); - } - - const result = await onboardingService.generateAvatars( - session.user.userId, - avatarInfo.facePhoto, - avatarInfo.suitColor - ); - + const generateAvatarsMutation = useGenerateAvatars({ + onSuccess: (result) => { if (result.success && result.avatarUrls) { setAvatarInfo(prev => ({ ...prev, @@ -330,15 +320,56 @@ export default function OnboardingWizard() { setErrors(prev => ({ ...prev, avatar: result.errorMessage || 'Failed to generate avatars' })); setAvatarInfo(prev => ({ ...prev, isGenerating: false })); } - } catch (error) { + }, + onError: () => { setErrors(prev => ({ ...prev, avatar: 'Failed to generate avatars. Please try again.' })); setAvatarInfo(prev => ({ ...prev, isGenerating: false })); + }, + }); + + const generateAvatars = async () => { + if (!avatarInfo.facePhoto) { + setErrors({ ...errors, facePhoto: 'Please upload a photo first' }); + return; + } + + if (!session?.user?.userId) { + setErrors({ ...errors, submit: 'User not authenticated' }); + return; + } + + setAvatarInfo(prev => ({ ...prev, isGenerating: true, generatedAvatars: [], selectedAvatarIndex: null })); + setErrors(prev => { + const { avatar, ...rest } = prev; + return rest; + }); + + try { + await generateAvatarsMutation.mutateAsync({ + userId: session.user.userId, + facePhotoData: avatarInfo.facePhoto, + suitColor: avatarInfo.suitColor, + }); + } catch (error) { + // Error handling is done in the mutation's onError callback } }; + const completeOnboardingMutation = useCompleteOnboarding({ + onSuccess: () => { + // TODO: Handle avatar assignment separately if needed + router.push('/dashboard'); + router.refresh(); + }, + onError: (error) => { + setErrors({ + submit: error.message || 'Failed to create profile', + }); + }, + }); + const handleSubmit = async (e: FormEvent) => { e.preventDefault(); - if (loading) return; // Validate step 2 - must have selected an avatar if (!validateStep(2)) { @@ -350,35 +381,26 @@ export default function OnboardingWizard() { return; } - setLoading(true); setErrors({}); try { - // Note: The current API doesn't support avatarUrl in onboarding - // This would need to be handled separately or the API would need to be updated - const result = await onboardingService.completeOnboarding({ + await completeOnboardingMutation.mutateAsync({ firstName: personalInfo.firstName.trim(), lastName: personalInfo.lastName.trim(), displayName: personalInfo.displayName.trim(), country: personalInfo.country, timezone: personalInfo.timezone || undefined, }); - - if (result.success) { - // TODO: Handle avatar assignment separately if needed - router.push('/dashboard'); - router.refresh(); - } else { - throw new Error(result.errorMessage || 'Failed to create profile'); - } } catch (error) { - setErrors({ - submit: error instanceof Error ? error.message : 'Failed to create profile', - }); - setLoading(false); + // Error handling is done in the mutation's onError callback } }; + // Loading state comes from the mutations + const loading = completeOnboardingMutation.isPending || + generateAvatarsMutation.isPending || + validateFacePhotoMutation.isPending; + const getCountryFlag = (countryCode: string): string => { const code = countryCode.toUpperCase(); if (code.length === 2) { diff --git a/apps/website/components/profile/UserPill.test.tsx b/apps/website/components/profile/UserPill.test.tsx index f2d53aa4d..eadd2f480 100644 --- a/apps/website/components/profile/UserPill.test.tsx +++ b/apps/website/components/profile/UserPill.test.tsx @@ -1,9 +1,9 @@ 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'; -import type { DriverDTO } from '@/lib/types/generated/DriverDTO'; // Mock useAuth to control session state vi.mock('@/lib/auth/AuthContext', () => { @@ -19,21 +19,21 @@ vi.mock('@/hooks/useEffectiveDriverId', () => { }; }); -// Mock services hook to inject stub driverService +// Mock the new DI hooks const mockFindById = vi.fn(); +let mockDriverData: any = null; -vi.mock('@/lib/services/ServiceProvider', () => { - return { - useServices: () => ({ - driverService: { - findById: mockFindById, - }, - mediaService: { - getDriverAvatar: vi.fn(), - }, - }), - }; -}); +vi.mock('@/hooks/driver/useFindDriverById', () => ({ + useFindDriverById: (driverId: string) => { + return { + data: mockDriverData, + isLoading: false, + isError: false, + isSuccess: !!mockDriverData, + refetch: vi.fn(), + }; + }, +})); interface MockSessionUser { id: string; @@ -64,6 +64,7 @@ describe('UserPill', () => { beforeEach(() => { mockedAuthValue = { session: null }; mockedDriverId = null; + mockDriverData = null; mockFindById.mockReset(); }); @@ -93,18 +94,20 @@ describe('UserPill', () => { }); it('loads driver via driverService and uses driver avatarUrl', async () => { - const driver: DriverDTO = { + 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; - mockFindById.mockResolvedValue(driver); + // Set the mock data that the hook will return + mockDriverData = driver; render(); @@ -112,6 +115,6 @@ describe('UserPill', () => { expect(screen.getByText('Test Driver')).toBeInTheDocument(); }); - expect(mockFindById).toHaveBeenCalledWith('driver-1'); + expect(mockFindById).not.toHaveBeenCalled(); // Hook is mocked, not called directly }); }); diff --git a/apps/website/components/profile/UserPill.tsx b/apps/website/components/profile/UserPill.tsx index 055169ec1..b6546dd90 100644 --- a/apps/website/components/profile/UserPill.tsx +++ b/apps/website/components/profile/UserPill.tsx @@ -11,13 +11,14 @@ import { CapabilityGate } from '@/components/shared/CapabilityGate'; import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId'; import type { DriverViewModel } from '@/lib/view-models/DriverViewModel'; import { DriverViewModel as DriverViewModelClass } from '@/lib/view-models/DriverViewModel'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useFindDriverById } from '@/hooks/driver/useFindDriverById'; // Hook to detect demo user mode based on session function useDemoUserMode(): { isDemo: boolean; demoRole: string | null } { const { session } = useAuth(); const [demoMode, setDemoMode] = useState({ isDemo: false, demoRole: null as string | null }); + // Check if this is a demo user useEffect(() => { if (!session?.user) { setDemoMode({ isDemo: false, demoRole: null }); @@ -81,12 +82,12 @@ function useHasAdminAccess(): boolean { } // Sponsor Pill Component - matches the style of DriverSummaryPill -function SponsorSummaryPill({ - onClick, +function SponsorSummaryPill({ + onClick, companyName = 'Acme Racing Co.', activeSponsors = 7, impressions = 127, -}: { +}: { onClick: () => void; companyName?: string; activeSponsors?: number; @@ -136,38 +137,22 @@ function SponsorSummaryPill({ export default function UserPill() { const { session } = useAuth(); - const { driverService, mediaService } = useServices(); - const [driver, setDriver] = useState(null); const [isMenuOpen, setIsMenuOpen] = useState(false); const { isDemo, demoRole } = useDemoUserMode(); const shouldReduceMotion = useReducedMotion(); const primaryDriverId = useEffectiveDriverId(); - // Load driver data only for non-demo users - useEffect(() => { - let cancelled = false; + // Use React-Query hook for driver data (only for non-demo users) + const { data: driverDto } = useFindDriverById(primaryDriverId || '', { + enabled: !!primaryDriverId && !isDemo, + }); - async function loadDriver() { - if (!primaryDriverId || isDemo) { - if (!cancelled) { - setDriver(null); - } - return; - } - - const dto = await driverService.findById(primaryDriverId); - if (!cancelled) { - setDriver(dto ? new DriverViewModelClass({ ...dto, avatarUrl: (dto as any).avatarUrl ?? null }) : null); - } - } - - void loadDriver(); - - return () => { - cancelled = true; - }; - }, [primaryDriverId, driverService, isDemo]); + // Transform DTO to ViewModel + const driver = useMemo(() => { + if (!driverDto) return null; + return new DriverViewModelClass({ ...driverDto, avatarUrl: (driverDto as any).avatarUrl ?? null }); + }, [driverDto]); const data = useMemo(() => { if (!session?.user) { diff --git a/apps/website/components/races/FileProtestModal.tsx b/apps/website/components/races/FileProtestModal.tsx index b4ecb979f..757d0530a 100644 --- a/apps/website/components/races/FileProtestModal.tsx +++ b/apps/website/components/races/FileProtestModal.tsx @@ -5,7 +5,7 @@ import Modal from '@/components/ui/Modal'; import Button from '@/components/ui/Button'; import type { FileProtestCommandDTO } from '@/lib/types/generated/FileProtestCommandDTO'; import type { ProtestIncidentDTO } from '@/lib/types/generated/ProtestIncidentDTO'; -import { useServices } from '@/lib/services/ServiceProvider'; +import { useFileProtest } from '@/hooks/race/useFileProtest'; import { AlertTriangle, Video, @@ -39,8 +39,7 @@ export default function FileProtestModal({ protestingDriverId, participants, }: FileProtestModalProps) { - const { raceService } = useServices(); - const [step, setStep] = useState<'form' | 'submitting' | 'success' | 'error'>('form'); + const fileProtestMutation = useFileProtest(); const [errorMessage, setErrorMessage] = useState(null); // Form state @@ -68,37 +67,41 @@ export default function FileProtestModal({ return; } - setStep('submitting'); setErrorMessage(null); - try { - const incident: ProtestIncidentDTO = { - lap: parseInt(lap, 10), - description: description.trim(), - ...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}), - }; + const incident: ProtestIncidentDTO = { + lap: parseInt(lap, 10), + description: description.trim(), + ...(timeInRace ? { timeInRace: parseInt(timeInRace, 10) } : {}), + }; - const command = { - raceId, - protestingDriverId, - accusedDriverId, - incident, - ...(comment.trim() ? { comment: comment.trim() } : {}), - ...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}), - } satisfies FileProtestCommandDTO; + const command = { + raceId, + protestingDriverId, + accusedDriverId, + incident, + ...(comment.trim() ? { comment: comment.trim() } : {}), + ...(proofVideoUrl.trim() ? { proofVideoUrl: proofVideoUrl.trim() } : {}), + } satisfies FileProtestCommandDTO; - await raceService.fileProtest(command); - - setStep('success'); - } catch (err) { - setStep('error'); - setErrorMessage(err instanceof Error ? err.message : 'Failed to file protest'); - } + fileProtestMutation.mutate(command, { + onSuccess: () => { + // Reset form state on success + setAccusedDriverId(''); + setLap(''); + setTimeInRace(''); + setDescription(''); + setComment(''); + setProofVideoUrl(''); + }, + onError: (error) => { + setErrorMessage(error.message || 'Failed to file protest'); + }, + }); }; const handleClose = () => { // Reset form state - setStep('form'); setErrorMessage(null); setAccusedDriverId(''); setLap(''); @@ -106,10 +109,12 @@ export default function FileProtestModal({ setDescription(''); setComment(''); setProofVideoUrl(''); + fileProtestMutation.reset(); onClose(); }; - if (step === 'success') { + // Show success state when mutation is successful + if (fileProtestMutation.isSuccess) { return (

Your protest has been submitted

- The stewards will review your protest and make a decision. + The stewards will review your protest and make a decision. You'll be notified of the outcome.