diff --git a/apps/website/app/dashboard/page.tsx b/apps/website/app/dashboard/page.tsx index 7e192b70c..f02093a32 100644 --- a/apps/website/app/dashboard/page.tsx +++ b/apps/website/app/dashboard/page.tsx @@ -27,41 +27,53 @@ import { UpcomingRaceItem } from '@/components/dashboard/UpcomingRaceItem'; import { FriendItem } from '@/components/dashboard/FriendItem'; import { FeedItemRow } from '@/components/dashboard/FeedItemRow'; -import { useDashboardOverview } from '@/hooks/useDashboardService'; 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'; + export default function DashboardPage() { - const { data: dashboardData, isLoading, error } = useDashboardOverview(); + const { dashboardService } = useServices(); + + const { data: dashboardData, isLoading, error, retry } = useDataFetching({ + queryKey: ['dashboardOverview'], + queryFn: () => dashboardService.getDashboardOverview(), + }); - if (isLoading) { - return ( -
-
Loading dashboard...
-
- ); - } + 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; - if (error || !dashboardData) { - return ( -
-
Failed to load dashboard
-
- ); - } + const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver; - 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 ( -
+ return ( +
{/* Hero Section */}
{/* Background Pattern */} @@ -327,4 +339,7 @@ export default function DashboardPage() {
); + }} + + ); } diff --git a/apps/website/app/drivers/DriversInteractive.tsx b/apps/website/app/drivers/DriversInteractive.tsx index 5d85f622e..0c7e39541 100644 --- a/apps/website/app/drivers/DriversInteractive.tsx +++ b/apps/website/app/drivers/DriversInteractive.tsx @@ -1,14 +1,23 @@ 'use client'; -import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { DriversTemplate } from '@/templates/DriversTemplate'; -import { useDriverLeaderboard } from '@/hooks/useDriverService'; import { 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 { Users } from 'lucide-react'; + export function DriversInteractive() { const router = useRouter(); - const { data: viewModel, isLoading: loading } = useDriverLeaderboard(); + const { driverService } = useServices(); + + const { data: viewModel, isLoading: loading, error, retry } = useDataFetching({ + queryKey: ['driverLeaderboard'], + queryFn: () => driverService.getDriverLeaderboard(), + }); const drivers = viewModel?.drivers || []; const totalRaces = viewModel?.totalRaces || 0; @@ -16,17 +25,35 @@ export function DriversInteractive() { const activeCount = viewModel?.activeCount || 0; // Transform data for template - const driverViewModels = drivers.map((driver, index) => + const driverViewModels = drivers.map((driver, index) => new DriverLeaderboardItemViewModel(driver, index + 1) ); return ( - + error={error} + retry={retry} + config={{ + loading: { variant: 'skeleton', message: 'Loading driver leaderboard...' }, + error: { variant: 'inline' }, + empty: { + icon: Users, + title: 'No drivers found', + description: 'There are no drivers in the system yet', + } + }} + > + {(leaderboardData) => ( + + )} + ); -} \ No newline at end of file + } \ No newline at end of file diff --git a/apps/website/app/drivers/[id]/DriverProfileInteractive.tsx b/apps/website/app/drivers/[id]/DriverProfileInteractive.tsx index 4b33d6778..95f3aa15a 100644 --- a/apps/website/app/drivers/[id]/DriverProfileInteractive.tsx +++ b/apps/website/app/drivers/[id]/DriverProfileInteractive.tsx @@ -1,12 +1,16 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { useParams, useRouter } from 'next/navigation'; import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate'; import { useServices } from '@/lib/services/ServiceProvider'; -import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel'; import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard'; +// Shared state components +import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; +import { StateContainer } from '@/components/shared/state/StateContainer'; +import { Car } from 'lucide-react'; + interface Team { id: string; name: string; @@ -24,34 +28,23 @@ export function DriverProfileInteractive() { const driverId = params.id as string; const { driverService, teamService } = useServices(); - const [driverProfile, setDriverProfile] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview'); - const [allTeamMemberships, setAllTeamMemberships] = useState([]); const [friendRequestSent, setFriendRequestSent] = useState(false); const isSponsorMode = useSponsorMode(); - useEffect(() => { - loadDriver(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [driverId]); + // Fetch driver profile + const { data: driverProfile, isLoading, error, retry } = useDataFetching({ + queryKey: ['driverProfile', driverId], + queryFn: () => driverService.getDriverProfile(driverId), + }); - const loadDriver = async () => { - try { - // Get driver profile - const profileViewModel = await driverService.getDriverProfile(driverId); - - if (!profileViewModel.currentDriver) { - setError('Driver not found'); - setLoading(false); - return; - } - - setDriverProfile(profileViewModel); - - // Load team memberships - get all teams and check memberships + // Fetch team memberships + const { data: allTeamMemberships } = useDataFetching({ + queryKey: ['driverTeamMemberships', driverId], + queryFn: async () => { + if (!driverProfile?.currentDriver) return []; + const allTeams = await teamService.getAllTeams(); const memberships: TeamMembershipInfo[] = []; @@ -69,13 +62,10 @@ export function DriverProfileInteractive() { }); } } - setAllTeamMemberships(memberships); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load driver'); - } finally { - setLoading(false); - } - }; + return memberships; + }, + enabled: !!driverProfile?.currentDriver, + }); const handleAddFriend = () => { setFriendRequestSent(true); @@ -110,23 +100,38 @@ export function DriverProfileInteractive() { /> ) : null; - if (!driverProfile) { - return null; - } - return ( - + retry={retry} + config={{ + loading: { variant: 'skeleton', message: 'Loading driver profile...' }, + error: { variant: 'full-screen' }, + empty: { + icon: Car, + title: 'Driver not found', + description: 'The driver profile may not exist or you may not have access', + action: { label: 'Back to Drivers', onClick: handleBackClick } + } + }} + > + {(profileData) => ( + + )} + ); -} \ No newline at end of file + } \ No newline at end of file diff --git a/apps/website/app/leaderboards/LeaderboardsStatic.tsx b/apps/website/app/leaderboards/LeaderboardsStatic.tsx index 369dfa5ee..96727638e 100644 --- a/apps/website/app/leaderboards/LeaderboardsStatic.tsx +++ b/apps/website/app/leaderboards/LeaderboardsStatic.tsx @@ -1,33 +1,88 @@ -import { ServiceFactory } from '@/lib/services/ServiceFactory'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; -import LeaderboardsInteractive from './LeaderboardsInteractive'; +'use client'; + +import { useRouter } from 'next/navigation'; +import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel'; -// ============================================================================ -// SERVER COMPONENT - Fetches data and passes to Interactive wrapper -// ============================================================================ +// Shared state components +import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; +import { StateContainer } from '@/components/shared/state/StateContainer'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { Trophy } from 'lucide-react'; -export default async function LeaderboardsStatic() { - // Create services for server-side data fetching - const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl()); - const driverService = serviceFactory.createDriverService(); - const teamService = serviceFactory.createTeamService(); +export default function LeaderboardsStatic() { + const router = useRouter(); + const { driverService, teamService } = useServices(); - // Fetch data server-side - let drivers: DriverLeaderboardItemViewModel[] = []; - let teams: TeamSummaryViewModel[] = []; + const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDataFetching({ + queryKey: ['driverLeaderboard'], + queryFn: () => driverService.getDriverLeaderboard(), + }); - try { - const driversViewModel = await driverService.getDriverLeaderboard(); - drivers = driversViewModel.drivers; - teams = await teamService.getAllTeams(); - } catch (error) { - console.error('Failed to load leaderboard data:', error); - drivers = []; - teams = []; - } + const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useDataFetching({ + queryKey: ['allTeams'], + queryFn: () => teamService.getAllTeams(), + }); - // Pass data to Interactive wrapper which handles client-side interactions - return ; + const handleDriverClick = (driverId: string) => { + router.push(`/drivers/${driverId}`); + }; + + const handleTeamClick = (teamId: string) => { + router.push(`/teams/${teamId}`); + }; + + const handleNavigateToDrivers = () => { + router.push('/leaderboards/drivers'); + }; + + const handleNavigateToTeams = () => { + router.push('/teams/leaderboard'); + }; + + // Combine loading states + const isLoading = driversLoading || teamsLoading; + // Combine errors (prioritize drivers error) + const error = driversError || teamsError; + // Combine retry functions + const retry = async () => { + if (driversError) await driversRetry(); + if (teamsError) await teamsRetry(); + }; + + // Prepare data for template + const drivers = driverData?.drivers || []; + const teamsData = teams || []; + const hasData = drivers.length > 0 || teamsData.length > 0; + + return ( + !data || (data.drivers.length === 0 && data.teams.length === 0)} + > + {(data) => ( + + )} + + ); } \ No newline at end of file diff --git a/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx b/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx index 50d88f692..d1afdb233 100644 --- a/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx +++ b/apps/website/app/leaderboards/drivers/DriverRankingsStatic.tsx @@ -1,28 +1,75 @@ -import { ServiceFactory } from '@/lib/services/ServiceFactory'; -import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl'; -import DriverRankingsInteractive from './DriverRankingsInteractive'; +'use client'; + +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate'; import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel'; -// ============================================================================ -// SERVER COMPONENT - Fetches data and passes to Interactive wrapper -// ============================================================================ +// Shared state components +import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; +import { StateContainer } from '@/components/shared/state/StateContainer'; +import { useServices } from '@/lib/services/ServiceProvider'; +import { Users } from 'lucide-react'; -export default async function DriverRankingsStatic() { - // Create services for server-side data fetching - const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl()); - const driverService = serviceFactory.createDriverService(); +type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner'; +type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate'; - // Fetch data server-side - let drivers: DriverLeaderboardItemViewModel[] = []; +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); - try { - const driversViewModel = await driverService.getDriverLeaderboard(); - drivers = driversViewModel.drivers; - } catch (error) { - console.error('Failed to load driver rankings:', error); - drivers = []; - } + const { data: driverData, isLoading, error, retry } = useDataFetching({ + queryKey: ['driverLeaderboard'], + queryFn: () => driverService.getDriverLeaderboard(), + }); - // Pass data to Interactive wrapper which handles client-side interactions - return ; + const handleDriverClick = (driverId: string) => { + if (driverId.startsWith('demo-')) return; + router.push(`/drivers/${driverId}`); + }; + + const handleBackToLeaderboards = () => { + router.push('/leaderboards'); + }; + + const drivers = driverData?.drivers || []; + + return ( + + {(driversData) => ( + setShowFilters(!showFilters)} + onDriverClick={handleDriverClick} + onBackToLeaderboards={handleBackToLeaderboards} + /> + )} + + ); } \ No newline at end of file diff --git a/apps/website/app/leagues/LeaguesInteractive.tsx b/apps/website/app/leagues/LeaguesInteractive.tsx index ef0b5afad..e400538ea 100644 --- a/apps/website/app/leagues/LeaguesInteractive.tsx +++ b/apps/website/app/leagues/LeaguesInteractive.tsx @@ -1,32 +1,24 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +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 { Trophy } from 'lucide-react'; + export default function LeaguesInteractive() { const router = useRouter(); - const [realLeagues, setRealLeagues] = useState([]); - const [loading, setLoading] = useState(true); - const { leagueService } = useServices(); - const loadLeagues = useCallback(async () => { - try { - const leagues = await leagueService.getAllLeagues(); - setRealLeagues(leagues); - } catch (error) { - console.error('Failed to load leagues:', error); - } finally { - setLoading(false); - } - }, [leagueService]); - - useEffect(() => { - void loadLeagues(); - }, [loadLeagues]); + const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({ + queryKey: ['allLeagues'], + queryFn: () => leagueService.getAllLeagues(), + }); const handleLeagueClick = (leagueId: string) => { router.push(`/leagues/${leagueId}`); @@ -37,11 +29,30 @@ export default function LeaguesInteractive() { }; return ( - + + {(leaguesData) => ( + + )} + ); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx b/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx index 18ce4fd33..643e0baaf 100644 --- a/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx +++ b/apps/website/app/leagues/[id]/LeagueDetailInteractive.tsx @@ -1,14 +1,18 @@ 'use client'; -import { useState, useEffect, useCallback } from 'react'; +import { useState, useCallback } 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 { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel'; import EndRaceModal from '@/components/leagues/EndRaceModal'; +// Shared state components +import { useDataFetching } from '@/components/shared/hooks/useDataFetching'; +import { StateContainer } from '@/components/shared/state/StateContainer'; +import { Trophy } from 'lucide-react'; + export default function LeagueDetailInteractive() { const router = useRouter(); const params = useParams(); @@ -16,39 +20,18 @@ export default function LeagueDetailInteractive() { const isSponsor = useSponsorMode(); const { leagueService, leagueMembershipService, raceService } = useServices(); - const [viewModel, setViewModel] = useState(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); const [endRaceModalRaceId, setEndRaceModalRaceId] = useState(null); const currentDriverId = useEffectiveDriverId(); const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - const loadLeagueData = async () => { - try { - const viewModelData = await leagueService.getLeagueDetailPageData(leagueId); - - if (!viewModelData) { - setError('League not found'); - setLoading(false); - return; - } - - setViewModel(viewModelData); - } catch (err) { - setError(err instanceof Error ? err.message : 'Failed to load league'); - } finally { - setLoading(false); - } - }; - - useEffect(() => { - loadLeagueData(); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [leagueId]); + const { data: viewModel, isLoading, error, retry } = useDataFetching({ + queryKey: ['leagueDetailPage', leagueId], + queryFn: () => leagueService.getLeagueDetailPageData(leagueId), + }); const handleMembershipChange = () => { - loadLeagueData(); + retry(); }; const handleEndRaceModalOpen = (raceId: string) => { @@ -68,7 +51,7 @@ export default function LeagueDetailInteractive() { try { await raceService.completeRace(endRaceModalRaceId); - await loadLeagueData(); + await retry(); setEndRaceModalRaceId(null); } catch (err) { alert(err instanceof Error ? err.message : 'Failed to complete race'); @@ -79,46 +62,51 @@ export default function LeagueDetailInteractive() { setEndRaceModalRaceId(null); }; - if (loading) { - return ( -
Loading league...
- ); - } - - if (error || !viewModel) { - return ( -
- {error || 'League not found'} -
- ); - } - return ( - <> - - {/* End Race Modal */} - {endRaceModalRaceId && viewModel && (() => { - const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId); - return race ? ( - - ) : null; - })()} - - + + {(leagueData) => ( + <> + + {/* End Race Modal */} + {endRaceModalRaceId && leagueData && (() => { + const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId); + return race ? ( + + ) : null; + })()} + + + )} + ); -} \ No newline at end of file + } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/settings/page.tsx b/apps/website/app/leagues/[id]/settings/page.tsx index 9460de075..5457fff5d 100644 --- a/apps/website/app/leagues/[id]/settings/page.tsx +++ b/apps/website/app/leagues/[id]/settings/page.tsx @@ -9,47 +9,34 @@ 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'; -import { useEffect, 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 LeagueSettingsPage() { const params = useParams(); const leagueId = params.id as string; const currentDriverId = useEffectiveDriverId(); const { leagueMembershipService, leagueSettingsService } = useServices(); - - const [settings, setSettings] = useState(null); - const [loading, setLoading] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); const router = useRouter(); - useEffect(() => { - async function checkAdmin() { + // Check admin status + const { data: isAdmin, isLoading: adminLoading } = useDataFetching({ + queryKey: ['leagueMembership', leagueId, currentDriverId], + queryFn: async () => { const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); - } - checkAdmin(); - }, [leagueId, currentDriverId, leagueMembershipService]); - - useEffect(() => { - async function loadSettings() { - setLoading(true); - try { - const settingsData = await leagueSettingsService.getLeagueSettings(leagueId); - if (settingsData) { - setSettings(settingsData); - } - } catch (err) { - console.error('Failed to load league settings:', err); - } finally { - setLoading(false); - } - } - - if (isAdmin) { - loadSettings(); - } - }, [leagueId, isAdmin, leagueSettingsService]); + return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; + }, + }); + // Load settings (only if admin) + const { data: settings, isLoading: settingsLoading, error, retry } = useDataFetching({ + queryKey: ['leagueSettings', leagueId], + queryFn: () => leagueSettingsService.getLeagueSettings(leagueId), + enabled: !!isAdmin, + }); const handleTransferOwnership = async (newOwnerId: string) => { try { @@ -60,6 +47,12 @@ export default function LeagueSettingsPage() { } }; + // Show loading for admin check + if (adminLoading) { + return ; + } + + // Show access denied if not admin if (!isAdmin) { return ( @@ -76,49 +69,47 @@ export default function LeagueSettingsPage() { ); } - if (loading) { - return ( - -
Loading configuration…
-
- ); - } - - if (!settings) { - return ( - -
- Unable to load league configuration for this demo league. -
-
- ); - } - return ( -
- {/* Header */} -
-
- + + {(settingsData) => ( +
+ {/* Header */} +
+
+ +
+
+

League Settings

+

Manage your league configuration

+
+
+ + {/* READONLY INFORMATION SECTION - Compact */} +
+ + + +
-
-

League Settings

-

Manage your league configuration

-
-
- - - - {/* READONLY INFORMATION SECTION - Compact */} -
- - - -
-
+ )} + ); } \ No newline at end of file diff --git a/apps/website/app/leagues/[id]/stewarding/page.tsx b/apps/website/app/leagues/[id]/stewarding/page.tsx index f1f278f5f..dd609a6e4 100644 --- a/apps/website/app/leagues/[id]/stewarding/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/page.tsx @@ -22,7 +22,12 @@ import { } from 'lucide-react'; import Link from 'next/link'; import { useParams } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +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(); @@ -30,46 +35,34 @@ export default function LeagueStewardingPage() { const currentDriverId = useEffectiveDriverId(); const { leagueStewardingService, leagueMembershipService } = useServices(); - const [stewardingData, setStewardingData] = useState(null); - const [loading, setLoading] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending'); const [selectedProtest, setSelectedProtest] = useState(null); const [expandedRaces, setExpandedRaces] = useState>(new Set()); const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false); - useEffect(() => { - async function checkAdmin() { + // Check admin status + const { data: isAdmin, isLoading: adminLoading } = useDataFetching({ + queryKey: ['leagueMembership', leagueId, currentDriverId], + queryFn: async () => { const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); - } - checkAdmin(); - }, [leagueId, currentDriverId, leagueMembershipService]); + return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false; + }, + }); - useEffect(() => { - async function loadData() { - setLoading(true); - try { - const data = await leagueStewardingService.getLeagueStewardingData(leagueId); - setStewardingData(data); - - // Auto-expand races with pending protests - const racesWithPending = new Set(); - data.pendingRaces.forEach(race => { - racesWithPending.add(race.race.id); - }); - setExpandedRaces(racesWithPending); - } catch (err) { - console.error('Failed to load data:', err); - } finally { - setLoading(false); - } - } - - if (isAdmin) { - loadData(); - } - }, [leagueId, isAdmin, leagueStewardingService]); + // 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); + }, + }); // Filter races based on active tab const filteredRaces = useMemo(() => { @@ -109,6 +102,9 @@ export default function LeagueStewardingPage() { notes: stewardNotes, }); } + + // Retry to refresh data + await retry(); }; const handleRejectProtest = async (protestId: string, stewardNotes: string) => { @@ -118,9 +114,11 @@ export default function LeagueStewardingPage() { decision: 'dismiss', decisionNotes: stewardNotes, }); + + // Retry to refresh data + await retry(); }; - const toggleRaceExpanded = (raceId: string) => { setExpandedRaces(prev => { const next = new Set(prev); @@ -149,6 +147,12 @@ export default function LeagueStewardingPage() { } }; + // Show loading for admin check + if (adminLoading) { + return ; + } + + // Show access denied if not admin if (!isAdmin) { return ( @@ -166,248 +170,260 @@ export default function LeagueStewardingPage() { } return ( -
- -
-
-

Stewarding

-

- Quick overview of protests and penalties across all races -

-
-
- - {/* Stats summary */} - {!loading && stewardingData && ( - - )} - - {/* Tab navigation */} -
-
- - -
-
- - {/* Content */} - {loading ? ( -
-
Loading stewarding data...
-
- ) : filteredRaces.length === 0 ? ( -
-
- + + {(data) => ( +
+ +
+
+

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 = stewardingData!.driverMap[protest.protestingDriverId]; - const accused = stewardingData!.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 - - )} -
-
- Lap {protest.incident.lap} - - Filed {new Date(protest.filedAt).toLocaleDateString()} - {protest.proofVideoUrl && ( - <> - - - - - )} -
-

- {protest.incident.description} -

- {protest.decisionNotes && ( -
-

- Steward: {protest.decisionNotes} -

-
- )} -
- {(protest.status === 'pending' || protest.status === 'under_review') && ( - - - - )} -
-
- ); - })} + {/* Stats summary */} + - {activeTab === 'history' && penalties.map((penalty) => { - const driver = stewardingData!.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`} - -
-
-
- ); - })} - - )} -
+ {/* 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 */} + - {activeTab === 'history' && ( - setShowQuickPenaltyModal(true)} /> - )} + {/* 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 + + )} +
+
+ Lap {protest.incident.lap} + + Filed {new Date(protest.filedAt).toLocaleDateString()} + {protest.proofVideoUrl && ( + <> + + + + + )} +
+

+ {protest.incident.description} +

+ {protest.decisionNotes && ( +
+

+ Steward: {protest.decisionNotes} +

+
+ )} +
+ {(protest.status === 'pending' || protest.status === 'under_review') && ( + + + + )} +
+
+ ); + })} - {selectedProtest && ( - setSelectedProtest(null)} - onAccept={handleAcceptProtest} - onReject={handleRejectProtest} - /> - )} + {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`} + +
+
+
+ ); + })} + + )} +
+ )} +
+ ); + })} +
+ )} + - {showQuickPenaltyModal && stewardingData && ( - setShowQuickPenaltyModal(false)} - adminId={currentDriverId} - races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))} - /> + {activeTab === 'history' && ( + setShowQuickPenaltyModal(true)} /> + )} + + {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 }))} + /> + )} +
)} -
+ ); } \ No newline at end of file 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 17f923c72..9b420189d 100644 --- a/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx +++ b/apps/website/app/leagues/[id]/stewarding/protests/[protestId]/page.tsx @@ -32,7 +32,12 @@ import { } from 'lucide-react'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useEffect, useMemo, useState } from 'react'; +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'; // Timeline event types interface TimelineEvent { @@ -105,10 +110,6 @@ export default function ProtestReviewPage() { const currentDriverId = useEffectiveDriverId(); const { leagueStewardingService, protestService, leagueMembershipService } = useServices(); - const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(true); - const [isAdmin, setIsAdmin] = useState(false); - // Decision state const [showDecisionPanel, setShowDecisionPanel] = useState(false); const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null); @@ -116,6 +117,30 @@ export default function ProtestReviewPage() { const [penaltyValue, setPenaltyValue] = useState(5); const [stewardNotes, setStewardNotes] = useState(''); 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; + }, + }); + + // 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); + } + }, + }); const penaltyTypes = useMemo(() => { const referenceItems = detail?.penaltyTypes ?? []; @@ -136,45 +161,6 @@ export default function ProtestReviewPage() { const selectedPenalty = useMemo(() => { return penaltyTypes.find((p) => p.type === penaltyType); }, [penaltyTypes, penaltyType]); - - // Comment state - const [newComment, setNewComment] = useState(''); - - useEffect(() => { - async function checkAdmin() { - await leagueMembershipService.fetchLeagueMemberships(leagueId); - const membership = leagueMembershipService.getMembership(leagueId, currentDriverId); - setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false); - } - checkAdmin(); - }, [leagueId, currentDriverId, leagueMembershipService]); - - useEffect(() => { - async function loadProtest() { - setLoading(true); - try { - const protestDetail = await leagueStewardingService.getProtestDetailViewModel(leagueId, protestId); - - setDetail(protestDetail); - - if (protestDetail.initialPenaltyType) { - setPenaltyType(protestDetail.initialPenaltyType); - setPenaltyValue(protestDetail.initialPenaltyValue); - } - } catch (err) { - console.error('Failed to load protest:', err); - alert('Failed to load protest details'); - router.push(`/leagues/${leagueId}/stewarding`); - } finally { - setLoading(false); - } - } - - if (isAdmin) { - loadProtest(); - } - }, [protestId, leagueId, isAdmin, router, leagueStewardingService]); - const handleSubmitDecision = async () => { if (!decision || !stewardNotes.trim() || !detail) return; @@ -295,6 +281,12 @@ export default function ProtestReviewPage() { } }; + // Show loading for admin check + if (adminLoading) { + return ; + } + + // Show access denied if not admin if (!isAdmin) { return ( @@ -311,435 +303,440 @@ export default function ProtestReviewPage() { ); } - if (loading || !detail) { - return ( - -
-
Loading protest details...
-
-
- ); - } - - const protest = detail.protest; - const race = detail.race; - const protestingDriver = detail.protestingDriver; - const accusedDriver = detail.accusedDriver; - - const statusConfig = getStatusConfig(protest.status); - const StatusIcon = statusConfig.icon; - const isPending = protest.status === 'pending'; - const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24)); - return ( -
- {/* Compact Header */} -
-
- - - -
-

Protest Review

-
- - {statusConfig.label} -
- {daysSinceFiled > 2 && isPending && ( - - - {daysSinceFiled}d old - - )} -
-
-
+ + {(protestDetail) => { + const protest = protestDetail.protest; + const race = protestDetail.race; + const protestingDriver = protestDetail.protestingDriver; + const accusedDriver = protestDetail.accusedDriver; - {/* Main Layout: Feed + Sidebar */} -
- {/* Left Sidebar - Incident Info */} -
- {/* Drivers Involved */} - -

Parties Involved

- -
- {/* Protesting Driver */} - -
-
- + const statusConfig = getStatusConfig(protest.status); + const StatusIcon = statusConfig.icon; + const isPending = protest.status === 'pending'; + const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24)); + + return ( +
+ {/* Compact Header */} +
+
+ + + +
+

Protest Review

+
+ + {statusConfig.label}
-
-

Protesting

-

{protestingDriver?.name || 'Unknown'}

-
- + {daysSinceFiled > 2 && isPending && ( + + + {daysSinceFiled}d old + + )}
- - - {/* Accused Driver */} - -
-
- -
-
-

Accused

-

{accusedDriver?.name || 'Unknown'}

-
- -
- -
- - - {/* Race Info */} - -

Race Details

- - -
- {race.name} -
- - -
-
- - {race.name} -
-
- - {race.formattedDate} -
- {protest.incident?.lap && ( -
- - Lap {protest.incident.lap} -
- )} -
-
- - {protest.proofVideoUrl && ( - -

Evidence

- - -
- )} - - {/* Quick Stats */} - -

Timeline

-
-
- Filed - {new Date(protest.submittedAt).toLocaleDateString()} -
-
- Age - 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days -
- {protest.reviewedAt && ( -
- Resolved - {new Date(protest.reviewedAt).toLocaleDateString()} -
- )} -
-
-
- - {/* Center - Discussion Feed */} -
- {/* Timeline / Feed */} - -
-

Discussion

-
- {/* Initial Protest Filing */} -
-
-
- -
-
-
- {protestingDriver?.name || 'Unknown'} - filed protest - - {new Date(protest.submittedAt).toLocaleString()} -
- -
-

{protest.description}

- - {protest.comment && ( -
-

Additional details:

-

{protest.comment}

-
- )} -
-
-
-
- - {/* Defense placeholder - will be populated when defense system is implemented */} - {protest.status === 'awaiting_defense' && ( -
-
-
- -
-
-

Defense Requested

-

Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...

-
-
-
- )} - - {/* Decision (if resolved) */} - {(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && ( -
-
-
- -
-
-
- Steward Decision - - {protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'} - - {protest.reviewedAt && ( - <> - - {new Date(protest.reviewedAt).toLocaleString()} - - )} -
- -
-

{protest.decisionNotes}

-
-
-
-
- )} -
- - {/* Add Comment (future feature) */} - {isPending && ( -
-
-
- -
-
-