@@ -390,7 +361,49 @@ export default function ProfilePage() {
}
return (
-
+
+ {(profileData) => {
+ // Extract data from profileData ViewModel
+ // At this point, we know profileData exists and currentDriver should exist
+ // (otherwise we would have shown the create form above)
+ const currentDriver = profileData.currentDriver;
+
+ // If currentDriver is null despite our checks, show empty state
+ if (!currentDriver) {
+ return (
+
+
+
+ No driver profile found
+ Please create a driver profile to continue
+
+
+ );
+ }
+
+ const stats = profileData.stats;
+ const teamMemberships = profileData.teamMemberships;
+ const socialSummary = profileData.socialSummary;
+ const extendedProfile = profileData.extendedProfile;
+ const globalRank = currentDriver.globalRank || null;
+
+ return (
+
{/* Hero Header Section */}
{/* Background Pattern */}
@@ -1045,13 +1058,16 @@ export default function ProfilePage() {
)}
- {activeTab === 'stats' && !stats && (
-
-
- No statistics available yet
- Join a league and complete races to see detailed stats
-
- )}
-
- );
+ {activeTab === 'stats' && !stats && (
+
+
+ No statistics available yet
+ Join a league and complete races to see detailed stats
+
+ )}
+
+ );
+ }}
+
+ );
}
\ No newline at end of file
diff --git a/apps/website/app/races/[id]/RaceDetailInteractive.tsx b/apps/website/app/races/[id]/RaceDetailInteractive.tsx
index eb6ac1ec7..22fa2ca8a 100644
--- a/apps/website/app/races/[id]/RaceDetailInteractive.tsx
+++ b/apps/website/app/races/[id]/RaceDetailInteractive.tsx
@@ -1,28 +1,39 @@
'use client';
-import { useState, useEffect } from 'react';
+import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
-import {
- useRaceDetail,
- useRegisterForRace,
- useWithdrawFromRace,
- useCancelRace,
- useCompleteRace,
- useReopenRace
+import {
+ useRegisterForRace,
+ useWithdrawFromRace,
+ useCancelRace,
+ useCompleteRace,
+ useReopenRace
} from '@/hooks/useRaceService';
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 { Flag } from 'lucide-react';
+
export function RaceDetailInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
+ const { raceService } = useServices();
- // Fetch data
- const { data: viewModel, isLoading, error } = useRaceDetail(raceId, currentDriverId);
+ // Fetch data using new hook
+ const { data: viewModel, isLoading, error, retry } = useDataFetching({
+ queryKey: ['raceDetail', raceId, currentDriverId],
+ queryFn: () => raceService.getRaceDetail(raceId, currentDriverId),
+ });
+
+ // Fetch membership
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
// UI State
@@ -37,7 +48,7 @@ export function RaceDetailInteractive() {
const reopenMutation = useReopenRace();
// Determine if user is owner/admin
- const isOwnerOrAdmin = membership
+ const isOwnerOrAdmin = membership
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
: false;
@@ -184,34 +195,53 @@ export function RaceDetailInteractive() {
} : undefined;
return (
-
+ >
+ {(raceData) => (
+
+ )}
+
);
-}
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx b/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx
index bb0f8f931..95796f288 100644
--- a/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx
+++ b/apps/website/app/races/[id]/results/RaceResultsInteractive.tsx
@@ -3,20 +3,36 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
-import { useRaceResultsDetail, useRaceWithSOF } from '@/hooks/useRaceService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
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 { Trophy } from 'lucide-react';
+
export function RaceResultsInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
+ const { raceResultsService, raceService } = useServices();
- // Fetch data
- const { data: raceData, isLoading, error } = useRaceResultsDetail(raceId, currentDriverId);
- const { data: sofData } = useRaceWithSOF(raceId);
+ // 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 membership
const { data: membership } = useLeagueMembership(raceData?.league?.id || '', currentDriverId);
// UI State
@@ -83,28 +99,47 @@ export function RaceResultsInteractive() {
};
return (
-
+ retry={retry}
+ config={{
+ loading: { variant: 'skeleton', message: 'Loading race results...' },
+ error: { variant: 'full-screen' },
+ empty: {
+ icon: Trophy,
+ title: 'No results available',
+ description: 'Race results will appear here once the race is completed',
+ action: { label: 'Back to Race', onClick: handleBack }
+ }
+ }}
+ >
+ {(raceResultsData) => (
+
+ )}
+
);
-}
\ 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 f83943d0f..eadff0595 100644
--- a/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx
+++ b/apps/website/app/races/[id]/stewarding/RaceStewardingInteractive.tsx
@@ -3,19 +3,30 @@
import { useState } from 'react';
import { useParams, useRouter } from 'next/navigation';
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
-import { useRaceStewardingData } from '@/hooks/useRaceStewardingService';
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
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 { Gavel } from 'lucide-react';
+
export function RaceStewardingInteractive() {
const router = useRouter();
const params = useParams();
const raceId = params.id as string;
const currentDriverId = useEffectiveDriverId();
+ const { raceStewardingService } = useServices();
- // Fetch data
- const { data: stewardingData, isLoading, error } = useRaceStewardingData(raceId, currentDriverId);
+ // Fetch data using new hook
+ const { data: stewardingData, isLoading, error, retry } = useDataFetching({
+ queryKey: ['raceStewardingData', raceId, currentDriverId],
+ queryFn: () => raceStewardingService.getRaceStewardingData(raceId, currentDriverId),
+ });
+
+ // Fetch membership
const { data: membership } = useLeagueMembership(stewardingData?.league?.id || '', currentDriverId);
// UI State
@@ -47,15 +58,34 @@ export function RaceStewardingInteractive() {
} : undefined;
return (
-
+ retry={retry}
+ config={{
+ loading: { variant: 'skeleton', message: 'Loading stewarding data...' },
+ error: { variant: 'full-screen' },
+ empty: {
+ icon: Gavel,
+ title: 'No stewarding data',
+ description: 'No protests or penalties for this race',
+ action: { label: 'Back to Race', onClick: handleBack }
+ }
+ }}
+ >
+ {(stewardingData) => (
+
+ )}
+
);
-}
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/apps/website/app/teams/TeamsInteractive.tsx b/apps/website/app/teams/TeamsInteractive.tsx
index 4458722ca..0e7e786d9 100644
--- a/apps/website/app/teams/TeamsInteractive.tsx
+++ b/apps/website/app/teams/TeamsInteractive.tsx
@@ -14,16 +14,26 @@ import WhyJoinTeamSection from '@/components/teams/WhyJoinTeamSection';
import SkillLevelSection from '@/components/teams/SkillLevelSection';
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
-import { useAllTeams } from '@/hooks/useTeamService';
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';
+
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
export default function TeamsInteractive() {
const router = useRouter();
- const { data: teams = [], isLoading: loading } = useAllTeams();
+ const { teamService } = useServices();
+
+ const { data: teams = [], isLoading: loading, error, retry } = useDataFetching({
+ queryKey: ['allTeams'],
+ queryFn: () => teamService.getAllTeams(),
+ });
+
const [searchQuery, setSearchQuery] = useState('');
const [showCreateForm, setShowCreateForm] = useState(false);
@@ -35,17 +45,20 @@ export default function TeamsInteractive() {
advanced: [],
pro: [],
};
- teams.forEach((team) => {
- const level = team.performanceLevel || 'intermediate';
- if (byLevel[level]) {
- byLevel[level].push(team);
- }
- });
+ if (teams) {
+ teams.forEach((team) => {
+ const level = team.performanceLevel || 'intermediate';
+ if (byLevel[level]) {
+ byLevel[level].push(team);
+ }
+ });
+ }
return byLevel;
}, [teams]);
// Select top teams by rating for the preview section
const topTeams = useMemo(() => {
+ if (!teams) return [];
const sortedByRating = [...teams].sort((a, b) => {
// Rating is not currently part of TeamSummaryViewModel in this build.
// Keep deterministic ordering by name until a rating field is exposed.
@@ -67,7 +80,7 @@ export default function TeamsInteractive() {
};
// Filter by search query
- const filteredTeams = teams.filter((team) => {
+ const filteredTeams = teams ? teams.filter((team) => {
if (!searchQuery) return true;
const query = searchQuery.toLowerCase();
return (
@@ -76,7 +89,7 @@ export default function TeamsInteractive() {
(team.region ?? '').toLowerCase().includes(query) ||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
);
- });
+ }) : [];
// Group teams by skill level
const teamsByLevel = useMemo(() => {
@@ -97,7 +110,7 @@ export default function TeamsInteractive() {
);
}, [groupsBySkillLevel, filteredTeams]);
- const recruitingCount = teams.filter((t) => t.isRecruiting).length;
+ const recruitingCount = teams ? teams.filter((t) => t.isRecruiting).length : 0;
const handleSkillLevelClick = (level: SkillLevel) => {
const element = document.getElementById(`level-${level}`);
@@ -126,98 +139,104 @@ export default function TeamsInteractive() {
);
}
- if (loading) {
- return (
-
- );
- }
-
return (
-
- {/* Hero Section */}
-
setShowCreateForm(true)}
- onBrowseTeams={handleBrowseTeams}
- onSkillLevelClick={handleSkillLevelClick}
- />
+ setShowCreateForm(true) }
+ }
+ }}
+ >
+ {(teamsData) => (
+
+ {/* Hero Section */}
+
setShowCreateForm(true)}
+ onBrowseTeams={handleBrowseTeams}
+ onSkillLevelClick={handleSkillLevelClick}
+ />
- {/* Search Bar */}
-
+ {/* Search Bar */}
+
- {/* Why Join Section */}
- {!searchQuery && }
+ {/* Why Join Section */}
+ {!searchQuery && }
- {/* Team Leaderboard Preview */}
- {!searchQuery && }
+ {/* Team Leaderboard Preview */}
+ {!searchQuery && }
- {/* Featured Recruiting */}
- {!searchQuery && }
+ {/* Featured Recruiting */}
+ {!searchQuery && }
- {/* Teams by Skill Level */}
- {teams.length === 0 ? (
-
-
-
-
+ {/* Teams by Skill Level */}
+ {teamsData.length === 0 ? (
+
+
+
+
+
+
+ No teams yet
+
+
+ Be the first to create a racing team. Gather drivers and compete together in endurance events.
+
+
setShowCreateForm(true)}
+ className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
+ >
+
+ Create Your First Team
+
+
+
+ ) : filteredTeams.length === 0 ? (
+
+
+
+
No teams found matching "{searchQuery}"
+
setSearchQuery('')}>
+ Clear search
+
+
+
+ ) : (
+
+ {SKILL_LEVELS.map((level, index) => (
+
+
+
+ ))}
-
- No teams yet
-
-
- Be the first to create a racing team. Gather drivers and compete together in endurance events.
-
-
setShowCreateForm(true)}
- className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
- >
-
- Create Your First Team
-
-
-
- ) : filteredTeams.length === 0 ? (
-
-
-
-
No teams found matching "{searchQuery}"
-
setSearchQuery('')}>
- Clear search
-
-
-
- ) : (
-
- {SKILL_LEVELS.map((level, index) => (
-
-
-
- ))}
+ )}
)}
-
+
);
}
\ No newline at end of file
diff --git a/apps/website/app/teams/TeamsStatic.tsx b/apps/website/app/teams/TeamsStatic.tsx
index 3938721c7..4c1da2ffa 100644
--- a/apps/website/app/teams/TeamsStatic.tsx
+++ b/apps/website/app/teams/TeamsStatic.tsx
@@ -1,8 +1,9 @@
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
import TeamsTemplate from '@/templates/TeamsTemplate';
-// This is a server component that fetches data server-side
-// It will be used by the page.tsx when server-side rendering is needed
+// This is a static component that receives data as props
+// It can be used in server components or parent components that fetch data
+// For client-side data fetching, use TeamsInteractive instead
interface TeamsStaticProps {
teams: TeamSummaryViewModel[];
diff --git a/apps/website/app/teams/[id]/TeamDetailInteractive.tsx b/apps/website/app/teams/[id]/TeamDetailInteractive.tsx
index f37153e28..aadb274e4 100644
--- a/apps/website/app/teams/[id]/TeamDetailInteractive.tsx
+++ b/apps/website/app/teams/[id]/TeamDetailInteractive.tsx
@@ -1,13 +1,16 @@
'use client';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useCallback } from 'react';
import { useParams, useRouter } from 'next/navigation';
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
import { useServices } from '@/lib/services/ServiceProvider';
-import { TeamDetailsViewModel } from '@/lib/view-models/TeamDetailsViewModel';
-import { TeamMemberViewModel } from '@/lib/view-models/TeamMemberViewModel';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
+// Shared state components
+import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
+import { StateContainer } from '@/components/shared/state/StateContainer';
+import { Users } from 'lucide-react';
+
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
export default function TeamDetailInteractive() {
@@ -17,43 +20,37 @@ export default function TeamDetailInteractive() {
const router = useRouter();
const currentDriverId = useEffectiveDriverId();
- const [team, setTeam] = useState(null);
- const [memberships, setMemberships] = useState([]);
const [activeTab, setActiveTab] = useState('overview');
- const [loading, setLoading] = useState(true);
- const [isAdmin, setIsAdmin] = useState(false);
- const loadTeamData = useCallback(async () => {
- setLoading(true);
- try {
- const teamDetails = await teamService.getTeamDetails(teamId, currentDriverId);
+ // Fetch team details
+ const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({
+ queryKey: ['teamDetails', teamId, currentDriverId],
+ queryFn: () => teamService.getTeamDetails(teamId, currentDriverId),
+ });
- if (!teamDetails) {
- setTeam(null);
- setMemberships([]);
- setIsAdmin(false);
- return;
- }
+ // 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,
+ });
- const teamMembers = await teamService.getTeamMembers(teamId, currentDriverId, teamDetails.ownerId);
+ const isLoading = teamLoading || membersLoading;
+ const error = teamError || membersError;
+ const retry = async () => {
+ await teamRetry();
+ await membersRetry();
+ };
- const adminStatus = teamDetails.isOwner ||
- teamMembers.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
-
- setTeam(teamDetails);
- setMemberships(teamMembers);
- setIsAdmin(adminStatus);
- } finally {
- setLoading(false);
- }
- }, [teamId, currentDriverId, teamService]);
-
- useEffect(() => {
- void loadTeamData();
- }, [loadTeamData]);
+ // Determine admin status
+ const isAdmin = teamDetails?.isOwner ||
+ (memberships || []).some((m: any) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
const handleUpdate = () => {
- loadTeamData();
+ retry();
};
const handleRemoveMember = async (driverId: string) => {
@@ -111,17 +108,36 @@ export default function TeamDetailInteractive() {
};
return (
-
+ router.push('/teams') }
+ }
+ }}
+ >
+ {(teamData) => (
+
+ )}
+
);
-}
\ No newline at end of file
+ }
\ No newline at end of file
diff --git a/apps/website/components/leagues/LeagueSchedule.tsx b/apps/website/components/leagues/LeagueSchedule.tsx
index 4eaf9707d..1afdbc230 100644
--- a/apps/website/components/leagues/LeagueSchedule.tsx
+++ b/apps/website/components/leagues/LeagueSchedule.tsx
@@ -1,12 +1,18 @@
'use client';
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
-import { useLeagueSchedule } from '@/hooks/useLeagueService';
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
import { useRouter } from 'next/navigation';
import { useMemo, useState } from 'react';
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
+// 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 { Calendar } from 'lucide-react';
+
interface LeagueScheduleProps {
leagueId: string;
}
@@ -16,8 +22,13 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
const currentDriverId = useEffectiveDriverId();
+ const { leagueService } = useServices();
+
+ const { data: schedule, isLoading, error, retry } = useDataFetching({
+ queryKey: ['leagueSchedule', leagueId],
+ queryFn: () => leagueService.getLeagueSchedule(leagueId),
+ });
- const { data: schedule, isLoading } = useLeagueSchedule(leagueId);
const registerMutation = useRegisterForRace();
const withdrawMutation = useWithdrawFromRace();
@@ -71,160 +82,191 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
const displayRaces = getDisplayRaces();
- if (isLoading) {
- return (
-
- Loading schedule...
-
- );
- }
-
return (
-
- {/* Filter Controls */}
-
-
- {displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
-
-
- setFilter('upcoming')}
- className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
- filter === 'upcoming'
- ? 'bg-primary-blue text-white'
- : 'bg-iron-gray text-gray-400 hover:text-white'
- }`}
- >
- Upcoming ({upcomingRaces.length})
-
- setFilter('past')}
- className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
- filter === 'past'
- ? 'bg-primary-blue text-white'
- : 'bg-iron-gray text-gray-400 hover:text-white'
- }`}
- >
- Past ({pastRaces.length})
-
- setFilter('all')}
- className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
- filter === 'all'
- ? 'bg-primary-blue text-white'
- : 'bg-iron-gray text-gray-400 hover:text-white'
- }`}
- >
- All ({races.length})
-
-
-
+
+ {(scheduleData) => {
+ const races = scheduleData?.races ?? [];
+ const upcomingRaces = races.filter((race) => race.isUpcoming);
+ const pastRaces = races.filter((race) => race.isPast);
+
+ const getDisplayRaces = () => {
+ switch (filter) {
+ case 'upcoming':
+ return upcomingRaces;
+ case 'past':
+ return [...pastRaces].reverse();
+ case 'all':
+ return [...upcomingRaces, ...[...pastRaces].reverse()];
+ default:
+ return races;
+ }
+ };
- {/* Race List */}
- {displayRaces.length === 0 ? (
-
-
No {filter} races
- {filter === 'upcoming' && (
-
Schedule your first race to get started
- )}
-
- ) : (
-
- {displayRaces.map((race) => {
- const isPast = race.isPast;
- const isUpcoming = race.isUpcoming;
- const isRegistered = Boolean(race.isRegistered);
- const trackLabel = race.track ?? race.name;
- const carLabel = race.car ?? '—';
- const sessionTypeLabel = (race.sessionType ?? 'race').toLowerCase();
- const isProcessing =
- registerMutation.isPending || withdrawMutation.isPending;
+ const displayRaces = getDisplayRaces();
- return (
-
router.push(`/races/${race.id}`)}
- >
-
-
-
-
{trackLabel}
- {isUpcoming && !isRegistered && (
-
- Upcoming
-
- )}
- {isUpcoming && isRegistered && (
-
- ✓ Registered
-
- )}
- {isPast && (
-
- Completed
-
- )}
-
-
{carLabel}
-
-
-
-
-
-
- {race.scheduledAt.toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })}
-
-
- {race.scheduledAt.toLocaleTimeString([], {
- hour: '2-digit',
- minute: '2-digit',
- })}
-
- {isPast && race.status === 'completed' && (
-
View Results →
- )}
-
-
- {/* Registration Actions */}
- {isUpcoming && (
-
e.stopPropagation()}>
- {!isRegistered ? (
- handleRegister(race, e)}
- disabled={isProcessing}
- className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
- >
- {registerMutation.isPending ? 'Registering...' : 'Register'}
-
- ) : (
- handleWithdraw(race, e)}
- disabled={isProcessing}
- className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
- >
- {withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
-
- )}
-
- )}
-
-
+ return (
+
+ {/* Filter Controls */}
+
+
+ {displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
+
+
+ setFilter('upcoming')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'upcoming'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ Upcoming ({upcomingRaces.length})
+
+ setFilter('past')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'past'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ Past ({pastRaces.length})
+
+ setFilter('all')}
+ className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
+ filter === 'all'
+ ? 'bg-primary-blue text-white'
+ : 'bg-iron-gray text-gray-400 hover:text-white'
+ }`}
+ >
+ All ({races.length})
+
- );
- })}
-
- )}
-
+
+
+ {/* Race List */}
+ {displayRaces.length === 0 ? (
+
+
No {filter} races
+ {filter === 'upcoming' && (
+
Schedule your first race to get started
+ )}
+
+ ) : (
+
+ {displayRaces.map((race) => {
+ const isPast = race.isPast;
+ const isUpcoming = race.isUpcoming;
+ const isRegistered = Boolean(race.isRegistered);
+ const trackLabel = race.track ?? race.name;
+ const carLabel = race.car ?? '—';
+ const sessionTypeLabel = (race.sessionType ?? 'race').toLowerCase();
+ const isProcessing =
+ registerMutation.isPending || withdrawMutation.isPending;
+
+ return (
+
router.push(`/races/${race.id}`)}
+ >
+
+
+
+
{trackLabel}
+ {isUpcoming && !isRegistered && (
+
+ Upcoming
+
+ )}
+ {isUpcoming && isRegistered && (
+
+ ✓ Registered
+
+ )}
+ {isPast && (
+
+ Completed
+
+ )}
+
+
{carLabel}
+
+
+
+
+
+
+ {race.scheduledAt.toLocaleDateString('en-US', {
+ month: 'short',
+ day: 'numeric',
+ year: 'numeric',
+ })}
+
+
+ {race.scheduledAt.toLocaleTimeString([], {
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+ {isPast && race.status === 'completed' && (
+
View Results →
+ )}
+
+
+ {/* Registration Actions */}
+ {isUpcoming && (
+
e.stopPropagation()}>
+ {!isRegistered ? (
+ handleRegister(race, e)}
+ disabled={isProcessing}
+ className="px-3 py-1.5 text-sm font-medium bg-primary-blue hover:bg-primary-blue/80 text-white rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
+ >
+ {registerMutation.isPending ? 'Registering...' : 'Register'}
+
+ ) : (
+ handleWithdraw(race, e)}
+ disabled={isProcessing}
+ className="px-3 py-1.5 text-sm font-medium bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap"
+ >
+ {withdrawMutation.isPending ? 'Withdrawing...' : 'Withdraw'}
+
+ )}
+
+ )}
+
+
+
+ );
+ })}
+
+ )}
+
+ );
+ }}
+
);
-}
+ }
diff --git a/apps/website/components/shared/hooks/useDataFetching.ts b/apps/website/components/shared/hooks/useDataFetching.ts
new file mode 100644
index 000000000..6a7761810
--- /dev/null
+++ b/apps/website/components/shared/hooks/useDataFetching.ts
@@ -0,0 +1,374 @@
+'use client';
+
+import { useState, useEffect, useCallback, useRef } from 'react';
+import { ApiError } from '@/lib/api/base/ApiError';
+import { UseDataFetchingOptions, UseDataFetchingResult } from '../types/state.types';
+import { delay, retryWithBackoff } from '@/lib/utils/errorUtils';
+
+/**
+ * useDataFetching Hook
+ *
+ * Unified data fetching hook with built-in state management, error handling,
+ * retry logic, and caching support.
+ *
+ * Features:
+ * - Automatic loading state management
+ * - Error classification and handling
+ * - Built-in retry with exponential backoff
+ * - Cache and stale time support
+ * - Refetch capability
+ * - Success/error callbacks
+ * - Auto-retry on mount for recoverable errors
+ *
+ * Usage Example:
+ * ```typescript
+ * const { data, isLoading, error, retry, refetch } = useDataFetching({
+ * queryKey: ['dashboardOverview'],
+ * queryFn: () => dashboardService.getDashboardOverview(),
+ * retryOnMount: true,
+ * cacheTime: 5 * 60 * 1000,
+ * onSuccess: (data) => console.log('Loaded:', data),
+ * onError: (error) => console.error('Error:', error),
+ * });
+ * ```
+ */
+export function useDataFetching
(
+ options: UseDataFetchingOptions
+): UseDataFetchingResult {
+ const {
+ queryKey,
+ queryFn,
+ enabled = true,
+ retryOnMount = false,
+ cacheTime = 5 * 60 * 1000, // 5 minutes
+ staleTime = 1 * 60 * 1000, // 1 minute
+ maxRetries = 3,
+ retryDelay = 1000,
+ onSuccess,
+ onError,
+ } = options;
+
+ // State management
+ const [data, setData] = useState(null);
+ const [isLoading, setIsLoading] = useState(false);
+ const [isFetching, setIsFetching] = useState(false);
+ const [error, setError] = useState(null);
+ const [lastUpdated, setLastUpdated] = useState(null);
+ const [isStale, setIsStale] = useState(true);
+
+ // Refs for caching and retry logic
+ const cacheRef = useRef<{
+ data: T | null;
+ timestamp: number;
+ isStale: boolean;
+ } | null>(null);
+
+ const retryCountRef = useRef(0);
+ const isMountedRef = useRef(true);
+
+ // Check if cache is valid
+ const isCacheValid = useCallback((): boolean => {
+ if (!cacheRef.current) return false;
+
+ const now = Date.now();
+ const age = now - cacheRef.current.timestamp;
+
+ // Cache is valid if within cacheTime and not stale
+ return age < cacheTime && !cacheRef.current.isStale;
+ }, [cacheTime]);
+
+ // Update cache
+ const updateCache = useCallback((newData: T | null, isStale: boolean = false) => {
+ cacheRef.current = {
+ data: newData,
+ timestamp: Date.now(),
+ isStale,
+ };
+ }, []);
+
+ // Main fetch function
+ const fetch = useCallback(async (isRetry: boolean = false): Promise => {
+ if (!enabled) {
+ return null;
+ }
+
+ // Check cache first
+ if (!isRetry && isCacheValid() && cacheRef.current && cacheRef.current.data !== null) {
+ setData(cacheRef.current.data);
+ setIsLoading(false);
+ setIsFetching(false);
+ setError(null);
+ setLastUpdated(new Date(cacheRef.current.timestamp));
+ setIsStale(false);
+ return cacheRef.current.data;
+ }
+
+ setIsFetching(true);
+ if (!isRetry) {
+ setIsLoading(true);
+ }
+
+ setError(null);
+
+ try {
+ // Execute the fetch with retry logic
+ const result = await retryWithBackoff(
+ async () => {
+ retryCountRef.current++;
+ return await queryFn();
+ },
+ maxRetries,
+ retryDelay
+ );
+
+ if (!isMountedRef.current) {
+ return null;
+ }
+
+ // Success - update state and cache
+ setData(result);
+ setLastUpdated(new Date());
+ setIsStale(false);
+ updateCache(result, false);
+ retryCountRef.current = 0; // Reset retry count on success
+
+ if (onSuccess) {
+ onSuccess(result);
+ }
+
+ return result;
+ } catch (err) {
+ if (!isMountedRef.current) {
+ return null;
+ }
+
+ // Convert to ApiError if needed
+ const apiError = err instanceof ApiError ? err : new ApiError(
+ err instanceof Error ? err.message : 'An unexpected error occurred',
+ 'UNKNOWN_ERROR',
+ {
+ timestamp: new Date().toISOString(),
+ retryCount: retryCountRef.current,
+ wasRetry: isRetry,
+ },
+ err instanceof Error ? err : undefined
+ );
+
+ setError(apiError);
+
+ if (onError) {
+ onError(apiError);
+ }
+
+ // Mark cache as stale on error
+ if (cacheRef.current) {
+ cacheRef.current.isStale = true;
+ setIsStale(true);
+ }
+
+ throw apiError;
+ } finally {
+ setIsLoading(false);
+ setIsFetching(false);
+ }
+ }, [enabled, isCacheValid, queryFn, maxRetries, retryDelay, updateCache, onSuccess, onError]);
+
+ // Retry function
+ const retry = useCallback(async () => {
+ return await fetch(true);
+ }, [fetch]);
+
+ // Refetch function
+ const refetch = useCallback(async () => {
+ // Force bypass cache
+ cacheRef.current = null;
+ return await fetch(false);
+ }, [fetch]);
+
+ // Initial fetch and auto-retry on mount
+ useEffect(() => {
+ isMountedRef.current = true;
+
+ const initialize = async () => {
+ if (!enabled) return;
+
+ // Check if we should auto-retry on mount
+ const shouldRetryOnMount = retryOnMount && error && error.isRetryable();
+
+ if (shouldRetryOnMount) {
+ try {
+ await retry();
+ } catch (err) {
+ // Error already set by retry
+ }
+ } else if (!data && !error) {
+ // Initial fetch
+ try {
+ await fetch(false);
+ } catch (err) {
+ // Error already set by fetch
+ }
+ }
+ };
+
+ initialize();
+
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, [enabled, retryOnMount]); // eslint-disable-line react-hooks/exhaustive-deps
+
+ // Effect to check staleness
+ useEffect(() => {
+ if (!lastUpdated) return;
+
+ const checkStale = () => {
+ if (!lastUpdated) return;
+
+ const now = Date.now();
+ const age = now - lastUpdated.getTime();
+
+ if (age > staleTime) {
+ setIsStale(true);
+ if (cacheRef.current) {
+ cacheRef.current.isStale = true;
+ }
+ }
+ };
+
+ const interval = setInterval(checkStale, 30000); // Check every 30 seconds
+ return () => clearInterval(interval);
+ }, [lastUpdated, staleTime]);
+
+ // Effect to update cache staleness
+ useEffect(() => {
+ if (isStale && cacheRef.current) {
+ cacheRef.current.isStale = true;
+ }
+ }, [isStale]);
+
+ // Clear cache function (useful for manual cache invalidation)
+ const clearCache = useCallback(() => {
+ cacheRef.current = null;
+ setIsStale(true);
+ }, []);
+
+ // Reset function (clears everything)
+ const reset = useCallback(() => {
+ setData(null);
+ setIsLoading(false);
+ setIsFetching(false);
+ setError(null);
+ setLastUpdated(null);
+ setIsStale(true);
+ cacheRef.current = null;
+ retryCountRef.current = 0;
+ }, []);
+
+ return {
+ data,
+ isLoading,
+ isFetching,
+ error,
+ retry,
+ refetch,
+ lastUpdated,
+ isStale,
+ // Additional utility functions (not part of standard interface but useful)
+ _clearCache: clearCache,
+ _reset: reset,
+ } as UseDataFetchingResult;
+}
+
+/**
+ * useDataFetchingWithPagination Hook
+ *
+ * Extension of useDataFetching for paginated data
+ */
+export function useDataFetchingWithPagination(
+ options: UseDataFetchingOptions & {
+ initialPage?: number;
+ pageSize?: number;
+ }
+) {
+ const {
+ initialPage = 1,
+ pageSize = 10,
+ queryFn,
+ ...restOptions
+ } = options;
+
+ const [page, setPage] = useState(initialPage);
+ const [hasMore, setHasMore] = useState(true);
+
+ const paginatedQueryFn = useCallback(async () => {
+ const result = await queryFn();
+
+ // Check if there's more data
+ if (Array.isArray(result)) {
+ setHasMore(result.length === pageSize);
+ }
+
+ return result;
+ }, [queryFn, pageSize]);
+
+ const result = useDataFetching({
+ ...restOptions,
+ queryFn: paginatedQueryFn,
+ });
+
+ const loadMore = useCallback(async () => {
+ if (!hasMore) return;
+
+ const nextPage = page + 1;
+ setPage(nextPage);
+
+ // This would need to be integrated with the actual API
+ // For now, we'll just refetch which may not be ideal
+ await result.refetch();
+ }, [page, hasMore, result]);
+
+ const resetPagination = useCallback(() => {
+ setPage(initialPage);
+ setHasMore(true);
+ if (result._reset) {
+ result._reset();
+ }
+ }, [initialPage, result]);
+
+ return {
+ ...result,
+ page,
+ hasMore,
+ loadMore,
+ resetPagination,
+ };
+}
+
+/**
+ * useDataFetchingWithRefresh Hook
+ *
+ * Extension with automatic refresh capability
+ */
+export function useDataFetchingWithRefresh(
+ options: UseDataFetchingOptions & {
+ refreshInterval?: number; // milliseconds
+ }
+) {
+ const { refreshInterval, ...restOptions } = options;
+ const result = useDataFetching(restOptions);
+
+ useEffect(() => {
+ if (!refreshInterval) return;
+
+ const interval = setInterval(() => {
+ if (!result.isLoading && !result.isFetching) {
+ result.refetch();
+ }
+ }, refreshInterval);
+
+ return () => clearInterval(interval);
+ }, [refreshInterval, result]);
+
+ return result;
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/EmptyState.tsx b/apps/website/components/shared/state/EmptyState.tsx
new file mode 100644
index 000000000..126c7aec8
--- /dev/null
+++ b/apps/website/components/shared/state/EmptyState.tsx
@@ -0,0 +1,326 @@
+'use client';
+
+import React from 'react';
+import { EmptyStateProps } from '../types/state.types';
+import Button from '@/components/ui/Button';
+
+// Illustration components (simple SVG representations)
+const Illustrations = {
+ racing: () => (
+
+
+
+
+
+
+
+ ),
+ league: () => (
+
+
+
+
+
+
+ ),
+ team: () => (
+
+
+
+
+
+
+ ),
+ sponsor: () => (
+
+
+
+
+
+ ),
+ driver: () => (
+
+
+
+
+
+
+
+ ),
+} as const;
+
+/**
+ * EmptyState Component
+ *
+ * Provides consistent empty/placeholder states with 3 variants:
+ * - default: Standard empty state with icon, title, description, and action
+ * - minimal: Simple version without extra styling
+ * - full-page: Full page empty state with centered layout
+ *
+ * Supports both icons and illustrations for visual appeal.
+ */
+export function EmptyState({
+ icon: Icon,
+ title,
+ description,
+ action,
+ variant = 'default',
+ className = '',
+ illustration,
+ ariaLabel = 'Empty state',
+}: EmptyStateProps) {
+ // Render illustration if provided
+ const IllustrationComponent = illustration ? Illustrations[illustration] : null;
+
+ // Common content
+ const Content = () => (
+ <>
+ {/* Visual - Icon or Illustration */}
+
+ {IllustrationComponent ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
+
+ {/* Title */}
+
+ {title}
+
+
+ {/* Description */}
+ {description && (
+
+ {description}
+
+ )}
+
+ {/* Action Button */}
+ {action && (
+
+
+ {action.icon && (
+
+ )}
+ {action.label}
+
+
+ )}
+ >
+ );
+
+ // Render different variants
+ switch (variant) {
+ case 'default':
+ return (
+
+ );
+
+ case 'minimal':
+ return (
+
+
+ {/* Minimal icon */}
+
+
+
+
+ {title}
+
+ {description && (
+
+ {description}
+
+ )}
+ {action && (
+
+ {action.label}
+ {action.icon && }
+
+ )}
+
+
+ );
+
+ case 'full-page':
+ return (
+
+
+
+ {IllustrationComponent ? (
+
+
+
+ ) : (
+
+ )}
+
+
+
+ {title}
+
+
+ {description && (
+
+ {description}
+
+ )}
+
+ {action && (
+
+
+ {action.icon && (
+
+ )}
+ {action.label}
+
+
+ )}
+
+ {/* Additional helper text for full-page variant */}
+
+
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+/**
+ * Convenience component for default empty state
+ */
+export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for minimal empty state
+ */
+export function MinimalEmptyState({ icon, title, description, action, className }: Omit) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for full-page empty state
+ */
+export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
+ return (
+
+ );
+}
+
+/**
+ * Pre-configured empty states for common scenarios
+ */
+
+export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
+ return (
+
+ );
+}
+
+export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
+ return (
+
+ );
+}
+
+export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/ErrorDisplay.tsx b/apps/website/components/shared/state/ErrorDisplay.tsx
new file mode 100644
index 000000000..cd0e035f9
--- /dev/null
+++ b/apps/website/components/shared/state/ErrorDisplay.tsx
@@ -0,0 +1,418 @@
+'use client';
+
+import React, { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
+import { ErrorDisplayProps } from '../types/state.types';
+import Button from '@/components/ui/Button';
+
+/**
+ * ErrorDisplay Component
+ *
+ * Provides standardized error display with 4 variants:
+ * - full-screen: Full page error with navigation
+ * - inline: Compact inline error message
+ * - card: Error card for grid layouts
+ * - toast: Toast notification style
+ *
+ * Features:
+ * - Auto-detects retryable errors
+ * - Shows user-friendly messages
+ * - Provides navigation options
+ * - Displays technical details in development
+ * - Fully accessible with ARIA labels and keyboard navigation
+ */
+export function ErrorDisplay({
+ error,
+ onRetry,
+ variant = 'full-screen',
+ showRetry: showRetryProp,
+ showNavigation = true,
+ actions = [],
+ className = '',
+ hideTechnicalDetails = false,
+ ariaLabel = 'Error notification',
+}: ErrorDisplayProps) {
+ const router = useRouter();
+ const [isRetrying, setIsRetrying] = useState(false);
+
+ // Auto-detect retry capability
+ const isRetryable = showRetryProp ?? error.isRetryable();
+ const isConnectivity = error.isConnectivityIssue();
+ const userMessage = error.getUserMessage();
+ const isDev = process.env.NODE_ENV === 'development' && !hideTechnicalDetails;
+
+ const handleRetry = async () => {
+ if (onRetry) {
+ setIsRetrying(true);
+ try {
+ await onRetry();
+ } finally {
+ setIsRetrying(false);
+ }
+ }
+ };
+
+ const handleGoBack = () => {
+ router.back();
+ };
+
+ const handleGoHome = () => {
+ router.push('/');
+ };
+
+ const handleClose = () => {
+ // For toast variant, this would typically close the notification
+ // Implementation depends on how toast notifications are managed
+ window.history.back();
+ };
+
+ // Icon based on error type
+ const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
+
+ // Common button styles
+ const buttonBase = 'flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50';
+ const primaryButton = `${buttonBase} bg-red-500 hover:bg-red-600 text-white`;
+ const secondaryButton = `${buttonBase} bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline`;
+ const ghostButton = `${buttonBase} hover:bg-iron-gray/50 text-gray-300`;
+
+ // Render different variants
+ switch (variant) {
+ case 'full-screen':
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
+
+
Error {error.context.statusCode || 'N/A'}
+
+
+
+
+ {/* Body */}
+
+
{userMessage}
+
+ {/* Technical Details (Development Only) */}
+ {isDev && (
+
+ Technical Details
+
+
Type: {error.type}
+
Endpoint: {error.context.endpoint || 'N/A'}
+ {error.context.statusCode &&
Status: {error.context.statusCode}
}
+ {error.context.retryCount !== undefined && (
+
Retries: {error.context.retryCount}
+ )}
+ {error.context.wasRetry &&
Was Retry: true
}
+
+
+ )}
+
+ {/* Action Buttons */}
+
+ {isRetryable && onRetry && (
+
+ {isRetrying ? (
+ <>
+
+ Retrying...
+ >
+ ) : (
+ <>
+
+ Try Again
+ >
+ )}
+
+ )}
+
+ {showNavigation && (
+
+
+
+ Go Back
+
+
+
+
+ Home
+
+
+ )}
+
+ {/* Custom Actions */}
+ {actions.length > 0 && (
+
+ {actions.map((action, index) => {
+ const variantClasses = {
+ primary: 'bg-primary-blue hover:bg-blue-600 text-white',
+ secondary: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline',
+ danger: 'bg-red-600 hover:bg-red-700 text-white',
+ ghost: 'hover:bg-iron-gray/50 text-gray-300',
+ }[action.variant || 'secondary'];
+
+ return (
+
+ {action.icon && }
+ {action.label}
+
+ );
+ })}
+
+ )}
+
+
+
+ {/* Footer */}
+
+
+
+ );
+
+ case 'inline':
+ return (
+
+
+
+
+
{userMessage}
+ {isDev && (
+
+ [{error.type}] {error.context.statusCode || 'N/A'}
+
+ )}
+
+ {isRetryable && onRetry && (
+
+ {isRetrying ? 'Retrying...' : 'Retry'}
+
+ )}
+ {actions.map((action, index) => (
+
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+
+ );
+
+ case 'card':
+ return (
+
+
+
+
{userMessage}
+ {isDev && (
+
+ {error.type} | Status: {error.context.statusCode || 'N/A'}
+
+ )}
+
+ {isRetryable && onRetry && (
+
+
+ {isRetrying ? 'Retrying' : 'Retry'}
+
+ )}
+ {actions.map((action, index) => (
+
+ {action.label}
+
+ ))}
+
+
+
+ );
+
+ case 'toast':
+ return (
+
+
+
+
{userMessage}
+ {isDev && (
+
+ [{error.type}] {error.context.statusCode || 'N/A'}
+
+ )}
+
+ {isRetryable && onRetry && (
+
+ {isRetrying ? '...' : 'Retry'}
+
+ )}
+ {actions.slice(0, 2).map((action, index) => (
+
+ {action.label}
+
+ ))}
+
+
+
+
+
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+/**
+ * Convenience component for full-screen error display
+ */
+export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for inline error display
+ */
+export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for card error display
+ */
+export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for toast error display
+ */
+export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/LoadingWrapper.tsx b/apps/website/components/shared/state/LoadingWrapper.tsx
new file mode 100644
index 000000000..67a58a4b4
--- /dev/null
+++ b/apps/website/components/shared/state/LoadingWrapper.tsx
@@ -0,0 +1,199 @@
+'use client';
+
+import React from 'react';
+import { LoadingWrapperProps } from '../types/state.types';
+
+/**
+ * LoadingWrapper Component
+ *
+ * Provides consistent loading states with multiple variants:
+ * - spinner: Traditional loading spinner (default)
+ * - skeleton: Skeleton screens for better UX
+ * - full-screen: Centered in viewport
+ * - inline: Compact inline loading
+ * - card: Loading card placeholders
+ *
+ * All variants are fully accessible with ARIA labels and keyboard support.
+ */
+export function LoadingWrapper({
+ variant = 'spinner',
+ message = 'Loading...',
+ className = '',
+ size = 'md',
+ skeletonCount = 3,
+ cardConfig,
+ ariaLabel = 'Loading content',
+}: LoadingWrapperProps) {
+ // Size mappings for different variants
+ const sizeClasses = {
+ sm: {
+ spinner: 'w-4 h-4 border-2',
+ inline: 'text-xs',
+ card: 'h-24',
+ },
+ md: {
+ spinner: 'w-10 h-10 border-2',
+ inline: 'text-sm',
+ card: 'h-32',
+ },
+ lg: {
+ spinner: 'w-16 h-16 border-4',
+ inline: 'text-base',
+ card: 'h-40',
+ },
+ };
+
+ const spinnerSize = sizeClasses[size].spinner;
+ const inlineSize = sizeClasses[size].inline;
+ const cardHeight = cardConfig?.height || sizeClasses[size].card;
+
+ // Render different variants
+ switch (variant) {
+ case 'spinner':
+ return (
+
+ );
+
+ case 'skeleton':
+ return (
+
+ {Array.from({ length: skeletonCount }).map((_, index) => (
+
+ ))}
+
+ );
+
+ case 'full-screen':
+ return (
+
+
+
+
+
{message}
+
This may take a moment...
+
+
+
+ );
+
+ case 'inline':
+ return (
+
+ );
+
+ case 'card':
+ const cardCount = cardConfig?.count || 3;
+ const cardClassName = cardConfig?.className || '';
+
+ return (
+
+ {Array.from({ length: cardCount }).map((_, index) => (
+
+ ))}
+
+ );
+
+ default:
+ return null;
+ }
+}
+
+/**
+ * Convenience component for full-screen loading
+ */
+export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for inline loading
+ */
+export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for skeleton loading
+ */
+export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick) {
+ return (
+
+ );
+}
+
+/**
+ * Convenience component for card loading
+ */
+export function CardLoading({ cardConfig, className = '' }: Pick) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/StateContainer.tsx b/apps/website/components/shared/state/StateContainer.tsx
new file mode 100644
index 000000000..4b196e6ae
--- /dev/null
+++ b/apps/website/components/shared/state/StateContainer.tsx
@@ -0,0 +1,389 @@
+'use client';
+
+import React, { ReactNode } from 'react';
+import { StateContainerProps, StateContainerConfig } from '../types/state.types';
+import { LoadingWrapper } from './LoadingWrapper';
+import { ErrorDisplay } from './ErrorDisplay';
+import { EmptyState } from './EmptyState';
+
+/**
+ * StateContainer Component
+ *
+ * Combined wrapper that automatically handles all states (loading, error, empty, success)
+ * based on the provided data and state values.
+ *
+ * Features:
+ * - Automatic state detection and rendering
+ * - Customizable configuration for each state
+ * - Custom render functions for advanced use cases
+ * - Consistent behavior across all pages
+ *
+ * Usage Example:
+ * ```typescript
+ *
+ * {(content) => }
+ *
+ * ```
+ */
+export function StateContainer({
+ data,
+ isLoading,
+ error,
+ retry,
+ children,
+ config,
+ className = '',
+ showEmpty = true,
+ isEmpty,
+}: StateContainerProps) {
+ // Determine if data is empty
+ const isDataEmpty = (data: T | null): boolean => {
+ if (data === null || data === undefined) return true;
+ if (isEmpty) return isEmpty(data);
+
+ // Default empty checks
+ if (Array.isArray(data)) return data.length === 0;
+ if (typeof data === 'object' && data !== null) {
+ return Object.keys(data).length === 0;
+ }
+ return false;
+ };
+
+ // Priority order: Loading > Error > Empty > Success
+ if (isLoading) {
+ const loadingConfig = config?.loading || {};
+
+ // Custom render
+ if (config?.customRender?.loading) {
+ return <>{config.customRender.loading()}>;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ if (error) {
+ const errorConfig = config?.error || {};
+
+ // Custom render
+ if (config?.customRender?.error) {
+ return <>{config.customRender.error(error)}>;
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ if (showEmpty && isDataEmpty(data)) {
+ const emptyConfig = config?.empty;
+
+ // Custom render
+ if (config?.customRender?.empty) {
+ return <>{config.customRender.empty()}>;
+ }
+
+ // If no empty config provided, show nothing (or could show default empty state)
+ if (!emptyConfig) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ // Success state - render children with data
+ if (data === null || data === undefined) {
+ // This shouldn't happen if we've handled all cases above, but as a fallback
+ return (
+
+
+
+ );
+ }
+
+ // At this point, data is guaranteed to be non-null
+ return <>{children(data as T)}>;
+}
+
+/**
+ * ListStateContainer - Specialized for list data
+ * Automatically handles empty arrays with appropriate messaging
+ */
+export function ListStateContainer({
+ data,
+ isLoading,
+ error,
+ retry,
+ children,
+ config,
+ className = '',
+ emptyConfig,
+}: StateContainerProps & {
+ emptyConfig?: {
+ icon: any;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+}) {
+ const listConfig: StateContainerConfig = {
+ ...config,
+ empty: emptyConfig || {
+ icon: require('lucide-react').List,
+ title: 'No items found',
+ description: 'This list is currently empty',
+ },
+ };
+
+ return (
+ !arr || arr.length === 0}
+ >
+ {children}
+
+ );
+}
+
+/**
+ * DetailStateContainer - Specialized for detail pages
+ * Includes back/refresh functionality
+ */
+export function DetailStateContainer({
+ data,
+ isLoading,
+ error,
+ retry,
+ children,
+ config,
+ className = '',
+ onBack,
+ onRefresh,
+}: StateContainerProps & {
+ onBack?: () => void;
+ onRefresh?: () => void;
+}) {
+ const detailConfig: StateContainerConfig = {
+ ...config,
+ error: {
+ ...config?.error,
+ actions: [
+ ...(config?.error?.actions || []),
+ ...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
+ ...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
+ ],
+ showNavigation: config?.error?.showNavigation ?? true,
+ },
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+/**
+ * PageStateContainer - Full page state management
+ * Wraps content in proper page structure
+ */
+export function PageStateContainer({
+ data,
+ isLoading,
+ error,
+ retry,
+ children,
+ config,
+ title,
+ description,
+}: StateContainerProps & {
+ title?: string;
+ description?: string;
+}) {
+ const pageConfig: StateContainerConfig = {
+ loading: {
+ variant: 'full-screen',
+ message: title ? `Loading ${title}...` : 'Loading...',
+ ...config?.loading,
+ },
+ error: {
+ variant: 'full-screen',
+ ...config?.error,
+ },
+ empty: config?.empty,
+ };
+
+ if (isLoading) {
+ return
+ {children}
+ ;
+ }
+
+ if (error) {
+ return
+ {children}
+ ;
+ }
+
+ if (!data || (Array.isArray(data) && data.length === 0)) {
+ if (config?.empty) {
+ return (
+
+
+ {title && (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ )}
+
+ {children}
+
+
+
+ );
+ }
+ }
+
+ return (
+
+
+ {title && (
+
+
{title}
+ {description && (
+
{description}
+ )}
+
+ )}
+
+ {children}
+
+
+
+ );
+}
+
+/**
+ * GridStateContainer - Specialized for grid layouts
+ * Handles card-based empty states
+ */
+export function GridStateContainer({
+ data,
+ isLoading,
+ error,
+ retry,
+ children,
+ config,
+ className = '',
+ emptyConfig,
+}: StateContainerProps & {
+ emptyConfig?: {
+ icon: any;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+}) {
+ const gridConfig: StateContainerConfig = {
+ loading: {
+ variant: 'card',
+ ...config?.loading,
+ },
+ ...config,
+ empty: emptyConfig || {
+ icon: require('lucide-react').Grid,
+ title: 'No items to display',
+ description: 'Try adjusting your filters or search',
+ },
+ };
+
+ return (
+ !arr || arr.length === 0}
+ >
+ {children}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/website/components/shared/state/__tests__/state-components.test.tsx b/apps/website/components/shared/state/__tests__/state-components.test.tsx
new file mode 100644
index 000000000..9d0897631
--- /dev/null
+++ b/apps/website/components/shared/state/__tests__/state-components.test.tsx
@@ -0,0 +1,59 @@
+/**
+ * Basic test file to verify state components are properly exported and typed
+ */
+
+import { LoadingWrapper } from '../LoadingWrapper';
+import { ErrorDisplay } from '../ErrorDisplay';
+import { EmptyState } from '../EmptyState';
+import { StateContainer } from '../StateContainer';
+import { useDataFetching } from '../../hooks/useDataFetching';
+import { ApiError } from '@/lib/api/base/ApiError';
+
+// This file just verifies that all components can be imported and are properly typed
+// Full testing would be done in separate test files
+
+describe('State Components - Basic Type Checking', () => {
+ it('should export all components', () => {
+ expect(LoadingWrapper).toBeDefined();
+ expect(ErrorDisplay).toBeDefined();
+ expect(EmptyState).toBeDefined();
+ expect(StateContainer).toBeDefined();
+ expect(useDataFetching).toBeDefined();
+ });
+
+ it('should have proper component signatures', () => {
+ // LoadingWrapper accepts props
+ const loadingProps = {
+ variant: 'spinner' as const,
+ message: 'Loading...',
+ size: 'md' as const,
+ };
+ expect(loadingProps).toBeDefined();
+
+ // ErrorDisplay accepts ApiError
+ const mockError = new ApiError(
+ 'Test error',
+ 'NETWORK_ERROR',
+ { timestamp: new Date().toISOString() }
+ );
+ expect(mockError).toBeDefined();
+ expect(mockError.isRetryable()).toBe(true);
+
+ // EmptyState accepts icon and title
+ const emptyProps = {
+ icon: require('lucide-react').Activity,
+ title: 'No data',
+ };
+ expect(emptyProps).toBeDefined();
+
+ // StateContainer accepts data and state
+ const stateProps = {
+ data: null,
+ isLoading: false,
+ error: null,
+ retry: async () => {},
+ children: (data: any) => {JSON.stringify(data)}
,
+ };
+ expect(stateProps).toBeDefined();
+ });
+});
\ No newline at end of file
diff --git a/apps/website/components/shared/types/state.types.ts b/apps/website/components/shared/types/state.types.ts
new file mode 100644
index 000000000..4f6b4a126
--- /dev/null
+++ b/apps/website/components/shared/types/state.types.ts
@@ -0,0 +1,386 @@
+/**
+ * TypeScript Interfaces for State Management Components
+ *
+ * Provides comprehensive type definitions for loading, error, and empty states
+ * across the GridPilot website application.
+ */
+
+import { ReactNode } from 'react';
+import { LucideIcon } from 'lucide-react';
+import { ApiError } from '@/lib/api/base/ApiError';
+
+// ============================================================================
+// Core State Interfaces
+// ============================================================================
+
+/**
+ * Basic state for any data fetching operation
+ */
+export interface PageState {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+}
+
+/**
+ * Extended state with metadata for advanced use cases
+ */
+export interface PageStateWithMeta extends PageState {
+ isFetching: boolean;
+ refetch: () => Promise;
+ lastUpdated: Date | null;
+ isStale: boolean;
+}
+
+// ============================================================================
+// Hook Interfaces
+// ============================================================================
+
+/**
+ * Options for useDataFetching hook
+ */
+export interface UseDataFetchingOptions {
+ /** Unique key for caching and invalidation */
+ queryKey: string[];
+ /** Function to fetch data */
+ queryFn: () => Promise;
+ /** Enable/disable the query */
+ enabled?: boolean;
+ /** Auto-retry on mount for recoverable errors */
+ retryOnMount?: boolean;
+ /** Cache time in milliseconds */
+ cacheTime?: number;
+ /** Stale time in milliseconds */
+ staleTime?: number;
+ /** Maximum retry attempts */
+ maxRetries?: number;
+ /** Delay between retries in milliseconds */
+ retryDelay?: number;
+ /** Success callback */
+ onSuccess?: (data: T) => void;
+ /** Error callback */
+ onError?: (error: ApiError) => void;
+}
+
+/**
+ * Result from useDataFetching hook
+ */
+export interface UseDataFetchingResult {
+ data: T | null;
+ isLoading: boolean;
+ isFetching: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ refetch: () => Promise;
+ lastUpdated: Date | null;
+ isStale: boolean;
+ // Internal methods (not part of public API but needed for extensions)
+ _clearCache?: () => void;
+ _reset?: () => void;
+}
+
+// ============================================================================
+// LoadingWrapper Component
+// ============================================================================
+
+export type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
+export type LoadingSize = 'sm' | 'md' | 'lg';
+
+export interface LoadingWrapperProps {
+ /** Visual variant of loading state */
+ variant?: LoadingVariant;
+ /** Custom message to display */
+ message?: string;
+ /** Additional CSS classes */
+ className?: string;
+ /** Size of loading indicator */
+ size?: LoadingSize;
+ /** For skeleton variant - number of skeleton items to show */
+ skeletonCount?: number;
+ /** For card variant - card layout configuration */
+ cardConfig?: {
+ height?: number;
+ count?: number;
+ className?: string;
+ };
+ /** ARIA label for accessibility */
+ ariaLabel?: string;
+}
+
+// ============================================================================
+// ErrorDisplay Component
+// ============================================================================
+
+export type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
+
+export interface ErrorAction {
+ /** Button label */
+ label: string;
+ /** Click handler */
+ onClick: () => void;
+ /** Visual variant */
+ variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
+ /** Optional icon */
+ icon?: LucideIcon;
+ /** Disabled state */
+ disabled?: boolean;
+}
+
+export interface ErrorDisplayProps {
+ /** The error to display */
+ error: ApiError;
+ /** Retry callback */
+ onRetry?: () => void;
+ /** Visual variant */
+ variant?: ErrorVariant;
+ /** Show retry button (auto-detected from error.isRetryable()) */
+ showRetry?: boolean;
+ /** Show navigation buttons */
+ showNavigation?: boolean;
+ /** Additional custom actions */
+ actions?: ErrorAction[];
+ /** Additional CSS classes */
+ className?: string;
+ /** Hide technical details in production */
+ hideTechnicalDetails?: boolean;
+ /** ARIA label for accessibility */
+ ariaLabel?: string;
+}
+
+// ============================================================================
+// EmptyState Component
+// ============================================================================
+
+export type EmptyVariant = 'default' | 'minimal' | 'full-page';
+export type EmptyIllustration = 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
+
+export interface EmptyStateProps {
+ /** Icon to display */
+ icon: LucideIcon;
+ /** Title text */
+ title: string;
+ /** Description text */
+ description?: string;
+ /** Primary action */
+ action?: {
+ label: string;
+ onClick: () => void;
+ icon?: LucideIcon;
+ variant?: 'primary' | 'secondary';
+ };
+ /** Visual variant */
+ variant?: EmptyVariant;
+ /** Additional CSS classes */
+ className?: string;
+ /** Illustration instead of icon */
+ illustration?: EmptyIllustration;
+ /** ARIA label for accessibility */
+ ariaLabel?: string;
+}
+
+// ============================================================================
+// StateContainer Component
+// ============================================================================
+
+export interface StateContainerConfig {
+ /** Loading state configuration */
+ loading?: {
+ variant?: LoadingVariant;
+ message?: string;
+ size?: LoadingSize;
+ skeletonCount?: number;
+ };
+ /** Error state configuration */
+ error?: {
+ variant?: ErrorVariant;
+ actions?: ErrorAction[];
+ showRetry?: boolean;
+ showNavigation?: boolean;
+ hideTechnicalDetails?: boolean;
+ };
+ /** Empty state configuration */
+ empty?: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+ /** Custom render functions for advanced use cases */
+ customRender?: {
+ loading?: () => ReactNode;
+ error?: (error: ApiError) => ReactNode;
+ empty?: () => ReactNode;
+ };
+}
+
+export interface StateContainerProps {
+ /** Current data */
+ data: T | null;
+ /** Loading state */
+ isLoading: boolean;
+ /** Error state */
+ error: ApiError | null;
+ /** Retry function */
+ retry: () => Promise;
+ /** Child render function */
+ children: (data: T) => ReactNode;
+ /** Configuration for all states */
+ config?: StateContainerConfig;
+ /** Additional CSS classes */
+ className?: string;
+ /** Whether to show empty state (default: true) */
+ showEmpty?: boolean;
+ /** Custom function to determine if data is empty */
+ isEmpty?: (data: T) => boolean;
+}
+
+// ============================================================================
+// Retry Configuration
+// ============================================================================
+
+export interface RetryConfig {
+ /** Maximum retry attempts */
+ maxAttempts?: number;
+ /** Base delay in milliseconds */
+ baseDelay?: number;
+ /** Backoff multiplier */
+ backoffMultiplier?: number;
+ /** Auto-retry on mount */
+ retryOnMount?: boolean;
+}
+
+// ============================================================================
+// Notification Configuration
+// ============================================================================
+
+export interface NotificationConfig {
+ /** Show toast on success */
+ showToastOnSuccess?: boolean;
+ /** Show toast on error */
+ showToastOnError?: boolean;
+ /** Custom success message */
+ successMessage?: string;
+ /** Custom error message */
+ errorMessage?: string;
+ /** Auto-dismiss delay in milliseconds */
+ autoDismissDelay?: number;
+}
+
+// ============================================================================
+// Analytics Configuration
+// ============================================================================
+
+export interface StateAnalytics {
+ /** Called when state changes */
+ onStateChange?: (from: string, to: string, data?: unknown) => void;
+ /** Called on error */
+ onError?: (error: ApiError, context: string) => void;
+ /** Called on retry */
+ onRetry?: (attempt: number, maxAttempts: number) => void;
+}
+
+// ============================================================================
+// Performance Metrics
+// ============================================================================
+
+export interface PerformanceMetrics {
+ /** Time to first render in milliseconds */
+ timeToFirstRender?: number;
+ /** Time to data load in milliseconds */
+ timeToDataLoad?: number;
+ /** Number of retry attempts */
+ retryCount?: number;
+ /** Whether cache was hit */
+ cacheHit?: boolean;
+}
+
+// ============================================================================
+// Advanced Configuration
+// ============================================================================
+
+export interface AdvancedStateConfig extends StateContainerConfig {
+ retry?: RetryConfig;
+ notifications?: NotificationConfig;
+ analytics?: StateAnalytics;
+ performance?: PerformanceMetrics;
+}
+
+// ============================================================================
+// Page Template Interfaces
+// ============================================================================
+
+/**
+ * Generic page template props
+ */
+export interface PageTemplateProps {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ refetch: () => Promise;
+ title?: string;
+ description?: string;
+ children: (data: T) => ReactNode;
+ config?: StateContainerConfig;
+}
+
+/**
+ * List page template props
+ */
+export interface ListPageTemplateProps extends PageTemplateProps {
+ emptyConfig?: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+ showSkeleton?: boolean;
+ skeletonCount?: number;
+}
+
+/**
+ * Detail page template props
+ */
+export interface DetailPageTemplateProps extends PageTemplateProps {
+ onBack?: () => void;
+ onRefresh?: () => void;
+}
+
+// ============================================================================
+// Default Configuration
+// ============================================================================
+
+export const DEFAULT_CONFIG = {
+ loading: {
+ variant: 'spinner' as LoadingVariant,
+ message: 'Loading...',
+ size: 'md' as LoadingSize,
+ },
+ error: {
+ variant: 'full-screen' as ErrorVariant,
+ showRetry: true,
+ showNavigation: true,
+ },
+ empty: {
+ title: 'No data available',
+ description: 'There is nothing to display here',
+ },
+ retry: {
+ maxAttempts: 3,
+ baseDelay: 1000,
+ backoffMultiplier: 2,
+ retryOnMount: true,
+ },
+ notifications: {
+ showToastOnSuccess: false,
+ showToastOnError: true,
+ autoDismissDelay: 5000,
+ },
+} as const;
\ No newline at end of file
diff --git a/apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md b/apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md
new file mode 100644
index 000000000..4c7076d64
--- /dev/null
+++ b/apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md
@@ -0,0 +1,943 @@
+# Streamlined Error and Load State Handling Design
+
+## Overview
+
+This document outlines a comprehensive design for standardizing error and loading state handling across all pages in the GridPilot website app using shared, user-friendly components.
+
+## Problem Statement
+
+The current implementation has several inconsistencies:
+- Mixed state management approaches (hook-based vs manual useState)
+- Inconsistent naming (loading vs isLoading vs pending)
+- Different error UIs (full-screen, inline, modal, toast)
+- Various loading UIs (spinners, skeletons, text-only, full-screen)
+- No shared useDataFetching() hook
+- Error boundaries not used everywhere
+
+## Solution Architecture
+
+### 1. Unified State Management Pattern
+
+All pages will follow this consistent pattern:
+
+```typescript
+// Standardized state interface
+interface PageState {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+}
+
+// Standardized hook signature
+interface UseDataFetchingOptions {
+ queryKey: string[];
+ queryFn: () => Promise;
+ enabled?: boolean;
+ retryOnMount?: boolean;
+}
+
+// Standardized return type
+interface UseDataFetchingResult {
+ data: T | null;
+ isLoading: boolean;
+ isFetching: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ refetch: () => Promise;
+}
+```
+
+### 2. Component Architecture
+
+```
+apps/website/components/shared/
+├── state/
+│ ├── LoadingWrapper.tsx # Main loading component with variants
+│ ├── ErrorDisplay.tsx # Standardized error display
+│ ├── EmptyState.tsx # Enhanced empty state
+│ └── StateContainer.tsx # Combined wrapper for all states
+├── hooks/
+│ └── useDataFetching.ts # Unified data fetching hook
+└── types/
+ └── state.types.ts # TypeScript interfaces
+```
+
+### 3. Shared Component Design
+
+#### LoadingWrapper Component
+
+**Purpose**: Provides consistent loading states with multiple variants
+
+**Variants**:
+- `spinner` - Traditional loading spinner (default)
+- `skeleton` - Skeleton screens for better UX
+- `full-screen` - Centered in viewport
+- `inline` - Compact inline loading
+- `card` - Loading card placeholders
+
+**Props Interface**:
+```typescript
+interface LoadingWrapperProps {
+ variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
+ message?: string;
+ className?: string;
+ size?: 'sm' | 'md' | 'lg';
+ /**
+ * For skeleton variant - number of skeleton items to show
+ */
+ skeletonCount?: number;
+ /**
+ * For card variant - card layout configuration
+ */
+ cardConfig?: {
+ height?: number;
+ count?: number;
+ className?: string;
+ };
+}
+```
+
+**Usage Examples**:
+```typescript
+// Default spinner
+
+
+// Full-screen with custom message
+
+
+// Skeleton for list
+
+
+// Inline loading
+
+```
+
+#### ErrorDisplay Component
+
+**Purpose**: Standardized error display with consistent behavior
+
+**Props Interface**:
+```typescript
+interface ErrorDisplayProps {
+ error: ApiError;
+ onRetry?: () => void;
+ variant?: 'full-screen' | 'inline' | 'card' | 'toast';
+ /**
+ * Show retry button (auto-detected from error.isRetryable())
+ */
+ showRetry?: boolean;
+ /**
+ * Show navigation buttons
+ */
+ showNavigation?: boolean;
+ /**
+ * Additional actions
+ */
+ actions?: Array<{
+ label: string;
+ onClick: () => void;
+ variant?: 'primary' | 'secondary' | 'danger';
+ }>;
+ className?: string;
+ /**
+ * Hide technical details in development
+ */
+ hideTechnicalDetails?: boolean;
+}
+```
+
+**Features**:
+- Auto-detects retryable errors
+- Shows user-friendly messages
+- Provides navigation options (back, home)
+- Displays technical details in development
+- Supports custom actions
+- Accessible (ARIA labels, keyboard navigation)
+
+**Usage Examples**:
+```typescript
+// Full-screen error
+
+
+// Inline error with custom actions
+ router.push('/support') }
+ ]}
+/>
+
+// Toast-style error
+
+```
+
+#### EmptyState Component
+
+**Purpose**: Consistent empty/placeholder states
+
+**Props Interface**:
+```typescript
+interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ icon?: LucideIcon;
+ };
+ variant?: 'default' | 'minimal' | 'full-page';
+ className?: string;
+ /**
+ * Show illustration instead of icon
+ */
+ illustration?: 'racing' | 'league' | 'team' | 'sponsor';
+}
+```
+
+**Usage Examples**:
+```typescript
+// No races
+ router.push('/leagues')
+ }}
+/>
+
+// No data with illustration
+
+```
+
+### 4. useDataFetching Hook
+
+**Purpose**: Unified data fetching with built-in state management
+
+**Signature**:
+```typescript
+function useDataFetching(
+ options: UseDataFetchingOptions
+): UseDataFetchingResult
+```
+
+**Implementation Features**:
+- Built-in retry logic
+- Error classification
+- Loading state management
+- Refetch capability
+- Integration with error boundaries
+- Automatic retry on mount for recoverable errors
+
+**Usage Example**:
+```typescript
+// Dashboard page
+function DashboardPage() {
+ const {
+ data,
+ isLoading,
+ error,
+ retry,
+ refetch
+ } = useDataFetching({
+ queryKey: ['dashboardOverview'],
+ queryFn: () => dashboardService.getDashboardOverview(),
+ retryOnMount: true,
+ });
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+```
+
+### 5. StateContainer Component
+
+**Purpose**: Combined wrapper that handles all states automatically
+
+**Props Interface**:
+```typescript
+interface StateContainerProps {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ children: (data: T) => React.ReactNode;
+ /**
+ * Configuration for each state
+ */
+ config?: {
+ loading?: {
+ variant?: LoadingWrapperProps['variant'];
+ message?: string;
+ };
+ error?: {
+ variant?: ErrorDisplayProps['variant'];
+ actions?: ErrorDisplayProps['actions'];
+ };
+ empty?: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: EmptyStateProps['action'];
+ };
+ };
+ className?: string;
+}
+```
+
+**Usage Example**:
+```typescript
+// Simplified page implementation
+function LeagueDetailPage() {
+ const { data, isLoading, error, retry } = useDataFetching({
+ queryKey: ['league', leagueId],
+ queryFn: () => leagueService.getLeague(leagueId),
+ });
+
+ return (
+ router.push('/leagues') }
+ }
+ }}
+ >
+ {(leagueData) => }
+
+ );
+}
+```
+
+## 6. Error Boundary Integration
+
+### EnhancedErrorBoundary Updates
+
+The existing `EnhancedErrorBoundary` will be updated to:
+1. Support the new standardized error display
+2. Provide automatic retry functionality
+3. Support custom fallback components
+4. Integrate with the notification system
+
+```typescript
+interface EnhancedErrorBoundaryProps {
+ children: ReactNode;
+ fallback?: ReactNode;
+ onError?: (error: Error, errorInfo: ErrorInfo) => void;
+ onReset?: () => void;
+ enableDevOverlay?: boolean;
+ context?: Record;
+ /**
+ * NEW: Auto-retry configuration
+ */
+ autoRetry?: {
+ enabled: boolean;
+ maxAttempts?: number;
+ delay?: number;
+ };
+ /**
+ * NEW: Error display variant
+ */
+ errorVariant?: ErrorDisplayProps['variant'];
+}
+```
+
+### ApiErrorBoundary Updates
+
+The `ApiErrorBoundary` will be enhanced to:
+1. Handle both API errors and regular errors
+2. Support retry functionality
+3. Provide consistent UI across all API calls
+
+## 7. Migration Strategy
+
+### Phase 1: Foundation (Week 1)
+1. Create new shared components in `components/shared/state/`
+2. Create `useDataFetching` hook
+3. Update TypeScript interfaces
+4. Add comprehensive tests
+
+### Phase 2: Core Pages (Week 2-3)
+Update pages in priority order:
+
+**High Priority**:
+- `app/dashboard/page.tsx`
+- `app/leagues/[id]/LeagueDetailInteractive.tsx`
+- `app/races/[id]/RaceDetailInteractive.tsx`
+
+**Medium Priority**:
+- `app/teams/[id]/TeamDetailInteractive.tsx`
+- `app/leagues/[id]/schedule/page.tsx`
+- `app/races/[id]/results/RaceResultsInteractive.tsx`
+- `app/races/[id]/stewarding/RaceStewardingInteractive.tsx`
+
+**Lower Priority**:
+- Sponsor pages
+- Profile pages
+- Other interactive components
+
+### Phase 3: Rollout (Week 4)
+1. Update remaining pages
+2. Add migration codemods for automation
+3. Update documentation
+4. Team training
+
+### Phase 4: Deprecation (Week 5-6)
+1. Remove old components
+2. Clean up legacy code
+3. Performance optimization
+
+## 8. Migration Examples
+
+### Before (Current State)
+```typescript
+// Dashboard page - inconsistent patterns
+function DashboardPage() {
+ const { data: dashboardData, isLoading, error } = useDashboardOverview();
+
+ if (isLoading) {
+ return (
+
+ Loading dashboard...
+
+ );
+ }
+
+ if (error || !dashboardData) {
+ return (
+
+ Failed to load dashboard
+
+ );
+ }
+
+ // ... render content
+}
+```
+
+### After (Standardized)
+```typescript
+// Dashboard page - unified pattern
+function DashboardPage() {
+ const { data, isLoading, error, retry } = useDataFetching({
+ queryKey: ['dashboardOverview'],
+ queryFn: () => dashboardService.getDashboardOverview(),
+ });
+
+ if (isLoading) {
+ return ;
+ }
+
+ if (error) {
+ return ;
+ }
+
+ if (!data) {
+ return (
+
+ );
+ }
+
+ return ;
+}
+```
+
+### Before (League Detail - Manual State)
+```typescript
+function LeagueDetailInteractive() {
+ const [viewModel, setViewModel] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+
+ 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();
+ }, [leagueId]);
+
+ if (loading) {
+ return Loading league...
;
+ }
+
+ if (error || !viewModel) {
+ return {error || 'League not found'}
;
+ }
+
+ // ... render content
+}
+```
+
+### After (League Detail - Standardized)
+```typescript
+function LeagueDetailInteractive() {
+ const params = useParams();
+ const leagueId = params.id as string;
+
+ const { data: viewModel, isLoading, error, retry } = useDataFetching({
+ queryKey: ['leagueDetailPage', leagueId],
+ queryFn: () => leagueService.getLeagueDetailPageData(leagueId),
+ });
+
+ return (
+ router.push('/leagues') }
+ }
+ }}
+ >
+ {(leagueData) => }
+
+ );
+}
+```
+
+## 9. Testing Strategy
+
+### Component Tests
+```typescript
+// LoadingWrapper tests
+describe('LoadingWrapper', () => {
+ it('renders spinner by default', () => { /* ... */ });
+ it('renders skeleton variant', () => { /* ... */ });
+ it('shows custom message', () => { /* ... */ });
+ it('is accessible', () => { /* ... */ });
+});
+
+// ErrorDisplay tests
+describe('ErrorDisplay', () => {
+ it('shows retry button for retryable errors', () => { /* ... */ });
+ it('hides retry for non-retryable errors', () => { /* ... */ });
+ it('provides navigation options', () => { /* ... */ });
+ it('shows technical details in dev mode', () => { /* ... */ });
+});
+
+// useDataFetching tests
+describe('useDataFetching', () => {
+ it('manages loading state correctly', () => { /* ... */ });
+ it('handles errors properly', () => { /* ... */ });
+ it('provides retry functionality', () => { /* ... */ });
+ it('integrates with error boundaries', () => { /* ... */ });
+});
+```
+
+### Integration Tests
+```typescript
+describe('Page State Handling', () => {
+ it('dashboard shows loading, then content', async () => { /* ... */ });
+ it('league page handles not found', async () => { /* ... */ });
+ it('race page handles network errors', async () => { /* ... */ });
+});
+```
+
+## 10. Accessibility Considerations
+
+All components will be fully accessible:
+- ARIA labels for loading states
+- Keyboard navigation support
+- Screen reader announcements
+- High contrast support
+- Focus management
+
+## 11. Performance Considerations
+
+- Lazy loading for heavy components
+- Memoization where appropriate
+- Optimistic updates support
+- Debounced retry logic
+- Minimal re-renders
+
+## 12. Documentation and Training
+
+### Developer Documentation
+- Component API documentation
+- Migration guides
+- Best practices
+- Troubleshooting guide
+
+### Team Training
+- Workshop on new patterns
+- Code review guidelines
+- Migration walkthroughs
+
+## 13. TypeScript Interfaces Reference
+
+All TypeScript interfaces are defined in `components/shared/state/types.ts`. Here's a comprehensive reference:
+
+### Core State Interfaces
+
+```typescript
+// Basic state for any data fetching
+interface PageState {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+}
+
+// Extended state with metadata
+interface PageStateWithMeta extends PageState {
+ isFetching: boolean;
+ refetch: () => Promise;
+ lastUpdated: Date | null;
+ isStale: boolean;
+}
+```
+
+### Hook Interfaces
+
+```typescript
+// useDataFetching options
+interface UseDataFetchingOptions {
+ queryKey: string[];
+ queryFn: () => Promise;
+ enabled?: boolean;
+ retryOnMount?: boolean;
+ cacheTime?: number;
+ staleTime?: number;
+ maxRetries?: number;
+ retryDelay?: number;
+ onSuccess?: (data: T) => void;
+ onError?: (error: ApiError) => void;
+}
+
+// useDataFetching result
+interface UseDataFetchingResult {
+ data: T | null;
+ isLoading: boolean;
+ isFetching: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ refetch: () => Promise;
+ lastUpdated: Date | null;
+ isStale: boolean;
+}
+```
+
+### Component Props
+
+```typescript
+// LoadingWrapper
+interface LoadingWrapperProps {
+ variant?: 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
+ message?: string;
+ className?: string;
+ size?: 'sm' | 'md' | 'lg';
+ skeletonCount?: number;
+ cardConfig?: {
+ height?: number;
+ count?: number;
+ className?: string;
+ };
+ ariaLabel?: string;
+}
+
+// ErrorDisplay
+interface ErrorDisplayProps {
+ error: ApiError;
+ onRetry?: () => void;
+ variant?: 'full-screen' | 'inline' | 'card' | 'toast';
+ showRetry?: boolean;
+ showNavigation?: boolean;
+ actions?: ErrorAction[];
+ className?: string;
+ hideTechnicalDetails?: boolean;
+ ariaLabel?: string;
+}
+
+interface ErrorAction {
+ label: string;
+ onClick: () => void;
+ variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
+ icon?: LucideIcon;
+ disabled?: boolean;
+}
+
+// EmptyState
+interface EmptyStateProps {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ icon?: LucideIcon;
+ variant?: 'primary' | 'secondary';
+ };
+ variant?: 'default' | 'minimal' | 'full-page';
+ className?: string;
+ illustration?: 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
+ ariaLabel?: string;
+}
+
+// StateContainer
+interface StateContainerProps {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ children: (data: T) => ReactNode;
+ config?: StateContainerConfig;
+ className?: string;
+ showEmpty?: boolean;
+ isEmpty?: (data: T) => boolean;
+}
+
+interface StateContainerConfig {
+ loading?: {
+ variant?: LoadingVariant;
+ message?: string;
+ size?: 'sm' | 'md' | 'lg';
+ };
+ error?: {
+ variant?: ErrorVariant;
+ actions?: ErrorAction[];
+ showRetry?: boolean;
+ showNavigation?: boolean;
+ };
+ empty?: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+ customRender?: {
+ loading?: () => ReactNode;
+ error?: (error: ApiError) => ReactNode;
+ empty?: () => ReactNode;
+ };
+}
+```
+
+### Page Template Interfaces
+
+```typescript
+// Generic page template
+interface PageTemplateProps {
+ data: T | null;
+ isLoading: boolean;
+ error: ApiError | null;
+ retry: () => Promise;
+ refetch: () => Promise;
+ title?: string;
+ description?: string;
+ children: (data: T) => ReactNode;
+ config?: StateContainerConfig;
+}
+
+// List page template
+interface ListPageTemplateProps extends PageTemplateProps {
+ emptyConfig?: {
+ icon: LucideIcon;
+ title: string;
+ description?: string;
+ action?: {
+ label: string;
+ onClick: () => void;
+ };
+ };
+ showSkeleton?: boolean;
+ skeletonCount?: number;
+}
+
+// Detail page template
+interface DetailPageTemplateProps extends PageTemplateProps {
+ onBack?: () => void;
+ onRefresh?: () => void;
+}
+```
+
+### Configuration Interfaces
+
+```typescript
+// Retry configuration
+interface RetryConfig {
+ maxAttempts?: number;
+ baseDelay?: number;
+ backoffMultiplier?: number;
+ retryOnMount?: boolean;
+}
+
+// Notification configuration
+interface NotificationConfig {
+ showToastOnSuccess?: boolean;
+ showToastOnError?: boolean;
+ successMessage?: string;
+ errorMessage?: string;
+ autoDismissDelay?: number;
+}
+
+// Analytics configuration
+interface StateAnalytics {
+ onStateChange?: (from: string, to: string, data?: unknown) => void;
+ onError?: (error: ApiError, context: string) => void;
+ onRetry?: (attempt: number, maxAttempts: number) => void;
+}
+
+// Performance metrics
+interface PerformanceMetrics {
+ timeToFirstRender?: number;
+ timeToDataLoad?: number;
+ retryCount?: number;
+ cacheHit?: boolean;
+}
+
+// Advanced configuration
+interface AdvancedStateConfig extends StateContainerConfig {
+ retry?: RetryConfig;
+ notifications?: NotificationConfig;
+ analytics?: StateAnalytics;
+ performance?: PerformanceMetrics;
+}
+```
+
+### Type Aliases
+
+```typescript
+type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
+type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
+type EmptyVariant = 'default' | 'minimal' | 'full-page';
+type EmptyCheck = (data: T | null) => boolean;
+```
+
+### Default Configuration
+
+```typescript
+const DEFAULT_CONFIG = {
+ loading: {
+ variant: 'spinner',
+ message: 'Loading...',
+ size: 'md',
+ },
+ error: {
+ variant: 'full-screen',
+ showRetry: true,
+ showNavigation: true,
+ },
+ empty: {
+ title: 'No data available',
+ description: 'There is nothing to display here',
+ },
+ retry: {
+ maxAttempts: 3,
+ baseDelay: 1000,
+ backoffMultiplier: 2,
+ retryOnMount: true,
+ },
+ notifications: {
+ showToastOnSuccess: false,
+ showToastOnError: true,
+ autoDismissDelay: 5000,
+ },
+} as const;
+```
+
+## 14. Success Metrics
+
+- 100% of pages use standardized components
+- Reduced error handling code by 60%
+- Consistent user experience across all pages
+- Improved error recovery rates
+- Faster development velocity
+
+## 15. Rollback Plan
+
+If issues arise:
+1. Feature flags to toggle new components
+2. Gradual rollout with monitoring
+3. Quick revert capability
+4. Comprehensive logging for debugging
+
+---
+
+This design provides a complete, user-friendly solution for error and load state handling that will improve both developer experience and user experience across the entire GridPilot website application.
\ No newline at end of file
diff --git a/apps/website/docs/STREAMLINED_STATE_HANDLING_SUMMARY.md b/apps/website/docs/STREAMLINED_STATE_HANDLING_SUMMARY.md
new file mode 100644
index 000000000..a9e8d2e68
--- /dev/null
+++ b/apps/website/docs/STREAMLINED_STATE_HANDLING_SUMMARY.md
@@ -0,0 +1,264 @@
+# Streamlined Error & Load State Handling - Quick Reference
+
+## 🎯 Goal
+Standardize error and loading state handling across all GridPilot website pages using user-friendly, accessible, and consistent shared components.
+
+## 📁 File Structure
+
+```
+apps/website/components/shared/
+├── state/
+│ ├── LoadingWrapper.tsx # Loading states
+│ ├── ErrorDisplay.tsx # Error states
+│ ├── EmptyState.tsx # Empty states
+│ └── StateContainer.tsx # Combined wrapper
+├── hooks/
+│ └── useDataFetching.ts # Unified data fetching
+└── types/
+ └── state.types.ts # TypeScript interfaces
+```
+
+## 🚀 Quick Start Examples
+
+### 1. Basic Page Implementation
+
+```typescript
+import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
+import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
+import { ErrorDisplay } from '@/components/shared/state/ErrorDisplay';
+import { EmptyState } from '@/components/shared/state/EmptyState';
+
+function MyPage() {
+ const { data, isLoading, error, retry } = useDataFetching({
+ queryKey: ['myData'],
+ queryFn: () => myService.getData(),
+ });
+
+ if (isLoading) return ;
+ if (error) return ;
+ if (!data) return ;
+
+ return ;
+}
+```
+
+### 2. Using StateContainer (Recommended)
+
+```typescript
+import { StateContainer } from '@/components/shared/state/StateContainer';
+
+function MyPage() {
+ const { data, isLoading, error, retry } = useDataFetching({
+ queryKey: ['myData'],
+ queryFn: () => myService.getData(),
+ });
+
+ return (
+
+ {(content) => }
+
+ );
+}
+```
+
+## 🎨 Component Variants
+
+### LoadingWrapper
+- `spinner` - Traditional spinner (default)
+- `skeleton` - Skeleton screens
+- `full-screen` - Centered in viewport
+- `inline` - Compact inline
+- `card` - Card placeholders
+
+### ErrorDisplay
+- `full-screen` - Full page error
+- `inline` - Inline error message
+- `card` - Error card
+- `toast` - Toast notification
+
+### EmptyState
+- `default` - Standard empty state
+- `minimal` - Simple version
+- `full-page` - Full page empty state
+
+## 🔧 useDataFetching Hook
+
+```typescript
+const {
+ data, // The fetched data
+ isLoading, // Initial load
+ isFetching, // Any fetch (including refetch)
+ error, // ApiError or null
+ retry, // Retry failed request
+ refetch, // Manual refetch
+ lastUpdated, // Timestamp
+ isStale // Cache status
+} = useDataFetching({
+ queryKey: ['uniqueKey', id],
+ queryFn: () => apiService.getData(id),
+ enabled: true, // Enable/disable query
+ retryOnMount: true, // Auto-retry on mount
+ cacheTime: 5 * 60 * 1000, // 5 minutes
+ staleTime: 1 * 60 * 1000, // 1 minute
+ maxRetries: 3,
+ retryDelay: 1000,
+ onSuccess: (data) => { /* ... */ },
+ onError: (error) => { /* ... */ },
+});
+```
+
+## 📋 Migration Checklist
+
+### Phase 1: Foundation
+- [ ] Create `components/shared/state/` directory
+- [ ] Implement `LoadingWrapper.tsx`
+- [ ] Implement `ErrorDisplay.tsx`
+- [ ] Implement `EmptyState.tsx`
+- [ ] Implement `StateContainer.tsx`
+- [ ] Implement `useDataFetching.ts` hook
+- [ ] Create TypeScript interfaces
+- [ ] Add comprehensive tests
+
+### Phase 2: Core Pages (High Priority)
+- [ ] `app/dashboard/page.tsx`
+- [ ] `app/leagues/[id]/LeagueDetailInteractive.tsx`
+- [ ] `app/races/[id]/RaceDetailInteractive.tsx`
+
+### Phase 3: Additional Pages
+- [ ] `app/teams/[id]/TeamDetailInteractive.tsx`
+- [ ] `app/leagues/[id]/schedule/page.tsx`
+- [ ] `app/races/[id]/results/RaceResultsInteractive.tsx`
+- [ ] `app/races/[id]/stewarding/RaceStewardingInteractive.tsx`
+- [ ] Sponsor pages
+- [ ] Profile pages
+
+### Phase 4: Cleanup
+- [ ] Remove old loading components
+- [ ] Remove old error components
+- [ ] Update documentation
+- [ ] Team training
+
+## 🔄 Before & After
+
+### Before (Inconsistent)
+```typescript
+function DashboardPage() {
+ const { data, isLoading, error } = useDashboardOverview();
+
+ if (isLoading) {
+ return (
+
+ Loading dashboard...
+
+ );
+ }
+
+ if (error || !dashboardData) {
+ return (
+
+ Failed to load dashboard
+
+ );
+ }
+
+ return ;
+}
+```
+
+### After (Standardized)
+```typescript
+function DashboardPage() {
+ const { data, isLoading, error, retry } = useDataFetching({
+ queryKey: ['dashboardOverview'],
+ queryFn: () => dashboardService.getDashboardOverview(),
+ });
+
+ return (
+
+ {(content) => }
+
+ );
+}
+```
+
+## 🎯 Key Benefits
+
+1. **Consistency**: Same patterns across all pages
+2. **User-Friendly**: Clear messages and helpful actions
+3. **Accessible**: ARIA labels, keyboard navigation
+4. **Developer-Friendly**: Simple API, less boilerplate
+5. **Maintainable**: Single source of truth
+6. **Flexible**: Multiple variants for different needs
+7. **Type-Safe**: Full TypeScript support
+
+## 📊 Success Metrics
+
+- ✅ 100% page coverage
+- ✅ 60% less error handling code
+- ✅ Consistent UX across app
+- ✅ Better error recovery
+- ✅ Faster development
+
+## 🔗 Related Files
+
+- Design Document: `STREAMLINED_STATE_HANDLING_DESIGN.md`
+- TypeScript Interfaces: `components/shared/state/types.ts`
+- Component Tests: `components/shared/state/*.test.tsx`
+- Hook Tests: `components/shared/hooks/useDataFetching.test.ts`
+
+## 💡 Tips
+
+1. **Always use `useDataFetching`** instead of manual state management
+2. **Prefer `StateContainer`** for complex pages
+3. **Use `skeleton` variant** for better perceived performance
+4. **Enable `retryOnMount`** for recoverable errors
+5. **Customize config** per page needs
+6. **Test all states**: loading, error, empty, success
+
+## 🆘 Troubleshooting
+
+**Q: Error not showing?**
+A: Check if error is instance of `ApiError`
+
+**Q: Loading not visible?**
+A: Verify `isLoading` is true and component is rendered
+
+**Q: Retry not working?**
+A: Ensure `onRetry` prop is passed to ErrorDisplay
+
+**Q: Empty state not showing?**
+A: Check if data is null/undefined and `showEmpty` is true
+
+---
+
+For detailed implementation guide, see: `STREAMLINED_STATE_HANDLING_DESIGN.md`
\ No newline at end of file
diff --git a/apps/website/templates/LeagueDetailTemplate.tsx b/apps/website/templates/LeagueDetailTemplate.tsx
index 537e60c71..e3e38c284 100644
--- a/apps/website/templates/LeagueDetailTemplate.tsx
+++ b/apps/website/templates/LeagueDetailTemplate.tsx
@@ -11,9 +11,8 @@ import SponsorInsightsCard, {
import Button from '@/components/ui/Button';
import Card from '@/components/ui/Card';
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
-import type { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
+import type { LeagueDetailPageViewModel, DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
import type { RaceViewModel } from '@/lib/view-models/RaceViewModel';
-import type { DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
import { ReactNode } from 'react';