error and load state
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
374
apps/website/components/shared/hooks/useDataFetching.ts
Normal file
374
apps/website/components/shared/hooks/useDataFetching.ts
Normal 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;
|
||||
}
|
||||
326
apps/website/components/shared/state/EmptyState.tsx
Normal file
326
apps/website/components/shared/state/EmptyState.tsx
Normal 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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
418
apps/website/components/shared/state/ErrorDisplay.tsx
Normal file
418
apps/website/components/shared/state/ErrorDisplay.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
199
apps/website/components/shared/state/LoadingWrapper.tsx
Normal file
199
apps/website/components/shared/state/LoadingWrapper.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
389
apps/website/components/shared/state/StateContainer.tsx
Normal file
389
apps/website/components/shared/state/StateContainer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
386
apps/website/components/shared/types/state.types.ts
Normal file
386
apps/website/components/shared/types/state.types.ts
Normal 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;
|
||||
943
apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md
Normal file
943
apps/website/docs/STREAMLINED_STATE_HANDLING_DESIGN.md
Normal 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.
|
||||
264
apps/website/docs/STREAMLINED_STATE_HANDLING_SUMMARY.md
Normal file
264
apps/website/docs/STREAMLINED_STATE_HANDLING_SUMMARY.md
Normal 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`
|
||||
@@ -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';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user