error and load state

This commit is contained in:
2026-01-06 11:05:16 +01:00
parent 4a1bfa57a3
commit 6aad7897db
29 changed files with 5172 additions and 1462 deletions

View File

@@ -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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-white">Loading dashboard...</div>
</main>
);
}
return (
<StateContainer
data={dashboardData}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading dashboard...' },
error: { variant: 'full-screen' },
empty: {
icon: Activity,
title: 'No dashboard data',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-red-400">Failed to load dashboard</div>
</main>
);
}
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 (
<main className="min-h-screen bg-deep-graphite">
return (
<main className="min-h-screen bg-deep-graphite">
{/* Hero Section */}
<section className="relative overflow-hidden">
{/* Background Pattern */}
@@ -327,4 +339,7 @@ export default function DashboardPage() {
</section>
</main>
);
}}
</StateContainer>
);
}

View File

@@ -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 (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
<StateContainer
data={viewModel}
isLoading={loading}
/>
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) => (
<DriversTemplate
drivers={driverViewModels}
totalRaces={totalRaces}
totalWins={totalWins}
activeCount={activeCount}
isLoading={false}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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<DriverProfileViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
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 (
<DriverProfileTemplate
driverProfile={driverProfile}
allTeamMemberships={allTeamMemberships}
isLoading={loading}
<StateContainer
data={driverProfile}
isLoading={isLoading}
error={error}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
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) => (
<DriverProfileTemplate
driverProfile={profileData}
allTeamMemberships={allTeamMemberships || []}
isLoading={false}
error={null}
onBackClick={handleBackClick}
onAddFriend={handleAddFriend}
friendRequestSent={friendRequestSent}
activeTab={activeTab}
setActiveTab={setActiveTab}
isSponsorMode={isSponsorMode}
sponsorInsights={sponsorInsights}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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 <LeaderboardsInteractive drivers={drivers} teams={teams} />;
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 (
<StateContainer
data={hasData ? { drivers, teams: teamsData } : null}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leaderboards...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leaderboard data',
description: 'There is no leaderboard data available at the moment.',
}
}}
isEmpty={(data) => !data || (data.drivers.length === 0 && data.teams.length === 0)}
>
{(data) => (
<LeaderboardsTemplate
drivers={data.drivers}
teams={data.teams}
onDriverClick={handleDriverClick}
onTeamClick={handleTeamClick}
onNavigateToDrivers={handleNavigateToDrivers}
onNavigateToTeams={handleNavigateToTeams}
/>
)}
</StateContainer>
);
}

View File

@@ -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<SortBy>('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 <DriverRankingsInteractive drivers={drivers} />;
const handleDriverClick = (driverId: string) => {
if (driverId.startsWith('demo-')) return;
router.push(`/drivers/${driverId}`);
};
const handleBackToLeaderboards = () => {
router.push('/leaderboards');
};
const drivers = driverData?.drivers || [];
return (
<StateContainer
data={drivers}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading driver rankings...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No drivers found',
description: 'There are no drivers in the system yet.',
}
}}
>
{(driversData) => (
<DriverRankingsTemplate
drivers={driversData}
searchQuery={searchQuery}
selectedSkill={selectedSkill}
sortBy={sortBy}
showFilters={showFilters}
onSearchChange={setSearchQuery}
onSkillChange={setSelectedSkill}
onSortChange={setSortBy}
onToggleFilters={() => setShowFilters(!showFilters)}
onDriverClick={handleDriverClick}
onBackToLeaderboards={handleBackToLeaderboards}
/>
)}
</StateContainer>
);
}

View File

@@ -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<LeagueSummaryViewModel[]>([]);
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 (
<LeaguesTemplate
leagues={realLeagues}
loading={loading}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
<StateContainer
data={realLeagues}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading leagues...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No leagues yet',
description: 'Create your first league to start organizing races and events.',
action: { label: 'Create League', onClick: handleCreateLeagueClick }
}
}}
>
{(leaguesData) => (
<LeaguesTemplate
leagues={leaguesData}
loading={false}
onLeagueClick={handleLeagueClick}
onCreateLeagueClick={handleCreateLeagueClick}
/>
)}
</StateContainer>
);
}

View File

@@ -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<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(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 (
<div className="text-center text-gray-400">Loading league...</div>
);
}
if (error || !viewModel) {
return (
<div className="text-center text-warning-amber">
{error || 'League not found'}
</div>
);
}
return (
<>
<LeagueDetailTemplate
viewModel={viewModel}
leagueId={leagueId}
isSponsor={isSponsor}
membership={membership}
currentDriverId={currentDriverId}
onMembershipChange={handleMembershipChange}
onEndRaceModalOpen={handleEndRaceModalOpen}
onLiveRaceClick={handleLiveRaceClick}
onBackToLeagues={handleBackToLeagues}
>
{/* End Race Modal */}
{endRaceModalRaceId && viewModel && (() => {
const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.name}
onConfirm={handleEndRaceConfirm}
onCancel={handleEndRaceCancel}
/>
) : null;
})()}
</LeagueDetailTemplate>
</>
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted or you may not have access',
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
}
}}
>
{(leagueData) => (
<>
<LeagueDetailTemplate
viewModel={leagueData}
leagueId={leagueId}
isSponsor={isSponsor}
membership={membership}
currentDriverId={currentDriverId}
onMembershipChange={handleMembershipChange}
onEndRaceModalOpen={handleEndRaceModalOpen}
onLiveRaceClick={handleLiveRaceClick}
onBackToLeagues={handleBackToLeagues}
>
{/* End Race Modal */}
{endRaceModalRaceId && leagueData && (() => {
const race = leagueData.runningRaces.find(r => r.id === endRaceModalRaceId);
return race ? (
<EndRaceModal
raceId={race.id}
raceName={race.name}
onConfirm={handleEndRaceConfirm}
onCancel={handleEndRaceCancel}
/>
) : null;
})()}
</LeagueDetailTemplate>
</>
)}
</StateContainer>
);
}
}

View File

@@ -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<LeagueSettingsViewModel | null>(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 <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -76,49 +69,47 @@ export default function LeagueSettingsPage() {
);
}
if (loading) {
return (
<Card>
<div className="py-6 text-sm text-gray-400">Loading configuration</div>
</Card>
);
}
if (!settings) {
return (
<Card>
<div className="py-6 text-sm text-gray-500">
Unable to load league configuration for this demo league.
</div>
</Card>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
<StateContainer
data={settings}
isLoading={settingsLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading settings...' },
error: { variant: 'full-screen' },
empty: {
icon: Settings,
title: 'No settings available',
description: 'Unable to load league configuration.',
}
}}
>
{(settingsData) => (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
<Settings className="w-6 h-6 text-primary-blue" />
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={settingsData.league} configForm={settingsData.config} />
<LeagueOwnershipTransfer
settings={settingsData}
currentDriverId={currentDriverId}
onTransferOwnership={handleTransferOwnership}
/>
</div>
</div>
<div>
<h1 className="text-2xl font-bold text-white">League Settings</h1>
<p className="text-sm text-gray-400">Manage your league configuration</p>
</div>
</div>
{/* READONLY INFORMATION SECTION - Compact */}
<div className="space-y-4">
<ReadonlyLeagueInfo league={settings.league} configForm={settings.config} />
<LeagueOwnershipTransfer
settings={settings}
currentDriverId={currentDriverId}
onTransferOwnership={handleTransferOwnership}
/>
</div>
</div>
)}
</StateContainer>
);
}

View File

@@ -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<LeagueStewardingViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [isAdmin, setIsAdmin] = useState(false);
const [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(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<string>();
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<string>();
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 <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -166,248 +170,260 @@ export default function LeagueStewardingPage() {
}
return (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
{/* Stats summary */}
{!loading && stewardingData && (
<StewardingStats
totalPending={stewardingData.totalPending}
totalResolved={stewardingData.totalResolved}
totalPenalties={stewardingData.totalPenalties}
/>
)}
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{stewardingData && stewardingData.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{stewardingData.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="text-center py-12 text-gray-400">
<div className="animate-pulse">Loading stewarding data...</div>
</div>
) : filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
<StateContainer
data={stewardingData}
isLoading={dataLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'No stewarding data',
description: 'There are no protests or penalties to review.',
}
}}
>
{(data) => (
<div className="space-y-6">
<Card>
<div className="flex items-center justify-between mb-6">
<div>
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
<p className="text-sm text-gray-400 mt-1">
Quick overview of protests and penalties across all races
</p>
</div>
</div>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{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 (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{/* Stats summary */}
<StewardingStats
totalPending={data.totalPending}
totalResolved={data.totalResolved}
totalPenalties={data.totalPenalties}
/>
{activeTab === 'history' && penalties.map((penalty) => {
const driver = stewardingData!.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{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`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
{/* Tab navigation */}
<div className="border-b border-charcoal-outline mb-6">
<div className="flex gap-4">
<button
onClick={() => setActiveTab('pending')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'pending'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
Pending Protests
{data.totalPending > 0 && (
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
{data.totalPending}
</span>
)}
</button>
<button
onClick={() => setActiveTab('history')}
className={`pb-3 px-1 font-medium transition-colors ${
activeTab === 'history'
? 'text-primary-blue border-b-2 border-primary-blue'
: 'text-gray-400 hover:text-white'
}`}
>
History
</button>
</div>
</div>
{/* Content */}
{filteredRaces.length === 0 ? (
<div className="text-center py-12">
<div className="w-16 h-16 mx-auto mb-4 rounded-full bg-performance-green/10 flex items-center justify-center">
<Flag className="w-8 h-8 text-performance-green" />
</div>
);
})}
</div>
)}
</Card>
<p className="font-semibold text-lg text-white mb-2">
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
</p>
<p className="text-sm text-gray-400">
{activeTab === 'pending'
? 'No pending protests to review'
: 'No resolved protests or penalties'}
</p>
</div>
) : (
<div className="space-y-4">
{filteredRaces.map(({ race, pendingProtests, resolvedProtests, penalties }) => {
const isExpanded = expandedRaces.has(race.id);
const displayProtests = activeTab === 'pending' ? pendingProtests : resolvedProtests;
return (
<div key={race.id} className="rounded-lg border border-charcoal-outline overflow-hidden">
{/* Race Header */}
<button
onClick={() => toggleRaceExpanded(race.id)}
className="w-full px-4 py-3 bg-iron-gray/30 hover:bg-iron-gray/50 transition-colors flex items-center justify-between"
>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-gray-400" />
<span className="font-medium text-white">{race.track}</span>
</div>
<div className="flex items-center gap-2 text-gray-400 text-sm">
<Calendar className="w-4 h-4" />
<span>{race.scheduledAt.toLocaleDateString()}</span>
</div>
{activeTab === 'pending' && pendingProtests.length > 0 && (
<span className="px-2 py-0.5 text-xs font-medium bg-warning-amber/20 text-warning-amber rounded-full">
{pendingProtests.length} pending
</span>
)}
{activeTab === 'history' && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-500/20 text-gray-400 rounded-full">
{resolvedProtests.length} protests, {penalties.length} penalties
</span>
)}
</div>
<ChevronRight className={`w-5 h-5 text-gray-400 transition-transform ${isExpanded ? 'rotate-90' : ''}`} />
</button>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{/* Expanded Content */}
{isExpanded && (
<div className="p-4 space-y-3 bg-deep-graphite/50">
{displayProtests.length === 0 && penalties.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
) : (
<>
{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 (
<div
key={protest.id}
className={`rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4 ${isUrgent ? 'border-l-4 border-l-red-500' : ''}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
<AlertCircle className="w-4 h-4 text-warning-amber flex-shrink-0" />
<span className="font-medium text-white">
{protester?.name || 'Unknown'} vs {accused?.name || 'Unknown'}
</span>
{getStatusBadge(protest.status)}
{isUrgent && (
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full flex items-center gap-1">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-400 mb-2">
<span>Lap {protest.incident.lap}</span>
<span></span>
<span>Filed {new Date(protest.filedAt).toLocaleDateString()}</span>
{protest.proofVideoUrl && (
<>
<span></span>
<span className="flex items-center gap-1 text-primary-blue">
<Video className="w-3 h-3" />
Video
</span>
</>
)}
</div>
<p className="text-sm text-gray-300 line-clamp-2">
{protest.incident.description}
</p>
{protest.decisionNotes && (
<div className="mt-2 p-2 rounded bg-iron-gray/50 border border-charcoal-outline/50">
<p className="text-xs text-gray-400">
<span className="font-medium">Steward:</span> {protest.decisionNotes}
</p>
</div>
)}
</div>
{(protest.status === 'pending' || protest.status === 'under_review') && (
<Link href={`/leagues/${leagueId}/stewarding/protests/${protest.id}`}>
<Button variant="primary">
Review
</Button>
</Link>
)}
</div>
</div>
);
})}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{activeTab === 'history' && penalties.map((penalty) => {
const driver = data.driverMap[penalty.driverId];
return (
<div
key={penalty.id}
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
<Gavel className="w-4 h-4 text-red-400" />
</div>
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
{penalty.type.replace('_', ' ')}
</span>
</div>
<p className="text-sm text-gray-400">{penalty.reason}</p>
</div>
<div className="text-right">
<span className="text-lg font-bold text-red-400">
{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`}
</span>
</div>
</div>
</div>
);
})}
</>
)}
</div>
)}
</div>
);
})}
</div>
)}
</Card>
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
{activeTab === 'history' && (
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
)}
{selectedProtest && (
<ReviewProtestModal
protest={selectedProtest}
onClose={() => setSelectedProtest(null)}
onAccept={handleAcceptProtest}
onReject={handleRejectProtest}
/>
)}
{showQuickPenaltyModal && stewardingData && (
<QuickPenaltyModal
drivers={stewardingData.allDrivers}
onClose={() => setShowQuickPenaltyModal(false)}
adminId={currentDriverId}
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
/>
)}
</div>
)}
</div>
</StateContainer>
);
}

View File

@@ -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<ProtestDetailViewModel | null>(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<number>(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 <LoadingWrapper variant="full-screen" message="Checking permissions..." />;
}
// Show access denied if not admin
if (!isAdmin) {
return (
<Card>
@@ -311,435 +303,440 @@ export default function ProtestReviewPage() {
);
}
if (loading || !detail) {
return (
<Card>
<div className="text-center py-12">
<div className="animate-pulse text-gray-400">Loading protest details...</div>
</div>
</Card>
);
}
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 (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</div>
</div>
<StateContainer
data={detail}
isLoading={detailLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading protest details...' },
error: { variant: 'full-screen' },
}}
>
{(protestDetail) => {
const protest = protestDetail.protest;
const race = protestDetail.race;
const protestingDriver = protestDetail.protestingDriver;
const accusedDriver = protestDetail.accusedDriver;
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
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 (
<div className="min-h-screen">
{/* Compact Header */}
<div className="mb-6">
<div className="flex items-center gap-3 mb-4">
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex-1 flex items-center gap-3">
<h1 className="text-xl font-bold text-white">Protest Review</h1>
<div className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${statusConfig.color}`}>
<StatusIcon className="w-3 h-3" />
{statusConfig.label}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
{daysSinceFiled > 2 && isPending && (
<span className="flex items-center gap-1 px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
<AlertTriangle className="w-3 h-3" />
{daysSinceFiled}d old
</span>
)}
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
</Card>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
{/* Main Layout: Feed + Sidebar */}
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
{/* Left Sidebar - Incident Info */}
<div className="lg:col-span-3 space-y-4">
{/* Drivers Involved */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
<div className="space-y-3">
{/* Protesting Driver */}
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-blue-500/50 hover:bg-blue-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-blue-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-blue-400 font-medium">Protesting</p>
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
{/* Accused Driver */}
<Link href={`/drivers/${accusedDriver?.id || ''}`} className="block">
<div className="flex items-center gap-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-orange-500/50 hover:bg-orange-500/5 transition-colors cursor-pointer">
<div className="w-10 h-10 rounded-full bg-orange-500/20 flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-orange-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-orange-400 font-medium">Accused</p>
<p className="text-sm font-semibold text-white truncate">{accusedDriver?.name || 'Unknown'}</p>
</div>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
</div>
</Card>
{/* Race Info */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Race Details</h3>
<Link
href={`/races/${race.id}`}
className="block mb-3 p-3 rounded-lg bg-deep-graphite border border-charcoal-outline hover:border-primary-blue/50 hover:bg-primary-blue/5 transition-colors"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-white">{race.name}</span>
<ExternalLink className="w-3 h-3 text-gray-500" />
</div>
</Link>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm">
<MapPin className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.name}</span>
</div>
<div className="flex items-center gap-2 text-sm">
<Calendar className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">{race.formattedDate}</span>
</div>
{protest.incident?.lap && (
<div className="flex items-center gap-2 text-sm">
<Flag className="w-4 h-4 text-gray-500" />
<span className="text-gray-300">Lap {protest.incident.lap}</span>
</div>
)}
</div>
</Card>
{protest.proofVideoUrl && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Evidence</h3>
<a
href={protest.proofVideoUrl}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-2 p-3 rounded-lg bg-primary-blue/10 border border-primary-blue/20 text-primary-blue hover:bg-primary-blue/20 transition-colors"
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
<Video className="w-4 h-4" />
<span className="text-sm font-medium flex-1">Watch Video</span>
<ExternalLink className="w-3 h-3" />
</a>
</Card>
)}
{/* Quick Stats */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Timeline</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-gray-500">Filed</span>
<span className="text-gray-300">{new Date(protest.submittedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between">
<span className="text-gray-500">Age</span>
<span className={daysSinceFiled > 2 ? 'text-red-400' : 'text-gray-300'}>{daysSinceFiled} days</span>
</div>
{protest.reviewedAt && (
<div className="flex justify-between">
<span className="text-gray-500">Resolved</span>
<span className="text-gray-300">{new Date(protest.reviewedAt).toLocaleDateString()}</span>
</div>
)}
</div>
</Card>
</div>
{/* Center - Discussion Feed */}
<div className="lg:col-span-6 space-y-4">
{/* Timeline / Feed */}
<Card className="p-0 overflow-hidden">
<div className="p-4 border-b border-charcoal-outline bg-iron-gray/30">
<h2 className="text-sm font-semibold text-white">Discussion</h2>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
<div className="divide-y divide-charcoal-outline/50">
{/* Initial Protest Filing */}
<div className="p-4">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
<AlertCircle className="w-5 h-5 text-blue-400" />
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
<span className="text-xs text-blue-400 font-medium">filed protest</span>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
</div>
<div className="bg-deep-graphite rounded-lg p-4 border border-charcoal-outline">
<p className="text-sm text-gray-300 mb-3">{protest.description}</p>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
{protest.comment && (
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
<p className="text-sm text-gray-400">{protest.comment}</p>
</div>
)}
</div>
</div>
</div>
</div>
{/* Defense placeholder - will be populated when defense system is implemented */}
{protest.status === 'awaiting_defense' && (
<div className="p-4 bg-purple-500/5">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-purple-500/20 flex items-center justify-center flex-shrink-0">
<MessageCircle className="w-5 h-5 text-purple-400" />
</div>
<div className="flex-1">
<p className="text-sm text-purple-400 font-medium mb-1">Defense Requested</p>
<p className="text-sm text-gray-400">Waiting for {accusedDriver?.name || 'the accused driver'} to submit their defense...</p>
</div>
</div>
</div>
)}
{/* Decision (if resolved) */}
{(protest.status === 'upheld' || protest.status === 'dismissed') && protest.decisionNotes && (
<div className={`p-4 ${protest.status === 'upheld' ? 'bg-red-500/5' : 'bg-gray-500/5'}`}>
<div className="flex gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center flex-shrink-0 ${
protest.status === 'upheld' ? 'bg-red-500/20' : 'bg-gray-500/20'
}`}>
<Gavel className={`w-5 h-5 ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-white text-sm">Steward Decision</span>
<span className={`text-xs font-medium ${protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'}`}>
{protest.status === 'upheld' ? 'Protest Upheld' : 'Protest Dismissed'}
</span>
{protest.reviewedAt && (
<>
<span className="text-xs text-gray-500"></span>
<span className="text-xs text-gray-500">{new Date(protest.reviewedAt).toLocaleString()}</span>
</>
)}
</div>
)}
</>
)}
<div className={`rounded-lg p-4 border ${
protest.status === 'upheld'
? 'bg-red-500/10 border-red-500/20'
: 'bg-gray-500/10 border-gray-500/20'
}`}>
<p className="text-sm text-gray-300">{protest.decisionNotes}</p>
</div>
</div>
</div>
</div>
)}
</div>
{/* Add Comment (future feature) */}
{isPending && (
<div className="p-4 border-t border-charcoal-outline bg-iron-gray/20">
<div className="flex gap-3">
<div className="w-10 h-10 rounded-full bg-iron-gray flex items-center justify-center flex-shrink-0">
<User className="w-5 h-5 text-gray-500" />
</div>
<div className="flex-1">
<textarea
value={newComment}
onChange={(e) => setNewComment(e.target.value)}
placeholder="Add a comment or request more information..."
rows={2}
className="w-full px-4 py-3 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 focus:outline-none focus:border-primary-blue text-sm resize-none"
/>
<div className="flex justify-end mt-2">
<Button variant="secondary" disabled={!newComment.trim()}>
<Send className="w-3 h-3 mr-1" />
Comment
</Button>
</div>
</div>
</div>
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
{/* Right Sidebar - Actions */}
<div className="lg:col-span-3 space-y-4">
{isPending && (
<>
{/* Quick Actions */}
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Actions</h3>
<div className="space-y-2">
<Button
variant="secondary"
className="w-full justify-start"
onClick={handleRequestDefense}
>
<MessageCircle className="w-4 h-4 mr-2" />
Request Defense
</Button>
<Button
variant="primary"
className="w-full justify-start"
onClick={() => setShowDecisionPanel(!showDecisionPanel)}
>
<Gavel className="w-4 h-4 mr-2" />
Make Decision
<ChevronDown className={`w-4 h-4 ml-auto transition-transform ${showDecisionPanel ? 'rotate-180' : ''}`} />
</Button>
</div>
</Card>
{/* Decision Panel */}
{showDecisionPanel && (
<Card className="p-4">
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Stewarding Decision</h3>
{/* Decision Selection */}
<div className="grid grid-cols-2 gap-2 mb-4">
<button
onClick={() => setDecision('uphold')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'uphold'
? 'border-red-500 bg-red-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<CheckCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'uphold' ? 'text-red-400' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
</button>
<button
onClick={() => setDecision('dismiss')}
className={`p-3 rounded-lg border-2 transition-all ${
decision === 'dismiss'
? 'border-gray-500 bg-gray-500/10'
: 'border-charcoal-outline hover:border-gray-600'
}`}
>
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
</button>
</div>
{/* Penalty Selection (if upholding) */}
{decision === 'uphold' && (
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
{penaltyTypes.length === 0 ? (
<div className="text-xs text-gray-500">
Loading penalty types...
</div>
) : (
<>
<div className="grid grid-cols-2 gap-1.5">
{penaltyTypes.map((penalty) => {
const Icon = penalty.icon;
const isSelected = penaltyType === penalty.type;
return (
<button
key={penalty.type}
onClick={() => {
setPenaltyType(penalty.type);
setPenaltyValue(penalty.defaultValue);
}}
className={`p-2 rounded-lg border transition-all text-left ${
isSelected
? `${penalty.color} border`
: 'border-charcoal-outline hover:border-gray-600 bg-iron-gray/30'
}`}
title={penalty.description}
>
<Icon className={`h-3.5 w-3.5 mb-0.5 ${isSelected ? '' : 'text-gray-500'}`} />
<p className={`text-[10px] font-medium leading-tight ${isSelected ? '' : 'text-gray-500'}`}>
{penalty.label}
</p>
</button>
);
})}
</div>
{selectedPenalty?.requiresValue && (
<div className="mt-3">
<label className="text-xs font-medium text-gray-400 mb-1 block">
Value ({selectedPenalty.valueLabel})
</label>
<input
type="number"
value={penaltyValue}
onChange={(e) => setPenaltyValue(Number(e.target.value))}
min="1"
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white text-sm focus:outline-none focus:border-primary-blue"
/>
</div>
)}
</>
)}
</div>
)}
{/* Steward Notes */}
<div className="mb-4">
<label className="text-xs font-medium text-gray-400 mb-1 block">Decision Reasoning *</label>
<textarea
value={stewardNotes}
onChange={(e) => setStewardNotes(e.target.value)}
placeholder="Explain your decision..."
rows={4}
className="w-full px-3 py-2 bg-deep-graphite border border-charcoal-outline rounded-lg text-white placeholder-gray-500 text-sm focus:outline-none focus:border-primary-blue resize-none"
/>
</div>
{/* Submit */}
<Button
variant="primary"
className="w-full"
onClick={handleSubmitDecision}
disabled={!decision || !stewardNotes.trim() || submitting}
>
{submitting ? 'Submitting...' : 'Submit Decision'}
</Button>
</Card>
)}
</>
)}
{/* Already Resolved Info */}
{!isPending && (
<Card className="p-4">
<div className={`text-center py-4 ${
protest.status === 'upheld' ? 'text-red-400' : 'text-gray-400'
}`}>
<Gavel className="w-8 h-8 mx-auto mb-2" />
<p className="font-semibold">Case Closed</p>
<p className="text-xs text-gray-500 mt-1">
{protest.status === 'upheld' ? 'Protest was upheld' : 'Protest was dismissed'}
</p>
</div>
</Card>
)}
</div>
</div>
</div>
);
}}
</StateContainer>
);
}

View File

@@ -1,19 +1,31 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
import { useCurrentDriver } from '@/hooks/useDriverService';
import { useAuth } from '@/lib/auth/AuthContext';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import { useServices } from '@/lib/services/ServiceProvider';
export default function OnboardingPage() {
const router = useRouter();
const { session } = useAuth();
const { data: driver, isLoading } = useCurrentDriver();
const { driverService } = useServices();
// Check if user is logged in
const shouldRedirectToLogin = !session;
// Fetch current driver data
const { data: driver, isLoading } = useDataFetching({
queryKey: ['currentDriver'],
queryFn: () => driverService.getCurrentDriver(),
enabled: !!session,
});
const shouldRedirectToDashboard = !isLoading && Boolean(driver);
useEffect(() => {
@@ -32,8 +44,8 @@ export default function OnboardingPage() {
if (isLoading) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
<main className="min-h-screen bg-deep-graphite">
<LoadingWrapper variant="full-screen" message="Loading onboarding..." />
</main>
);
}
@@ -47,4 +59,4 @@ export default function OnboardingPage() {
<OnboardingWizard />
</main>
);
}
}

View File

@@ -14,6 +14,11 @@ import type {
DriverProfileViewModel
} from '@/lib/view-models/DriverProfileViewModel';
import { getMediaUrl } from '@/lib/utilities/media';
// Shared state components
import { useDataFetching } from '@/components/shared/hooks/useDataFetching';
import { StateContainer } from '@/components/shared/state/StateContainer';
import { LoadingWrapper } from '@/components/shared/state/LoadingWrapper';
import {
Activity,
Award,
@@ -260,34 +265,19 @@ export default function ProfilePage() {
const { driverService, mediaService } = useServices();
const [profileData, setProfileData] = useState<DriverProfileViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
const effectiveDriverId = useEffectiveDriverId();
const isOwnProfile = true; // This page is always your own profile
useEffect(() => {
if (!effectiveDriverId) {
return;
}
// Shared state components
const { data: profileData, isLoading: loading, error, retry } = useDataFetching({
queryKey: ['driverProfile', effectiveDriverId],
queryFn: () => driverService.getDriverProfile(effectiveDriverId),
enabled: !!effectiveDriverId,
});
const loadData = async () => {
setLoading(true);
try {
const profileViewModel = await driverService.getDriverProfile(effectiveDriverId);
setProfileData(profileViewModel);
} catch (error) {
console.error('Failed to load profile:', error);
} finally {
setLoading(false);
}
};
void loadData();
}, [effectiveDriverId, driverService]);
const [editMode, setEditMode] = useState(false);
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
const [friendRequestSent, setFriendRequestSent] = useState(false);
// Update URL when tab changes
useEffect(() => {
@@ -315,7 +305,8 @@ export default function ProfilePage() {
try {
const updatedProfile = await driverService.updateProfile(updates);
setProfileData(updatedProfile);
// Update local state
retry();
setEditMode(false);
} catch (error) {
console.error('Failed to update profile:', error);
@@ -327,20 +318,8 @@ export default function ProfilePage() {
// In production, this would call a use case
};
if (loading) {
return (
<div className="max-w-6xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading profile...</p>
</div>
</div>
</div>
);
}
if (!profileData?.currentDriver) {
// Show create form if no profile exists
if (!loading && !profileData?.currentDriver && !error) {
return (
<div className="max-w-4xl mx-auto px-4">
<div className="text-center mb-8">
@@ -366,16 +345,8 @@ export default function ProfilePage() {
);
}
// Extract data from profileData ViewModel
const currentDriver = profileData.currentDriver;
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver?.globalRank || null;
// Show edit mode
if (editMode) {
if (editMode && profileData?.currentDriver) {
return (
<div className="max-w-4xl mx-auto px-4 space-y-6">
<div className="flex items-center justify-between mb-4">
@@ -390,7 +361,49 @@ export default function ProfilePage() {
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
<StateContainer
data={profileData}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading profile...' },
error: { variant: 'full-screen' },
empty: {
icon: User,
title: 'No profile data',
description: 'Unable to load your profile information',
action: { label: 'Retry', onClick: retry }
}
}}
>
{(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 (
<div className="max-w-4xl mx-auto px-4">
<Card className="text-center py-12">
<User className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No driver profile found</p>
<p className="text-sm text-gray-500">Please create a driver profile to continue</p>
</Card>
</div>
);
}
const stats = profileData.stats;
const teamMemberships = profileData.teamMemberships;
const socialSummary = profileData.socialSummary;
const extendedProfile = profileData.extendedProfile;
const globalRank = currentDriver.globalRank || null;
return (
<div className="max-w-7xl mx-auto px-4 pb-12 space-y-6">
{/* Hero Header Section */}
<div className="relative rounded-2xl overflow-hidden bg-gradient-to-br from-iron-gray/80 via-iron-gray/60 to-deep-graphite border border-charcoal-outline">
{/* Background Pattern */}
@@ -1045,13 +1058,16 @@ export default function ProfilePage() {
</div>
)}
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
{activeTab === 'stats' && !stats && (
<Card className="text-center py-12">
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-2">No statistics available yet</p>
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
</Card>
)}
</div>
);
}}
</StateContainer>
);
}

View File

@@ -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 (
<RaceDetailTemplate
viewModel={templateViewModel}
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading race details...' },
error: { variant: 'full-screen' },
empty: {
icon: Flag,
title: 'Race not found',
description: 'The race may have been cancelled or deleted',
action: { label: 'Back to Races', onClick: handleBack }
}
}}
/>
>
{(raceData) => (
<RaceDetailTemplate
viewModel={templateViewModel}
isLoading={false}
error={null}
onBack={handleBack}
onRegister={handleRegister}
onWithdraw={handleWithdraw}
onCancel={handleCancel}
onReopen={handleReopen}
onEndRace={handleEndRace}
onFileProtest={handleFileProtest}
onResultsClick={handleResultsClick}
onStewardingClick={handleStewardingClick}
onLeagueClick={handleLeagueClick}
onDriverClick={handleDriverClick}
currentDriverId={currentDriverId}
isOwnerOrAdmin={isOwnerOrAdmin}
showProtestModal={showProtestModal}
setShowProtestModal={setShowProtestModal}
showEndRaceModal={showEndRaceModal}
setShowEndRaceModal={setShowEndRaceModal}
mutationLoading={{
register: registerMutation.isPending,
withdraw: withdrawMutation.isPending,
cancel: cancelMutation.isPending,
reopen: reopenMutation.isPending,
complete: completeMutation.isPending,
}}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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 (
<RaceResultsTemplate
raceTrack={raceData?.race?.track}
raceScheduledAt={raceData?.race?.scheduledAt}
totalDrivers={raceData?.stats.totalDrivers}
leagueName={raceData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceData?.pointsSystem ?? {}}
fastestLapTime={raceData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
<StateContainer
data={raceData}
isLoading={isLoading}
error={error}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
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) => (
<RaceResultsTemplate
raceTrack={raceResultsData?.race?.track}
raceScheduledAt={raceResultsData?.race?.scheduledAt}
totalDrivers={raceResultsData?.stats.totalDrivers}
leagueName={raceResultsData?.league?.name}
raceSOF={raceSOF}
results={results}
penalties={penalties}
pointsSystem={raceResultsData?.pointsSystem ?? {}}
fastestLapTime={raceResultsData?.fastestLapTime ?? 0}
currentDriverId={currentDriverId}
isAdmin={isAdmin}
isLoading={false}
error={null}
onBack={handleBack}
onImportResults={handleImportResults}
onPenaltyClick={handlePenaltyClick}
importing={importing}
importSuccess={importSuccess}
importError={importError}
showImportForm={showImportForm}
setShowImportForm={setShowImportForm}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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 (
<RaceStewardingTemplate
stewardingData={templateData}
<StateContainer
data={stewardingData}
isLoading={isLoading}
error={error}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
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) => (
<RaceStewardingTemplate
stewardingData={templateData}
isLoading={false}
error={null}
onBack={handleBack}
onReviewProtest={handleReviewProtest}
isAdmin={isAdmin}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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 (
<div className="max-w-7xl mx-auto px-4">
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex flex-col items-center gap-4">
<div className="w-10 h-10 border-2 border-purple-400 border-t-transparent rounded-full animate-spin" />
<p className="text-gray-400">Loading teams...</p>
</div>
</div>
</div>
);
}
return (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teams}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
<StateContainer
data={teams}
isLoading={loading}
error={error}
retry={retry}
config={{
loading: { variant: 'spinner', message: 'Loading teams...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'No teams yet',
description: 'Be the first to create a racing team. Gather drivers and compete together in endurance events.',
action: { label: 'Create Your First Team', onClick: () => setShowCreateForm(true) }
}
}}
>
{(teamsData) => (
<div className="max-w-7xl mx-auto px-4 pb-12">
{/* Hero Section */}
<TeamHeroSection
teams={teamsData}
teamsByLevel={teamsByLevel}
recruitingCount={recruitingCount}
onShowCreateForm={() => setShowCreateForm(true)}
onBrowseTeams={handleBrowseTeams}
onSkillLevelClick={handleSkillLevelClick}
/>
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Search Bar */}
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Why Join Section */}
{!searchQuery && <WhyJoinTeamSection />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Team Leaderboard Preview */}
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teams} onTeamClick={handleTeamClick} />}
{/* Featured Recruiting */}
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
{/* Teams by Skill Level */}
{teams.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
{/* Teams by Skill Level */}
{teamsData.length === 0 ? (
<Card className="text-center py-16">
<div className="max-w-md mx-auto">
<div className="flex h-16 w-16 mx-auto items-center justify-center rounded-2xl bg-purple-500/10 border border-purple-500/20 mb-6">
<Users className="w-8 h-8 text-purple-400" />
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
</div>
<Heading level={2} className="text-2xl mb-3">
No teams yet
</Heading>
<p className="text-gray-400 mb-8">
Be the first to create a racing team. Gather drivers and compete together in endurance events.
</p>
<Button
variant="primary"
onClick={() => setShowCreateForm(true)}
className="flex items-center gap-2 mx-auto bg-purple-600 hover:bg-purple-500"
>
<Sparkles className="w-4 h-4" />
Create Your First Team
</Button>
</div>
</Card>
) : filteredTeams.length === 0 ? (
<Card className="text-center py-12">
<div className="flex flex-col items-center gap-4">
<Search className="w-10 h-10 text-gray-600" />
<p className="text-gray-400">No teams found matching "{searchQuery}"</p>
<Button variant="secondary" onClick={() => setSearchQuery('')}>
Clear search
</Button>
</div>
</Card>
) : (
<div>
{SKILL_LEVELS.map((level, index) => (
<div key={level} id={`level-${level}`} className="scroll-mt-8">
<SkillLevelSection
level={{
id: level,
label: level.charAt(0).toUpperCase() + level.slice(1),
icon: level === 'pro' ? Crown : level === 'advanced' ? Star : level === 'intermediate' ? TrendingUp : Shield,
color: level === 'pro' ? 'text-yellow-400' : level === 'advanced' ? 'text-purple-400' : level === 'intermediate' ? 'text-primary-blue' : 'text-green-400',
bgColor: level === 'pro' ? 'bg-yellow-400/10' : level === 'advanced' ? 'bg-purple-400/10' : level === 'intermediate' ? 'bg-primary-blue/10' : 'bg-green-400/10',
borderColor: level === 'pro' ? 'border-yellow-400/30' : level === 'advanced' ? 'border-purple-400/30' : level === 'intermediate' ? 'border-primary-blue/30' : 'border-green-400/30',
description: level === 'pro' ? 'Elite competition, sponsored teams' : level === 'advanced' ? 'Competitive racing, high consistency' : level === 'intermediate' ? 'Growing skills, regular practice' : 'Learning the basics, friendly environment',
}}
teams={teamsByLevel[level] ?? []}
onTeamClick={handleTeamClick}
defaultExpanded={index === 0}
/>
</div>
))}
)}
</div>
)}
</div>
</StateContainer>
);
}

View File

@@ -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[];

View File

@@ -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<TeamDetailsViewModel | null>(null);
const [memberships, setMemberships] = useState<TeamMemberViewModel[]>([]);
const [activeTab, setActiveTab] = useState<Tab>('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 (
<TeamDetailTemplate
team={team}
memberships={memberships}
activeTab={activeTab}
loading={loading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
<StateContainer
data={teamDetails}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading team details...' },
error: { variant: 'full-screen' },
empty: {
icon: Users,
title: 'Team not found',
description: 'The team may have been deleted or you may not have access',
action: { label: 'Back to Teams', onClick: () => router.push('/teams') }
}
}}
>
{(teamData) => (
<TeamDetailTemplate
team={teamData}
memberships={memberships || []}
activeTab={activeTab}
loading={isLoading}
isAdmin={isAdmin}
onTabChange={setActiveTab}
onUpdate={handleUpdate}
onRemoveMember={handleRemoveMember}
onChangeRole={handleChangeRole}
onGoBack={handleGoBack}
/>
)}
</StateContainer>
);
}
}

View File

@@ -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 (
<div className="text-center py-8 text-gray-400">
Loading schedule...
</div>
);
}
return (
<div>
{/* Filter Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p>
<div className="flex gap-2">
<button
onClick={() => 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})
</button>
<button
onClick={() => 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})
</button>
<button
onClick={() => 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})
</button>
</div>
</div>
<StateContainer
data={schedule}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading schedule...' },
error: { variant: 'inline' },
empty: {
icon: Calendar,
title: 'No races scheduled',
description: 'This league has no races yet',
}
}}
>
{(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 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No {filter} races</p>
{filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
)}
</div>
) : (
<div className="space-y-3">
{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 (
<div
key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
isPast
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{trackLabel}</h3>
{isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
)}
{isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
)}
{isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
Completed
</span>
)}
</div>
<p className="text-sm text-gray-400">{carLabel}</p>
<div className="flex items-center gap-3 mt-2">
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-white font-medium">
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-sm text-gray-400">
{race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
{isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p>
)}
</div>
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
{!isRegistered ? (
<button
onClick={(e) => 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'}
</button>
) : (
<button
onClick={(e) => 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'}
</button>
)}
</div>
)}
</div>
</div>
return (
<div>
{/* Filter Controls */}
<div className="mb-4 flex items-center justify-between">
<p className="text-sm text-gray-400">
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
</p>
<div className="flex gap-2">
<button
onClick={() => 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})
</button>
<button
onClick={() => 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})
</button>
<button
onClick={() => 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})
</button>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Race List */}
{displayRaces.length === 0 ? (
<div className="text-center py-8 text-gray-400">
<p className="mb-2">No {filter} races</p>
{filter === 'upcoming' && (
<p className="text-sm text-gray-500">Schedule your first race to get started</p>
)}
</div>
) : (
<div className="space-y-3">
{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 (
<div
key={race.id}
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
isPast
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
}`}
onClick={() => router.push(`/races/${race.id}`)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<h3 className="text-white font-medium">{trackLabel}</h3>
{isUpcoming && !isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-primary-blue/10 text-primary-blue rounded border border-primary-blue/30">
Upcoming
</span>
)}
{isUpcoming && isRegistered && (
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
Registered
</span>
)}
{isPast && (
<span className="px-2 py-0.5 text-xs font-medium bg-gray-700/50 text-gray-400 rounded border border-gray-600/50">
Completed
</span>
)}
</div>
<p className="text-sm text-gray-400">{carLabel}</p>
<div className="flex items-center gap-3 mt-2">
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="text-right">
<p className="text-white font-medium">
{race.scheduledAt.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})}
</p>
<p className="text-sm text-gray-400">
{race.scheduledAt.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})}
</p>
{isPast && race.status === 'completed' && (
<p className="text-xs text-primary-blue mt-1">View Results </p>
)}
</div>
{/* Registration Actions */}
{isUpcoming && (
<div onClick={(e) => e.stopPropagation()}>
{!isRegistered ? (
<button
onClick={(e) => 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'}
</button>
) : (
<button
onClick={(e) => 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'}
</button>
)}
</div>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
);
}}
</StateContainer>
);
}
}

View File

@@ -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<T>(
options: UseDataFetchingOptions<T>
): UseDataFetchingResult<T> {
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<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFetching, setIsFetching] = useState<boolean>(false);
const [error, setError] = useState<ApiError | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [isStale, setIsStale] = useState<boolean>(true);
// Refs for caching and retry logic
const cacheRef = useRef<{
data: T | null;
timestamp: number;
isStale: boolean;
} | null>(null);
const retryCountRef = useRef<number>(0);
const isMountedRef = useRef<boolean>(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<T | null> => {
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<T>;
}
/**
* useDataFetchingWithPagination Hook
*
* Extension of useDataFetching for paginated data
*/
export function useDataFetchingWithPagination<T>(
options: UseDataFetchingOptions<T[]> & {
initialPage?: number;
pageSize?: number;
}
) {
const {
initialPage = 1,
pageSize = 10,
queryFn,
...restOptions
} = options;
const [page, setPage] = useState<number>(initialPage);
const [hasMore, setHasMore] = useState<boolean>(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<T[]>({
...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<T>(
options: UseDataFetchingOptions<T> & {
refreshInterval?: number; // milliseconds
}
) {
const { refreshInterval, ...restOptions } = options;
const result = useDataFetching<T>(restOptions);
useEffect(() => {
if (!refreshInterval) return;
const interval = setInterval(() => {
if (!result.isLoading && !result.isFetching) {
result.refetch();
}
}, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, result]);
return result;
}

View File

@@ -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: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M20 70 L80 70 L85 50 L80 30 L20 30 L15 50 Z" fill="currentColor" opacity="0.2"/>
<path d="M30 60 L70 60 L75 50 L70 40 L30 40 L25 50 Z" fill="currentColor" opacity="0.4"/>
<circle cx="35" cy="65" r="3" fill="currentColor"/>
<circle cx="65" cy="65" r="3" fill="currentColor"/>
<path d="M50 30 L50 20 M45 25 L50 20 L55 25" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
league: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="35" r="15" fill="currentColor" opacity="0.3"/>
<path d="M35 50 L50 45 L65 50 L65 70 L35 70 Z" fill="currentColor" opacity="0.2"/>
<path d="M40 55 L50 52 L60 55" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
<path d="M40 62 L50 59 L60 62" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
</svg>
),
team: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="35" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="65" cy="35" r="8" fill="currentColor" opacity="0.3"/>
<circle cx="50" cy="55" r="10" fill="currentColor" opacity="0.2"/>
<path d="M35 45 L35 60 M65 45 L65 60 M50 65 L50 80" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
</svg>
),
sponsor: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="25" y="25" width="50" height="50" rx="8" fill="currentColor" opacity="0.2"/>
<path d="M35 50 L45 60 L65 40" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M50 35 L50 65 M40 50 L60 50" stroke="currentColor" strokeWidth="2" opacity="0.5"/>
</svg>
),
driver: () => (
<svg className="w-20 h-20" viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="50" cy="30" r="8" fill="currentColor" opacity="0.3"/>
<path d="M42 38 L58 38 L55 55 L45 55 Z" fill="currentColor" opacity="0.2"/>
<path d="M45 55 L40 70 M55 55 L60 70" stroke="currentColor" strokeWidth="3" strokeLinecap="round"/>
<circle cx="40" cy="72" r="3" fill="currentColor"/>
<circle cx="60" cy="72" r="3" fill="currentColor"/>
</svg>
),
} 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 */}
<div className="flex justify-center mb-4">
{IllustrationComponent ? (
<div className="text-gray-500">
<IllustrationComponent />
</div>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-8 h-8 text-gray-500" />
</div>
)}
</div>
{/* Title */}
<h3 className="text-xl font-semibold text-white mb-2 text-center">
{title}
</h3>
{/* Description */}
{description && (
<p className="text-gray-400 mb-6 text-center leading-relaxed">
{description}
</p>
)}
{/* Action Button */}
{action && (
<div className="flex justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[140px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
</>
);
// Render different variants
switch (variant) {
case 'default':
return (
<div
className={`text-center py-12 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-md mx-auto">
<Content />
</div>
</div>
);
case 'minimal':
return (
<div
className={`text-center py-8 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-sm mx-auto space-y-3">
{/* Minimal icon */}
<div className="flex justify-center">
<Icon className="w-10 h-10 text-gray-600" />
</div>
<h3 className="text-lg font-medium text-gray-300">
{title}
</h3>
{description && (
<p className="text-sm text-gray-500">
{description}
</p>
)}
{action && (
<button
onClick={action.onClick}
className="text-sm text-primary-blue hover:text-blue-400 font-medium mt-2 inline-flex items-center gap-1"
>
{action.label}
{action.icon && <action.icon className="w-3 h-3" />}
</button>
)}
</div>
</div>
);
case 'full-page':
return (
<div
className={`fixed inset-0 bg-deep-graphite flex items-center justify-center p-6 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="max-w-lg w-full text-center">
<div className="mb-6">
{IllustrationComponent ? (
<div className="text-gray-500 flex justify-center">
<IllustrationComponent />
</div>
) : (
<div className="flex justify-center">
<div className="flex h-20 w-20 items-center justify-center rounded-3xl bg-iron-gray/60 border border-charcoal-outline/50">
<Icon className="w-10 h-10 text-gray-500" />
</div>
</div>
)}
</div>
<h2 className="text-3xl font-bold text-white mb-4">
{title}
</h2>
{description && (
<p className="text-gray-400 text-lg mb-8 leading-relaxed">
{description}
</p>
)}
{action && (
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant={action.variant || 'primary'}
onClick={action.onClick}
className="min-w-[160px]"
>
{action.icon && (
<action.icon className="w-4 h-4 mr-2" />
)}
{action.label}
</Button>
</div>
)}
{/* Additional helper text for full-page variant */}
<div className="mt-8 text-sm text-gray-500">
Need help? Contact us at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
);
default:
return null;
}
}
/**
* Convenience component for default empty state
*/
export function DefaultEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="default"
className={className}
illustration={illustration}
/>
);
}
/**
* Convenience component for minimal empty state
*/
export function MinimalEmptyState({ icon, title, description, action, className }: Omit<EmptyStateProps, 'variant'>) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="minimal"
className={className}
/>
);
}
/**
* Convenience component for full-page empty state
*/
export function FullPageEmptyState({ icon, title, description, action, className, illustration }: EmptyStateProps) {
return (
<EmptyState
icon={icon}
title={title}
description={description}
action={action}
variant="full-page"
className={className}
illustration={illustration}
/>
);
}
/**
* Pre-configured empty states for common scenarios
*/
export function NoDataEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Activity}
title="No data available"
description="There is nothing to display here at the moment"
action={onRetry ? { label: 'Refresh', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoResultsEmptyState({ onRetry }: { onRetry?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Search}
title="No results found"
description="Try adjusting your search or filters"
action={onRetry ? { label: 'Clear Filters', onClick: onRetry } : undefined}
variant="default"
/>
);
}
export function NoAccessEmptyState({ onBack }: { onBack?: () => void }) {
return (
<EmptyState
icon={require('lucide-react').Lock}
title="Access denied"
description="You don't have permission to view this content"
action={onBack ? { label: 'Go Back', onClick: onBack } : undefined}
variant="full-page"
/>
);
}

View File

@@ -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 (
<div
className="min-h-screen bg-deep-graphite flex items-center justify-center p-4"
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-500/20 rounded-lg">
<ErrorIcon className="w-6 h-6 text-red-400" />
</div>
<div>
<h1 className="text-xl font-bold text-white">
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
</h1>
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
</div>
</div>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
{/* Technical Details (Development Only) */}
{isDev && (
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
<div className="mt-2 space-y-1">
<div>Type: {error.type}</div>
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
{error.context.retryCount !== undefined && (
<div>Retries: {error.context.retryCount}</div>
)}
{error.context.wasRetry && <div>Was Retry: true</div>}
</div>
</details>
)}
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className={primaryButton}
aria-label={isRetrying ? 'Retrying...' : 'Try again'}
>
{isRetrying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Try Again
</>
)}
</button>
)}
{showNavigation && (
<div className="flex gap-2">
<button
onClick={handleGoBack}
className={`${secondaryButton} flex-1`}
aria-label="Go back to previous page"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</button>
<button
onClick={handleGoHome}
className={`${secondaryButton} flex-1`}
aria-label="Go to home page"
>
<Home className="w-4 h-4" />
Home
</button>
</div>
)}
{/* Custom Actions */}
{actions.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
{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 (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className={`${buttonBase} ${variantClasses} ${action.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={action.label}
>
{action.icon && <action.icon className="w-4 h-4" />}
{action.label}
</button>
);
})}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
If this persists, please contact support at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
aria-label="Email support"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="flex items-start gap-3">
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-red-200 font-medium">{userMessage}</p>
{isDev && (
<p className="text-xs text-red-300/70 mt-1 font-mono">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-3">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-200 p-1 rounded hover:bg-iron-gray/50"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
case 'card':
return (
<div
className={`bg-iron-gray border border-red-500/30 rounded-xl overflow-hidden ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="bg-red-500/10 border-b border-red-500/20 p-4">
<div className="flex items-center gap-2">
<ErrorIcon className="w-5 h-5 text-red-400" />
<h3 className="text-white font-semibold">Error</h3>
</div>
</div>
<div className="p-4 space-y-3">
<p className="text-gray-300 text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 font-mono">
{error.type} | Status: {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex flex-wrap gap-2 pt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50 flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
{isRetrying ? 'Retrying' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
</div>
);
case 'toast':
return (
<div
className={`bg-iron-gray border-l-4 border-red-500 rounded-r-lg shadow-lg p-4 flex items-start gap-3 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 mt-1">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-2.5 py-1 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? '...' : 'Retry'}
</button>
)}
{actions.slice(0, 2).map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-2.5 py-1 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-white p-1 rounded hover:bg-iron-gray/50 flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
);
default:
return null;
}
}
/**
* Convenience component for full-screen error display
*/
export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="full-screen"
{...props}
/>
);
}
/**
* Convenience component for inline error display
*/
export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="inline"
{...props}
/>
);
}
/**
* Convenience component for card error display
*/
export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="card"
{...props}
/>
);
}
/**
* Convenience component for toast error display
*/
export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="toast"
{...props}
/>
);
}

View File

@@ -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 (
<div
className={`flex items-center justify-center min-h-[200px] ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="flex flex-col items-center gap-3">
<div
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
/>
<p className="text-gray-400 text-sm">{message}</p>
</div>
</div>
);
case 'skeleton':
return (
<div
className={`space-y-3 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: skeletonCount }).map((_, index) => (
<div
key={index}
className="w-full bg-iron-gray/40 rounded-lg animate-pulse"
style={{ height: cardHeight }}
/>
))}
</div>
);
case 'full-screen':
return (
<div
className="fixed inset-0 z-50 bg-deep-graphite/90 backdrop-blur-sm flex items-center justify-center p-4"
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="text-center max-w-md">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg font-medium">{message}</p>
<p className="text-gray-400 text-sm">This may take a moment...</p>
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`inline-flex items-center gap-2 ${inlineSize} ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<span className="text-gray-400">{message}</span>
</div>
);
case 'card':
const cardCount = cardConfig?.count || 3;
const cardClassName = cardConfig?.className || '';
return (
<div
className={`grid gap-4 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: cardCount }).map((_, index) => (
<div
key={index}
className={`bg-iron-gray/40 rounded-xl overflow-hidden border border-charcoal-outline/50 ${cardClassName}`}
style={{ height: cardHeight }}
>
<div className="h-full w-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</div>
</div>
))}
</div>
);
default:
return null;
}
}
/**
* Convenience component for full-screen loading
*/
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
return (
<LoadingWrapper
variant="full-screen"
message={message}
className={className}
/>
);
}
/**
* Convenience component for inline loading
*/
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
return (
<LoadingWrapper
variant="inline"
message={message}
size={size}
className={className}
/>
);
}
/**
* Convenience component for skeleton loading
*/
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
return (
<LoadingWrapper
variant="skeleton"
skeletonCount={skeletonCount}
className={className}
/>
);
}
/**
* Convenience component for card loading
*/
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
return (
<LoadingWrapper
variant="card"
cardConfig={cardConfig}
className={className}
/>
);
}

View File

@@ -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
* <StateContainer
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* config={{
* loading: { variant: 'skeleton', message: 'Loading...' },
* error: { variant: 'full-screen' },
* empty: {
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }
* }}
* >
* {(content) => <MyContent data={content} />}
* </StateContainer>
* ```
*/
export function StateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
showEmpty = true,
isEmpty,
}: StateContainerProps<T>) {
// 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 (
<div className={className}>
<LoadingWrapper
variant={loadingConfig.variant || 'spinner'}
message={loadingConfig.message || 'Loading...'}
size={loadingConfig.size || 'md'}
skeletonCount={loadingConfig.skeletonCount}
/>
</div>
);
}
if (error) {
const errorConfig = config?.error || {};
// Custom render
if (config?.customRender?.error) {
return <>{config.customRender.error(error)}</>;
}
return (
<div className={className}>
<ErrorDisplay
error={error}
onRetry={retry}
variant={errorConfig.variant || 'full-screen'}
actions={errorConfig.actions}
showRetry={errorConfig.showRetry}
showNavigation={errorConfig.showNavigation}
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
/>
</div>
);
}
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 (
<div className={className}>
<EmptyState
icon={require('lucide-react').Inbox}
title="No data available"
description="There is nothing to display here"
/>
</div>
);
}
return (
<div className={className}>
<EmptyState
icon={emptyConfig.icon}
title={emptyConfig.title}
description={emptyConfig.description}
action={emptyConfig.action}
variant="default"
/>
</div>
);
}
// 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 (
<div className={className}>
<EmptyState
icon={require('lucide-react').AlertCircle}
title="Unexpected state"
description="No data available but no error or loading state"
/>
</div>
);
}
// 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<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const listConfig: StateContainerConfig<T[]> = {
...config,
empty: emptyConfig || {
icon: require('lucide-react').List,
title: 'No items found',
description: 'This list is currently empty',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={listConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}
/**
* DetailStateContainer - Specialized for detail pages
* Includes back/refresh functionality
*/
export function DetailStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
onBack,
onRefresh,
}: StateContainerProps<T> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
const detailConfig: StateContainerConfig<T> = {
...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 (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={detailConfig}
className={className}
>
{children}
</StateContainer>
);
}
/**
* PageStateContainer - Full page state management
* Wraps content in proper page structure
*/
export function PageStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
title,
description,
}: StateContainerProps<T> & {
title?: string;
description?: string;
}) {
const pageConfig: StateContainerConfig<T> = {
loading: {
variant: 'full-screen',
message: title ? `Loading ${title}...` : 'Loading...',
...config?.loading,
},
error: {
variant: 'full-screen',
...config?.error,
},
empty: config?.empty,
};
if (isLoading) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (error) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
if (config?.empty) {
return (
<div className="min-h-screen bg-deep-graphite py-12">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
}
return (
<div className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-6">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
/**
* GridStateContainer - Specialized for grid layouts
* Handles card-based empty states
*/
export function GridStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const gridConfig: StateContainerConfig<T[]> = {
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 (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={gridConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}

View File

@@ -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) => <div>{JSON.stringify(data)}</div>,
};
expect(stateProps).toBeDefined();
});
});

View File

@@ -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<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
}
/**
* Extended state with metadata for advanced use cases
*/
export interface PageStateWithMeta<T> extends PageState<T> {
isFetching: boolean;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
}
// ============================================================================
// Hook Interfaces
// ============================================================================
/**
* Options for useDataFetching hook
*/
export interface UseDataFetchingOptions<T> {
/** Unique key for caching and invalidation */
queryKey: string[];
/** Function to fetch data */
queryFn: () => Promise<T>;
/** 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<T> {
data: T | null;
isLoading: boolean;
isFetching: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
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<T> {
/** 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<T> {
/** Current data */
data: T | null;
/** Loading state */
isLoading: boolean;
/** Error state */
error: ApiError | null;
/** Retry function */
retry: () => Promise<void>;
/** Child render function */
children: (data: T) => ReactNode;
/** Configuration for all states */
config?: StateContainerConfig<T>;
/** 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<T> extends StateContainerConfig<T> {
retry?: RetryConfig;
notifications?: NotificationConfig;
analytics?: StateAnalytics;
performance?: PerformanceMetrics;
}
// ============================================================================
// Page Template Interfaces
// ============================================================================
/**
* Generic page template props
*/
export interface PageTemplateProps<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
title?: string;
description?: string;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
}
/**
* List page template props
*/
export interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
showSkeleton?: boolean;
skeletonCount?: number;
}
/**
* Detail page template props
*/
export interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
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;

View File

@@ -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<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
}
// Standardized hook signature
interface UseDataFetchingOptions<T> {
queryKey: string[];
queryFn: () => Promise<T>;
enabled?: boolean;
retryOnMount?: boolean;
}
// Standardized return type
interface UseDataFetchingResult<T> {
data: T | null;
isLoading: boolean;
isFetching: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
}
```
### 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
<LoadingWrapper />
// Full-screen with custom message
<LoadingWrapper
variant="full-screen"
message="Loading race details..."
/>
// Skeleton for list
<LoadingWrapper
variant="skeleton"
skeletonCount={5}
/>
// Inline loading
<LoadingWrapper
variant="inline"
size="sm"
message="Saving..."
/>
```
#### 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
<ErrorDisplay
error={error}
onRetry={retry}
variant="full-screen"
/>
// Inline error with custom actions
<ErrorDisplay
error={error}
variant="inline"
actions={[
{ label: 'Contact Support', onClick: () => router.push('/support') }
]}
/>
// Toast-style error
<ErrorDisplay
error={error}
variant="toast"
showNavigation={false}
/>
```
#### 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
<EmptyState
icon={Calendar}
title="No upcoming races"
description="Join a league to see races here"
action={{
label: "Browse Leagues",
onClick: () => router.push('/leagues')
}}
/>
// No data with illustration
<EmptyState
illustration="racing"
title="No results found"
description="Try adjusting your filters"
variant="minimal"
/>
```
### 4. useDataFetching Hook
**Purpose**: Unified data fetching with built-in state management
**Signature**:
```typescript
function useDataFetching<T>(
options: UseDataFetchingOptions<T>
): UseDataFetchingResult<T>
```
**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 <LoadingWrapper variant="full-screen" />;
}
if (error) {
return (
<ErrorDisplay
error={error}
onRetry={retry}
variant="full-screen"
/>
);
}
if (!data) {
return (
<EmptyState
icon={Activity}
title="No dashboard data"
description="Try refreshing the page"
action={{ label: "Refresh", onClick: refetch }}
/>
);
}
return <DashboardContent data={data} />;
}
```
### 5. StateContainer Component
**Purpose**: Combined wrapper that handles all states automatically
**Props Interface**:
```typescript
interface StateContainerProps<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
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 (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted',
action: { label: 'Back to Leagues', onClick: () => router.push('/leagues') }
}
}}
>
{(leagueData) => <LeagueDetailContent league={leagueData} />}
</StateContainer>
);
}
```
## 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<string, unknown>;
/**
* 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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-white">Loading dashboard...</div>
</main>
);
}
if (error || !dashboardData) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-red-400">Failed to load dashboard</div>
</main>
);
}
// ... 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 <LoadingWrapper variant="full-screen" message="Loading dashboard..." />;
}
if (error) {
return <ErrorDisplay error={error} onRetry={retry} variant="full-screen" />;
}
if (!data) {
return (
<EmptyState
icon={Activity}
title="No dashboard data"
description="Try refreshing the page"
action={{ label: "Refresh", onClick: retry }}
/>
);
}
return <DashboardContent data={data} />;
}
```
### Before (League Detail - Manual State)
```typescript
function LeagueDetailInteractive() {
const [viewModel, setViewModel] = useState<LeagueDetailPageViewModel | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <div className="text-center text-gray-400">Loading league...</div>;
}
if (error || !viewModel) {
return <div className="text-center text-warning-amber">{error || 'League not found'}</div>;
}
// ... 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 (
<StateContainer
data={viewModel}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading league...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'League not found',
description: 'The league may have been deleted or you may not have access',
action: { label: 'Back to Leagues', onClick: () => router.push('/leagues') }
}
}}
>
{(leagueData) => <LeagueDetailTemplate viewModel={leagueData} leagueId={leagueId} />}
</StateContainer>
);
}
```
## 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<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
}
// Extended state with metadata
interface PageStateWithMeta<T> extends PageState<T> {
isFetching: boolean;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
}
```
### Hook Interfaces
```typescript
// useDataFetching options
interface UseDataFetchingOptions<T> {
queryKey: string[];
queryFn: () => Promise<T>;
enabled?: boolean;
retryOnMount?: boolean;
cacheTime?: number;
staleTime?: number;
maxRetries?: number;
retryDelay?: number;
onSuccess?: (data: T) => void;
onError?: (error: ApiError) => void;
}
// useDataFetching result
interface UseDataFetchingResult<T> {
data: T | null;
isLoading: boolean;
isFetching: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
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<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
className?: string;
showEmpty?: boolean;
isEmpty?: (data: T) => boolean;
}
interface StateContainerConfig<T> {
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<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
title?: string;
description?: string;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
}
// List page template
interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
showSkeleton?: boolean;
skeletonCount?: number;
}
// Detail page template
interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
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<T> extends StateContainerConfig<T> {
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<T> = (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.

View File

@@ -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 <LoadingWrapper variant="full-screen" />;
if (error) return <ErrorDisplay error={error} onRetry={retry} variant="full-screen" />;
if (!data) return <EmptyState icon={Calendar} title="No data" />;
return <MyContent data={data} />;
}
```
### 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 (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'skeleton', message: 'Loading...' },
error: { variant: 'full-screen' },
empty: {
icon: Trophy,
title: 'No data found',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(content) => <MyContent data={content} />}
</StateContainer>
);
}
```
## 🎨 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 (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-white">Loading dashboard...</div>
</main>
);
}
if (error || !dashboardData) {
return (
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
<div className="text-red-400">Failed to load dashboard</div>
</main>
);
}
return <DashboardContent data={dashboardData} />;
}
```
### After (Standardized)
```typescript
function DashboardPage() {
const { data, isLoading, error, retry } = useDataFetching({
queryKey: ['dashboardOverview'],
queryFn: () => dashboardService.getDashboardOverview(),
});
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={{
loading: { variant: 'full-screen', message: 'Loading dashboard...' },
error: { variant: 'full-screen' },
empty: {
icon: Activity,
title: 'No dashboard data',
description: 'Try refreshing the page',
action: { label: 'Refresh', onClick: retry }
}
}}
>
{(content) => <DashboardContent data={content} />}
</StateContainer>
);
}
```
## 🎯 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`

View File

@@ -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';