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 { FriendItem } from '@/components/dashboard/FriendItem';
|
||||||
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
|
import { FeedItemRow } from '@/components/dashboard/FeedItemRow';
|
||||||
|
|
||||||
import { useDashboardOverview } from '@/hooks/useDashboardService';
|
|
||||||
import { getCountryFlag } from '@/lib/utilities/country';
|
import { getCountryFlag } from '@/lib/utilities/country';
|
||||||
import { getGreeting, timeUntil } from '@/lib/utilities/time';
|
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() {
|
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 (
|
||||||
return (
|
<StateContainer
|
||||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
data={dashboardData}
|
||||||
<div className="text-white">Loading dashboard...</div>
|
isLoading={isLoading}
|
||||||
</main>
|
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) {
|
const { totalRaces, wins, podiums, rating, globalRank, consistency } = currentDriver;
|
||||||
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 currentDriver = dashboardData.currentDriver;
|
return (
|
||||||
const nextRace = dashboardData.nextRace;
|
<main className="min-h-screen bg-deep-graphite">
|
||||||
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">
|
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<section className="relative overflow-hidden">
|
<section className="relative overflow-hidden">
|
||||||
{/* Background Pattern */}
|
{/* Background Pattern */}
|
||||||
@@ -327,4 +339,7 @@ export default function DashboardPage() {
|
|||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
}}
|
||||||
|
</StateContainer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,23 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { DriversTemplate } from '@/templates/DriversTemplate';
|
import { DriversTemplate } from '@/templates/DriversTemplate';
|
||||||
import { useDriverLeaderboard } from '@/hooks/useDriverService';
|
|
||||||
import { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
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() {
|
export function DriversInteractive() {
|
||||||
const router = useRouter();
|
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 drivers = viewModel?.drivers || [];
|
||||||
const totalRaces = viewModel?.totalRaces || 0;
|
const totalRaces = viewModel?.totalRaces || 0;
|
||||||
@@ -16,17 +25,35 @@ export function DriversInteractive() {
|
|||||||
const activeCount = viewModel?.activeCount || 0;
|
const activeCount = viewModel?.activeCount || 0;
|
||||||
|
|
||||||
// Transform data for template
|
// Transform data for template
|
||||||
const driverViewModels = drivers.map((driver, index) =>
|
const driverViewModels = drivers.map((driver, index) =>
|
||||||
new DriverLeaderboardItemViewModel(driver, index + 1)
|
new DriverLeaderboardItemViewModel(driver, index + 1)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DriversTemplate
|
<StateContainer
|
||||||
drivers={driverViewModels}
|
data={viewModel}
|
||||||
totalRaces={totalRaces}
|
|
||||||
totalWins={totalWins}
|
|
||||||
activeCount={activeCount}
|
|
||||||
isLoading={loading}
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
import { DriverProfileTemplate } from '@/templates/DriverProfileTemplate';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { DriverProfileViewModel } from '@/lib/view-models/DriverProfileViewModel';
|
|
||||||
import SponsorInsightsCard, { useSponsorMode, MetricBuilders, SlotTemplates } from '@/components/sponsors/SponsorInsightsCard';
|
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 {
|
interface Team {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -24,34 +28,23 @@ export function DriverProfileInteractive() {
|
|||||||
const driverId = params.id as string;
|
const driverId = params.id as string;
|
||||||
const { driverService, teamService } = useServices();
|
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 [activeTab, setActiveTab] = useState<'overview' | 'stats'>('overview');
|
||||||
const [allTeamMemberships, setAllTeamMemberships] = useState<TeamMembershipInfo[]>([]);
|
|
||||||
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||||
|
|
||||||
const isSponsorMode = useSponsorMode();
|
const isSponsorMode = useSponsorMode();
|
||||||
|
|
||||||
useEffect(() => {
|
// Fetch driver profile
|
||||||
loadDriver();
|
const { data: driverProfile, isLoading, error, retry } = useDataFetching({
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
queryKey: ['driverProfile', driverId],
|
||||||
}, [driverId]);
|
queryFn: () => driverService.getDriverProfile(driverId),
|
||||||
|
});
|
||||||
|
|
||||||
const loadDriver = async () => {
|
// Fetch team memberships
|
||||||
try {
|
const { data: allTeamMemberships } = useDataFetching({
|
||||||
// Get driver profile
|
queryKey: ['driverTeamMemberships', driverId],
|
||||||
const profileViewModel = await driverService.getDriverProfile(driverId);
|
queryFn: async () => {
|
||||||
|
if (!driverProfile?.currentDriver) return [];
|
||||||
if (!profileViewModel.currentDriver) {
|
|
||||||
setError('Driver not found');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDriverProfile(profileViewModel);
|
|
||||||
|
|
||||||
// Load team memberships - get all teams and check memberships
|
|
||||||
const allTeams = await teamService.getAllTeams();
|
const allTeams = await teamService.getAllTeams();
|
||||||
const memberships: TeamMembershipInfo[] = [];
|
const memberships: TeamMembershipInfo[] = [];
|
||||||
|
|
||||||
@@ -69,13 +62,10 @@ export function DriverProfileInteractive() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAllTeamMemberships(memberships);
|
return memberships;
|
||||||
} catch (err) {
|
},
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load driver');
|
enabled: !!driverProfile?.currentDriver,
|
||||||
} finally {
|
});
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddFriend = () => {
|
const handleAddFriend = () => {
|
||||||
setFriendRequestSent(true);
|
setFriendRequestSent(true);
|
||||||
@@ -110,23 +100,38 @@ export function DriverProfileInteractive() {
|
|||||||
/>
|
/>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
if (!driverProfile) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DriverProfileTemplate
|
<StateContainer
|
||||||
driverProfile={driverProfile}
|
data={driverProfile}
|
||||||
allTeamMemberships={allTeamMemberships}
|
isLoading={isLoading}
|
||||||
isLoading={loading}
|
|
||||||
error={error}
|
error={error}
|
||||||
onBackClick={handleBackClick}
|
retry={retry}
|
||||||
onAddFriend={handleAddFriend}
|
config={{
|
||||||
friendRequestSent={friendRequestSent}
|
loading: { variant: 'skeleton', message: 'Loading driver profile...' },
|
||||||
activeTab={activeTab}
|
error: { variant: 'full-screen' },
|
||||||
setActiveTab={setActiveTab}
|
empty: {
|
||||||
isSponsorMode={isSponsorMode}
|
icon: Car,
|
||||||
sponsorInsights={sponsorInsights}
|
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';
|
'use client';
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
import LeaderboardsInteractive from './LeaderboardsInteractive';
|
import { useRouter } from 'next/navigation';
|
||||||
|
import LeaderboardsTemplate from '@/templates/LeaderboardsTemplate';
|
||||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
|
|
||||||
// ============================================================================
|
// Shared state components
|
||||||
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
|
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() {
|
export default function LeaderboardsStatic() {
|
||||||
// Create services for server-side data fetching
|
const router = useRouter();
|
||||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
const { driverService, teamService } = useServices();
|
||||||
const driverService = serviceFactory.createDriverService();
|
|
||||||
const teamService = serviceFactory.createTeamService();
|
|
||||||
|
|
||||||
// Fetch data server-side
|
const { data: driverData, isLoading: driversLoading, error: driversError, retry: driversRetry } = useDataFetching({
|
||||||
let drivers: DriverLeaderboardItemViewModel[] = [];
|
queryKey: ['driverLeaderboard'],
|
||||||
let teams: TeamSummaryViewModel[] = [];
|
queryFn: () => driverService.getDriverLeaderboard(),
|
||||||
|
});
|
||||||
|
|
||||||
try {
|
const { data: teams, isLoading: teamsLoading, error: teamsError, retry: teamsRetry } = useDataFetching({
|
||||||
const driversViewModel = await driverService.getDriverLeaderboard();
|
queryKey: ['allTeams'],
|
||||||
drivers = driversViewModel.drivers;
|
queryFn: () => teamService.getAllTeams(),
|
||||||
teams = await teamService.getAllTeams();
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load leaderboard data:', error);
|
|
||||||
drivers = [];
|
|
||||||
teams = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass data to Interactive wrapper which handles client-side interactions
|
const handleDriverClick = (driverId: string) => {
|
||||||
return <LeaderboardsInteractive drivers={drivers} teams={teams} />;
|
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';
|
'use client';
|
||||||
import { getWebsiteApiBaseUrl } from '@/lib/config/apiBaseUrl';
|
|
||||||
import DriverRankingsInteractive from './DriverRankingsInteractive';
|
import { useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import DriverRankingsTemplate from '@/templates/DriverRankingsTemplate';
|
||||||
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
import type { DriverLeaderboardItemViewModel } from '@/lib/view-models/DriverLeaderboardItemViewModel';
|
||||||
|
|
||||||
// ============================================================================
|
// Shared state components
|
||||||
// SERVER COMPONENT - Fetches data and passes to Interactive wrapper
|
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() {
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
// Create services for server-side data fetching
|
type SortBy = 'rank' | 'rating' | 'wins' | 'podiums' | 'winRate';
|
||||||
const serviceFactory = new ServiceFactory(getWebsiteApiBaseUrl());
|
|
||||||
const driverService = serviceFactory.createDriverService();
|
|
||||||
|
|
||||||
// Fetch data server-side
|
export default function DriverRankingsStatic() {
|
||||||
let drivers: DriverLeaderboardItemViewModel[] = [];
|
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 { data: driverData, isLoading, error, retry } = useDataFetching({
|
||||||
const driversViewModel = await driverService.getDriverLeaderboard();
|
queryKey: ['driverLeaderboard'],
|
||||||
drivers = driversViewModel.drivers;
|
queryFn: () => driverService.getDriverLeaderboard(),
|
||||||
} catch (error) {
|
});
|
||||||
console.error('Failed to load driver rankings:', error);
|
|
||||||
drivers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pass data to Interactive wrapper which handles client-side interactions
|
const handleDriverClick = (driverId: string) => {
|
||||||
return <DriverRankingsInteractive drivers={drivers} />;
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
import { LeaguesTemplate } from '@/templates/LeaguesTemplate';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import type { LeagueSummaryViewModel } from '@/lib/view-models/LeagueSummaryViewModel';
|
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() {
|
export default function LeaguesInteractive() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [realLeagues, setRealLeagues] = useState<LeagueSummaryViewModel[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const { leagueService } = useServices();
|
const { leagueService } = useServices();
|
||||||
|
|
||||||
const loadLeagues = useCallback(async () => {
|
const { data: realLeagues = [], isLoading: loading, error, retry } = useDataFetching({
|
||||||
try {
|
queryKey: ['allLeagues'],
|
||||||
const leagues = await leagueService.getAllLeagues();
|
queryFn: () => leagueService.getAllLeagues(),
|
||||||
setRealLeagues(leagues);
|
});
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load leagues:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, [leagueService]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
void loadLeagues();
|
|
||||||
}, [loadLeagues]);
|
|
||||||
|
|
||||||
const handleLeagueClick = (leagueId: string) => {
|
const handleLeagueClick = (leagueId: string) => {
|
||||||
router.push(`/leagues/${leagueId}`);
|
router.push(`/leagues/${leagueId}`);
|
||||||
@@ -37,11 +29,30 @@ export default function LeaguesInteractive() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LeaguesTemplate
|
<StateContainer
|
||||||
leagues={realLeagues}
|
data={realLeagues}
|
||||||
loading={loading}
|
isLoading={loading}
|
||||||
onLeagueClick={handleLeagueClick}
|
error={error}
|
||||||
onCreateLeagueClick={handleCreateLeagueClick}
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
import { LeagueDetailTemplate } from '@/templates/LeagueDetailTemplate';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
import { useServices } from '@/lib/services/ServiceProvider';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
import { useSponsorMode } from '@/components/sponsors/SponsorInsightsCard';
|
||||||
import { LeagueDetailPageViewModel } from '@/lib/view-models/LeagueDetailPageViewModel';
|
|
||||||
import EndRaceModal from '@/components/leagues/EndRaceModal';
|
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() {
|
export default function LeagueDetailInteractive() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -16,39 +20,18 @@ export default function LeagueDetailInteractive() {
|
|||||||
const isSponsor = useSponsorMode();
|
const isSponsor = useSponsorMode();
|
||||||
const { leagueService, leagueMembershipService, raceService } = useServices();
|
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 [endRaceModalRaceId, setEndRaceModalRaceId] = useState<string | null>(null);
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||||
|
|
||||||
const loadLeagueData = async () => {
|
const { data: viewModel, isLoading, error, retry } = useDataFetching({
|
||||||
try {
|
queryKey: ['leagueDetailPage', leagueId],
|
||||||
const viewModelData = await leagueService.getLeagueDetailPageData(leagueId);
|
queryFn: () => 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 handleMembershipChange = () => {
|
const handleMembershipChange = () => {
|
||||||
loadLeagueData();
|
retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEndRaceModalOpen = (raceId: string) => {
|
const handleEndRaceModalOpen = (raceId: string) => {
|
||||||
@@ -68,7 +51,7 @@ export default function LeagueDetailInteractive() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await raceService.completeRace(endRaceModalRaceId);
|
await raceService.completeRace(endRaceModalRaceId);
|
||||||
await loadLeagueData();
|
await retry();
|
||||||
setEndRaceModalRaceId(null);
|
setEndRaceModalRaceId(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
alert(err instanceof Error ? err.message : 'Failed to complete race');
|
||||||
@@ -79,46 +62,51 @@ export default function LeagueDetailInteractive() {
|
|||||||
setEndRaceModalRaceId(null);
|
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 (
|
return (
|
||||||
<>
|
<StateContainer
|
||||||
<LeagueDetailTemplate
|
data={viewModel}
|
||||||
viewModel={viewModel}
|
isLoading={isLoading}
|
||||||
leagueId={leagueId}
|
error={error}
|
||||||
isSponsor={isSponsor}
|
retry={retry}
|
||||||
membership={membership}
|
config={{
|
||||||
currentDriverId={currentDriverId}
|
loading: { variant: 'skeleton', message: 'Loading league...' },
|
||||||
onMembershipChange={handleMembershipChange}
|
error: { variant: 'full-screen' },
|
||||||
onEndRaceModalOpen={handleEndRaceModalOpen}
|
empty: {
|
||||||
onLiveRaceClick={handleLiveRaceClick}
|
icon: Trophy,
|
||||||
onBackToLeagues={handleBackToLeagues}
|
title: 'League not found',
|
||||||
>
|
description: 'The league may have been deleted or you may not have access',
|
||||||
{/* End Race Modal */}
|
action: { label: 'Back to Leagues', onClick: handleBackToLeagues }
|
||||||
{endRaceModalRaceId && viewModel && (() => {
|
}
|
||||||
const race = viewModel.runningRaces.find(r => r.id === endRaceModalRaceId);
|
}}
|
||||||
return race ? (
|
>
|
||||||
<EndRaceModal
|
{(leagueData) => (
|
||||||
raceId={race.id}
|
<>
|
||||||
raceName={race.name}
|
<LeagueDetailTemplate
|
||||||
onConfirm={handleEndRaceConfirm}
|
viewModel={leagueData}
|
||||||
onCancel={handleEndRaceCancel}
|
leagueId={leagueId}
|
||||||
/>
|
isSponsor={isSponsor}
|
||||||
) : null;
|
membership={membership}
|
||||||
})()}
|
currentDriverId={currentDriverId}
|
||||||
</LeagueDetailTemplate>
|
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 { LeagueSettingsViewModel } from '@/lib/view-models/LeagueSettingsViewModel';
|
||||||
import { AlertTriangle, Settings } from 'lucide-react';
|
import { AlertTriangle, Settings } from 'lucide-react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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() {
|
export default function LeagueSettingsPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const leagueId = params.id as string;
|
const leagueId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const { leagueMembershipService, leagueSettingsService } = useServices();
|
const { leagueMembershipService, leagueSettingsService } = useServices();
|
||||||
|
|
||||||
const [settings, setSettings] = useState<LeagueSettingsViewModel | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
useEffect(() => {
|
// Check admin status
|
||||||
async function checkAdmin() {
|
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||||
|
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||||
|
queryFn: async () => {
|
||||||
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
const membership = leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
return 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]);
|
|
||||||
|
|
||||||
|
// 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) => {
|
const handleTransferOwnership = async (newOwnerId: string) => {
|
||||||
try {
|
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) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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 (
|
return (
|
||||||
<div className="space-y-6">
|
<StateContainer
|
||||||
{/* Header */}
|
data={settings}
|
||||||
<div className="flex items-center gap-3">
|
isLoading={settingsLoading}
|
||||||
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-primary-blue/10">
|
error={error}
|
||||||
<Settings className="w-6 h-6 text-primary-blue" />
|
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>
|
||||||
<div>
|
)}
|
||||||
<h1 className="text-2xl font-bold text-white">League Settings</h1>
|
</StateContainer>
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,12 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams } from 'next/navigation';
|
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() {
|
export default function LeagueStewardingPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -30,46 +35,34 @@ export default function LeagueStewardingPage() {
|
|||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const { leagueStewardingService, leagueMembershipService } = useServices();
|
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 [activeTab, setActiveTab] = useState<'pending' | 'history'>('pending');
|
||||||
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
|
const [selectedProtest, setSelectedProtest] = useState<any | null>(null);
|
||||||
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
const [expandedRaces, setExpandedRaces] = useState<Set<string>>(new Set());
|
||||||
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
const [showQuickPenaltyModal, setShowQuickPenaltyModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
// Check admin status
|
||||||
async function checkAdmin() {
|
const { data: isAdmin, isLoading: adminLoading } = useDataFetching({
|
||||||
|
queryKey: ['leagueMembership', leagueId, currentDriverId],
|
||||||
|
queryFn: async () => {
|
||||||
const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
|
const membership = await leagueMembershipService.getMembership(leagueId, currentDriverId);
|
||||||
setIsAdmin(membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false);
|
return membership ? LeagueRoleUtility.isLeagueAdminOrHigherRole(membership.role) : false;
|
||||||
}
|
},
|
||||||
checkAdmin();
|
});
|
||||||
}, [leagueId, currentDriverId, leagueMembershipService]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
// Load stewarding data (only if admin)
|
||||||
async function loadData() {
|
const { data: stewardingData, isLoading: dataLoading, error, retry } = useDataFetching({
|
||||||
setLoading(true);
|
queryKey: ['leagueStewarding', leagueId],
|
||||||
try {
|
queryFn: () => leagueStewardingService.getLeagueStewardingData(leagueId),
|
||||||
const data = await leagueStewardingService.getLeagueStewardingData(leagueId);
|
enabled: !!isAdmin,
|
||||||
setStewardingData(data);
|
onSuccess: (data) => {
|
||||||
|
// Auto-expand races with pending protests
|
||||||
// Auto-expand races with pending protests
|
const racesWithPending = new Set<string>();
|
||||||
const racesWithPending = new Set<string>();
|
data.pendingRaces.forEach(race => {
|
||||||
data.pendingRaces.forEach(race => {
|
racesWithPending.add(race.race.id);
|
||||||
racesWithPending.add(race.race.id);
|
});
|
||||||
});
|
setExpandedRaces(racesWithPending);
|
||||||
setExpandedRaces(racesWithPending);
|
},
|
||||||
} catch (err) {
|
});
|
||||||
console.error('Failed to load data:', err);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isAdmin) {
|
|
||||||
loadData();
|
|
||||||
}
|
|
||||||
}, [leagueId, isAdmin, leagueStewardingService]);
|
|
||||||
|
|
||||||
// Filter races based on active tab
|
// Filter races based on active tab
|
||||||
const filteredRaces = useMemo(() => {
|
const filteredRaces = useMemo(() => {
|
||||||
@@ -109,6 +102,9 @@ export default function LeagueStewardingPage() {
|
|||||||
notes: stewardNotes,
|
notes: stewardNotes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry to refresh data
|
||||||
|
await retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
const handleRejectProtest = async (protestId: string, stewardNotes: string) => {
|
||||||
@@ -118,9 +114,11 @@ export default function LeagueStewardingPage() {
|
|||||||
decision: 'dismiss',
|
decision: 'dismiss',
|
||||||
decisionNotes: stewardNotes,
|
decisionNotes: stewardNotes,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Retry to refresh data
|
||||||
|
await retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const toggleRaceExpanded = (raceId: string) => {
|
const toggleRaceExpanded = (raceId: string) => {
|
||||||
setExpandedRaces(prev => {
|
setExpandedRaces(prev => {
|
||||||
const next = new Set(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) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -166,248 +170,260 @@ export default function LeagueStewardingPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<StateContainer
|
||||||
<Card>
|
data={stewardingData}
|
||||||
<div className="flex items-center justify-between mb-6">
|
isLoading={dataLoading}
|
||||||
<div>
|
error={error}
|
||||||
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
retry={retry}
|
||||||
<p className="text-sm text-gray-400 mt-1">
|
config={{
|
||||||
Quick overview of protests and penalties across all races
|
loading: { variant: 'spinner', message: 'Loading stewarding data...' },
|
||||||
</p>
|
error: { variant: 'full-screen' },
|
||||||
</div>
|
empty: {
|
||||||
</div>
|
icon: Flag,
|
||||||
|
title: 'No stewarding data',
|
||||||
{/* Stats summary */}
|
description: 'There are no protests or penalties to review.',
|
||||||
{!loading && stewardingData && (
|
}
|
||||||
<StewardingStats
|
}}
|
||||||
totalPending={stewardingData.totalPending}
|
>
|
||||||
totalResolved={stewardingData.totalResolved}
|
{(data) => (
|
||||||
totalPenalties={stewardingData.totalPenalties}
|
<div className="space-y-6">
|
||||||
/>
|
<Card>
|
||||||
)}
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<div>
|
||||||
{/* Tab navigation */}
|
<h2 className="text-xl font-semibold text-white">Stewarding</h2>
|
||||||
<div className="border-b border-charcoal-outline mb-6">
|
<p className="text-sm text-gray-400 mt-1">
|
||||||
<div className="flex gap-4">
|
Quick overview of protests and penalties across all races
|
||||||
<button
|
</p>
|
||||||
onClick={() => setActiveTab('pending')}
|
</div>
|
||||||
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" />
|
|
||||||
</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 */}
|
{/* Stats summary */}
|
||||||
{isExpanded && (
|
<StewardingStats
|
||||||
<div className="p-4 space-y-3 bg-deep-graphite/50">
|
totalPending={data.totalPending}
|
||||||
{displayProtests.length === 0 && penalties.length === 0 ? (
|
totalResolved={data.totalResolved}
|
||||||
<p className="text-sm text-gray-400 text-center py-4">No items to display</p>
|
totalPenalties={data.totalPenalties}
|
||||||
) : (
|
/>
|
||||||
<>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{activeTab === 'history' && penalties.map((penalty) => {
|
{/* Tab navigation */}
|
||||||
const driver = stewardingData!.driverMap[penalty.driverId];
|
<div className="border-b border-charcoal-outline mb-6">
|
||||||
return (
|
<div className="flex gap-4">
|
||||||
<div
|
<button
|
||||||
key={penalty.id}
|
onClick={() => setActiveTab('pending')}
|
||||||
className="rounded-lg border border-charcoal-outline bg-iron-gray/30 p-4"
|
className={`pb-3 px-1 font-medium transition-colors ${
|
||||||
>
|
activeTab === 'pending'
|
||||||
<div className="flex items-center gap-3">
|
? 'text-primary-blue border-b-2 border-primary-blue'
|
||||||
<div className="w-8 h-8 rounded-full bg-red-500/20 flex items-center justify-center flex-shrink-0">
|
: 'text-gray-400 hover:text-white'
|
||||||
<Gavel className="w-4 h-4 text-red-400" />
|
}`}
|
||||||
</div>
|
>
|
||||||
<div className="flex-1">
|
Pending Protests
|
||||||
<div className="flex items-center gap-2">
|
{data.totalPending > 0 && (
|
||||||
<span className="font-medium text-white">{driver?.name || 'Unknown'}</span>
|
<span className="ml-2 px-2 py-0.5 text-xs bg-warning-amber/20 text-warning-amber rounded-full">
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-red-500/20 text-red-400 rounded-full">
|
{data.totalPending}
|
||||||
{penalty.type.replace('_', ' ')}
|
</span>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
</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>
|
||||||
);
|
<p className="font-semibold text-lg text-white mb-2">
|
||||||
})}
|
{activeTab === 'pending' ? 'All Clear!' : 'No History Yet'}
|
||||||
</div>
|
</p>
|
||||||
)}
|
<p className="text-sm text-gray-400">
|
||||||
</Card>
|
{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' && (
|
{/* Expanded Content */}
|
||||||
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
{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 && (
|
{activeTab === 'history' && penalties.map((penalty) => {
|
||||||
<ReviewProtestModal
|
const driver = data.driverMap[penalty.driverId];
|
||||||
protest={selectedProtest}
|
return (
|
||||||
onClose={() => setSelectedProtest(null)}
|
<div
|
||||||
onAccept={handleAcceptProtest}
|
key={penalty.id}
|
||||||
onReject={handleRejectProtest}
|
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 && (
|
{activeTab === 'history' && (
|
||||||
<QuickPenaltyModal
|
<PenaltyFAB onClick={() => setShowQuickPenaltyModal(true)} />
|
||||||
drivers={stewardingData.allDrivers}
|
)}
|
||||||
onClose={() => setShowQuickPenaltyModal(false)}
|
|
||||||
adminId={currentDriverId}
|
{selectedProtest && (
|
||||||
races={stewardingData.racesWithData.map(r => ({ id: r.race.id, track: r.race.track, scheduledAt: r.race.scheduledAt }))}
|
<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';
|
} from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
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
|
// Timeline event types
|
||||||
interface TimelineEvent {
|
interface TimelineEvent {
|
||||||
@@ -105,10 +110,6 @@ export default function ProtestReviewPage() {
|
|||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
|
const { leagueStewardingService, protestService, leagueMembershipService } = useServices();
|
||||||
|
|
||||||
const [detail, setDetail] = useState<ProtestDetailViewModel | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
|
|
||||||
// Decision state
|
// Decision state
|
||||||
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
const [showDecisionPanel, setShowDecisionPanel] = useState(false);
|
||||||
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
const [decision, setDecision] = useState<'uphold' | 'dismiss' | null>(null);
|
||||||
@@ -116,6 +117,30 @@ export default function ProtestReviewPage() {
|
|||||||
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
const [penaltyValue, setPenaltyValue] = useState<number>(5);
|
||||||
const [stewardNotes, setStewardNotes] = useState('');
|
const [stewardNotes, setStewardNotes] = useState('');
|
||||||
const [submitting, setSubmitting] = useState(false);
|
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 penaltyTypes = useMemo(() => {
|
||||||
const referenceItems = detail?.penaltyTypes ?? [];
|
const referenceItems = detail?.penaltyTypes ?? [];
|
||||||
@@ -136,45 +161,6 @@ export default function ProtestReviewPage() {
|
|||||||
const selectedPenalty = useMemo(() => {
|
const selectedPenalty = useMemo(() => {
|
||||||
return penaltyTypes.find((p) => p.type === penaltyType);
|
return penaltyTypes.find((p) => p.type === penaltyType);
|
||||||
}, [penaltyTypes, 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 () => {
|
const handleSubmitDecision = async () => {
|
||||||
if (!decision || !stewardNotes.trim() || !detail) return;
|
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) {
|
if (!isAdmin) {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<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 (
|
return (
|
||||||
<div className="min-h-screen">
|
<StateContainer
|
||||||
{/* Compact Header */}
|
data={detail}
|
||||||
<div className="mb-6">
|
isLoading={detailLoading}
|
||||||
<div className="flex items-center gap-3 mb-4">
|
error={error}
|
||||||
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
retry={retry}
|
||||||
<ArrowLeft className="h-5 w-5" />
|
config={{
|
||||||
</Link>
|
loading: { variant: 'spinner', message: 'Loading protest details...' },
|
||||||
<div className="flex-1 flex items-center gap-3">
|
error: { variant: 'full-screen' },
|
||||||
<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" />
|
{(protestDetail) => {
|
||||||
{statusConfig.label}
|
const protest = protestDetail.protest;
|
||||||
</div>
|
const race = protestDetail.race;
|
||||||
{daysSinceFiled > 2 && isPending && (
|
const protestingDriver = protestDetail.protestingDriver;
|
||||||
<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">
|
const accusedDriver = protestDetail.accusedDriver;
|
||||||
<AlertTriangle className="w-3 h-3" />
|
|
||||||
{daysSinceFiled}d old
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main Layout: Feed + Sidebar */}
|
const statusConfig = getStatusConfig(protest.status);
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
const StatusIcon = statusConfig.icon;
|
||||||
{/* Left Sidebar - Incident Info */}
|
const isPending = protest.status === 'pending';
|
||||||
<div className="lg:col-span-3 space-y-4">
|
const daysSinceFiled = Math.floor((Date.now() - new Date(protest.submittedAt).getTime()) / (1000 * 60 * 60 * 24));
|
||||||
{/* Drivers Involved */}
|
|
||||||
<Card className="p-4">
|
return (
|
||||||
<h3 className="text-xs font-semibold text-gray-500 uppercase tracking-wider mb-3">Parties Involved</h3>
|
<div className="min-h-screen">
|
||||||
|
{/* Compact Header */}
|
||||||
<div className="space-y-3">
|
<div className="mb-6">
|
||||||
{/* Protesting Driver */}
|
<div className="flex items-center gap-3 mb-4">
|
||||||
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
<Link href={`/leagues/${leagueId}/stewarding`} className="text-gray-400 hover:text-white transition-colors">
|
||||||
<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">
|
<ArrowLeft className="h-5 w-5" />
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
</Link>
|
||||||
<User className="w-5 h-5 text-blue-400" />
|
<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>
|
||||||
<div className="flex-1 min-w-0">
|
{daysSinceFiled > 2 && isPending && (
|
||||||
<p className="text-xs text-blue-400 font-medium">Protesting</p>
|
<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">
|
||||||
<p className="text-sm font-semibold text-white truncate">{protestingDriver?.name || 'Unknown'}</p>
|
<AlertTriangle className="w-3 h-3" />
|
||||||
</div>
|
{daysSinceFiled}d old
|
||||||
<ExternalLink className="w-3 h-3 text-gray-500" />
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</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>
|
</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>
|
||||||
|
|
||||||
<div className="divide-y divide-charcoal-outline/50">
|
{/* Main Layout: Feed + Sidebar */}
|
||||||
{/* Initial Protest Filing */}
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6">
|
||||||
<div className="p-4">
|
{/* Left Sidebar - Incident Info */}
|
||||||
<div className="flex gap-3">
|
<div className="lg:col-span-3 space-y-4">
|
||||||
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
{/* Drivers Involved */}
|
||||||
<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 && (
|
|
||||||
<Card className="p-4">
|
<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="space-y-3">
|
||||||
<div className="grid grid-cols-2 gap-2 mb-4">
|
{/* Protesting Driver */}
|
||||||
<button
|
<Link href={`/drivers/${protestingDriver?.id || ''}`} className="block">
|
||||||
onClick={() => setDecision('uphold')}
|
<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">
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
<div className="w-10 h-10 rounded-full bg-blue-500/20 flex items-center justify-center flex-shrink-0">
|
||||||
decision === 'uphold'
|
<User className="w-5 h-5 text-blue-400" />
|
||||||
? 'border-red-500 bg-red-500/10'
|
</div>
|
||||||
: 'border-charcoal-outline hover:border-gray-600'
|
<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'}`} />
|
<Video className="w-4 h-4" />
|
||||||
<p className={`text-xs font-medium ${decision === 'uphold' ? 'text-red-400' : 'text-gray-400'}`}>Uphold</p>
|
<span className="text-sm font-medium flex-1">Watch Video</span>
|
||||||
</button>
|
<ExternalLink className="w-3 h-3" />
|
||||||
<button
|
</a>
|
||||||
onClick={() => setDecision('dismiss')}
|
</Card>
|
||||||
className={`p-3 rounded-lg border-2 transition-all ${
|
)}
|
||||||
decision === 'dismiss'
|
|
||||||
? 'border-gray-500 bg-gray-500/10'
|
{/* Quick Stats */}
|
||||||
: 'border-charcoal-outline hover:border-gray-600'
|
<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">
|
||||||
<XCircle className={`w-5 h-5 mx-auto mb-1 ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-500'}`} />
|
<div className="flex justify-between">
|
||||||
<p className={`text-xs font-medium ${decision === 'dismiss' ? 'text-gray-300' : 'text-gray-400'}`}>Dismiss</p>
|
<span className="text-gray-500">Filed</span>
|
||||||
</button>
|
<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>
|
||||||
|
|
||||||
{/* Penalty Selection (if upholding) */}
|
<div className="divide-y divide-charcoal-outline/50">
|
||||||
{decision === 'uphold' && (
|
{/* Initial Protest Filing */}
|
||||||
<div className="mb-4">
|
<div className="p-4">
|
||||||
<label className="text-xs font-medium text-gray-400 mb-2 block">Penalty Type</label>
|
<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">
|
||||||
{penaltyTypes.length === 0 ? (
|
<AlertCircle className="w-5 h-5 text-blue-400" />
|
||||||
<div className="text-xs text-gray-500">
|
|
||||||
Loading penalty types...
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex-1 min-w-0">
|
||||||
<>
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="grid grid-cols-2 gap-1.5">
|
<span className="font-semibold text-white text-sm">{protestingDriver?.name || 'Unknown'}</span>
|
||||||
{penaltyTypes.map((penalty) => {
|
<span className="text-xs text-blue-400 font-medium">filed protest</span>
|
||||||
const Icon = penalty.icon;
|
<span className="text-xs text-gray-500">•</span>
|
||||||
const isSelected = penaltyType === penalty.type;
|
<span className="text-xs text-gray-500">{new Date(protest.submittedAt).toLocaleString()}</span>
|
||||||
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>
|
</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 && (
|
{protest.comment && (
|
||||||
<div className="mt-3">
|
<div className="mt-3 pt-3 border-t border-charcoal-outline/50">
|
||||||
<label className="text-xs font-medium text-gray-400 mb-1 block">
|
<p className="text-xs text-gray-500 mb-1">Additional details:</p>
|
||||||
Value ({selectedPenalty.valueLabel})
|
<p className="text-sm text-gray-400">{protest.comment}</p>
|
||||||
</label>
|
</div>
|
||||||
<input
|
)}
|
||||||
type="number"
|
</div>
|
||||||
value={penaltyValue}
|
</div>
|
||||||
onChange={(e) => setPenaltyValue(Number(e.target.value))}
|
</div>
|
||||||
min="1"
|
</div>
|
||||||
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"
|
|
||||||
/>
|
{/* 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>
|
||||||
)}
|
|
||||||
</>
|
<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>
|
</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>
|
</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>
|
</div>
|
||||||
</Card>
|
|
||||||
)}
|
{/* Right Sidebar - Actions */}
|
||||||
</div>
|
<div className="lg:col-span-3 space-y-4">
|
||||||
</div>
|
{isPending && (
|
||||||
</div>
|
<>
|
||||||
|
{/* 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';
|
'use client';
|
||||||
|
|
||||||
import { Loader2 } from 'lucide-react';
|
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
|
import OnboardingWizard from '@/components/onboarding/OnboardingWizard';
|
||||||
import { useCurrentDriver } from '@/hooks/useDriverService';
|
|
||||||
import { useAuth } from '@/lib/auth/AuthContext';
|
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() {
|
export default function OnboardingPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { session } = useAuth();
|
const { session } = useAuth();
|
||||||
const { data: driver, isLoading } = useCurrentDriver();
|
const { driverService } = useServices();
|
||||||
|
|
||||||
|
// Check if user is logged in
|
||||||
const shouldRedirectToLogin = !session;
|
const shouldRedirectToLogin = !session;
|
||||||
|
|
||||||
|
// Fetch current driver data
|
||||||
|
const { data: driver, isLoading } = useDataFetching({
|
||||||
|
queryKey: ['currentDriver'],
|
||||||
|
queryFn: () => driverService.getCurrentDriver(),
|
||||||
|
enabled: !!session,
|
||||||
|
});
|
||||||
|
|
||||||
const shouldRedirectToDashboard = !isLoading && Boolean(driver);
|
const shouldRedirectToDashboard = !isLoading && Boolean(driver);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -32,8 +44,8 @@ export default function OnboardingPage() {
|
|||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-deep-graphite flex items-center justify-center">
|
<main className="min-h-screen bg-deep-graphite">
|
||||||
<Loader2 className="w-8 h-8 text-primary-blue animate-spin" />
|
<LoadingWrapper variant="full-screen" message="Loading onboarding..." />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -47,4 +59,4 @@ export default function OnboardingPage() {
|
|||||||
<OnboardingWizard />
|
<OnboardingWizard />
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -14,6 +14,11 @@ import type {
|
|||||||
DriverProfileViewModel
|
DriverProfileViewModel
|
||||||
} from '@/lib/view-models/DriverProfileViewModel';
|
} from '@/lib/view-models/DriverProfileViewModel';
|
||||||
import { getMediaUrl } from '@/lib/utilities/media';
|
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 {
|
import {
|
||||||
Activity,
|
Activity,
|
||||||
Award,
|
Award,
|
||||||
@@ -260,34 +265,19 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
const { driverService, mediaService } = useServices();
|
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 effectiveDriverId = useEffectiveDriverId();
|
||||||
const isOwnProfile = true; // This page is always your own profile
|
const isOwnProfile = true; // This page is always your own profile
|
||||||
|
|
||||||
useEffect(() => {
|
// Shared state components
|
||||||
if (!effectiveDriverId) {
|
const { data: profileData, isLoading: loading, error, retry } = useDataFetching({
|
||||||
return;
|
queryKey: ['driverProfile', effectiveDriverId],
|
||||||
}
|
queryFn: () => driverService.getDriverProfile(effectiveDriverId),
|
||||||
|
enabled: !!effectiveDriverId,
|
||||||
|
});
|
||||||
|
|
||||||
const loadData = async () => {
|
const [editMode, setEditMode] = useState(false);
|
||||||
setLoading(true);
|
const [activeTab, setActiveTab] = useState<ProfileTab>(tabParam || 'overview');
|
||||||
try {
|
const [friendRequestSent, setFriendRequestSent] = useState(false);
|
||||||
const profileViewModel = await driverService.getDriverProfile(effectiveDriverId);
|
|
||||||
setProfileData(profileViewModel);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load profile:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
void loadData();
|
|
||||||
}, [effectiveDriverId, driverService]);
|
|
||||||
|
|
||||||
// Update URL when tab changes
|
// Update URL when tab changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -315,7 +305,8 @@ export default function ProfilePage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedProfile = await driverService.updateProfile(updates);
|
const updatedProfile = await driverService.updateProfile(updates);
|
||||||
setProfileData(updatedProfile);
|
// Update local state
|
||||||
|
retry();
|
||||||
setEditMode(false);
|
setEditMode(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update profile:', error);
|
console.error('Failed to update profile:', error);
|
||||||
@@ -327,20 +318,8 @@ export default function ProfilePage() {
|
|||||||
// In production, this would call a use case
|
// In production, this would call a use case
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) {
|
// Show create form if no profile exists
|
||||||
return (
|
if (!loading && !profileData?.currentDriver && !error) {
|
||||||
<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) {
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4">
|
<div className="max-w-4xl mx-auto px-4">
|
||||||
<div className="text-center mb-8">
|
<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
|
// Show edit mode
|
||||||
if (editMode) {
|
if (editMode && profileData?.currentDriver) {
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto px-4 space-y-6">
|
<div className="max-w-4xl mx-auto px-4 space-y-6">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
@@ -390,7 +361,49 @@ export default function ProfilePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* 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">
|
<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 */}
|
{/* Background Pattern */}
|
||||||
@@ -1045,13 +1058,16 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'stats' && !stats && (
|
{activeTab === 'stats' && !stats && (
|
||||||
<Card className="text-center py-12">
|
<Card className="text-center py-12">
|
||||||
<BarChart3 className="w-16 h-16 text-gray-600 mx-auto mb-4" />
|
<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-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>
|
<p className="text-sm text-gray-500">Join a league and complete races to see detailed stats</p>
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}}
|
||||||
|
</StateContainer>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -1,28 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
import { RaceDetailTemplate } from '@/templates/RaceDetailTemplate';
|
||||||
import {
|
import {
|
||||||
useRaceDetail,
|
useRegisterForRace,
|
||||||
useRegisterForRace,
|
useWithdrawFromRace,
|
||||||
useWithdrawFromRace,
|
useCancelRace,
|
||||||
useCancelRace,
|
useCompleteRace,
|
||||||
useCompleteRace,
|
useReopenRace
|
||||||
useReopenRace
|
|
||||||
} from '@/hooks/useRaceService';
|
} from '@/hooks/useRaceService';
|
||||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { LeagueMembershipUtility } from '@/lib/utilities/LeagueMembershipUtility';
|
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() {
|
export function RaceDetailInteractive() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
const { raceService } = useServices();
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data using new hook
|
||||||
const { data: viewModel, isLoading, error } = useRaceDetail(raceId, currentDriverId);
|
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);
|
const { data: membership } = useLeagueMembership(viewModel?.league?.id || '', currentDriverId);
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -37,7 +48,7 @@ export function RaceDetailInteractive() {
|
|||||||
const reopenMutation = useReopenRace();
|
const reopenMutation = useReopenRace();
|
||||||
|
|
||||||
// Determine if user is owner/admin
|
// Determine if user is owner/admin
|
||||||
const isOwnerOrAdmin = membership
|
const isOwnerOrAdmin = membership
|
||||||
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
|
? LeagueMembershipUtility.isOwnerOrAdmin(viewModel?.league?.id || '', currentDriverId)
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
@@ -184,34 +195,53 @@ export function RaceDetailInteractive() {
|
|||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceDetailTemplate
|
<StateContainer
|
||||||
viewModel={templateViewModel}
|
data={viewModel}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
onBack={handleBack}
|
retry={retry}
|
||||||
onRegister={handleRegister}
|
config={{
|
||||||
onWithdraw={handleWithdraw}
|
loading: { variant: 'skeleton', message: 'Loading race details...' },
|
||||||
onCancel={handleCancel}
|
error: { variant: 'full-screen' },
|
||||||
onReopen={handleReopen}
|
empty: {
|
||||||
onEndRace={handleEndRace}
|
icon: Flag,
|
||||||
onFileProtest={handleFileProtest}
|
title: 'Race not found',
|
||||||
onResultsClick={handleResultsClick}
|
description: 'The race may have been cancelled or deleted',
|
||||||
onStewardingClick={handleStewardingClick}
|
action: { label: 'Back to Races', onClick: handleBack }
|
||||||
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,
|
|
||||||
}}
|
}}
|
||||||
/>
|
>
|
||||||
|
{(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 { useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
import { RaceResultsTemplate } from '@/templates/RaceResultsTemplate';
|
||||||
import { useRaceResultsDetail, useRaceWithSOF } from '@/hooks/useRaceService';
|
|
||||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
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() {
|
export function RaceResultsInteractive() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
const { raceResultsService, raceService } = useServices();
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data using new hook
|
||||||
const { data: raceData, isLoading, error } = useRaceResultsDetail(raceId, currentDriverId);
|
const { data: raceData, isLoading, error, retry } = useDataFetching({
|
||||||
const { data: sofData } = useRaceWithSOF(raceId);
|
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);
|
const { data: membership } = useLeagueMembership(raceData?.league?.id || '', currentDriverId);
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -83,28 +99,47 @@ export function RaceResultsInteractive() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceResultsTemplate
|
<StateContainer
|
||||||
raceTrack={raceData?.race?.track}
|
data={raceData}
|
||||||
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}
|
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
onBack={handleBack}
|
retry={retry}
|
||||||
onImportResults={handleImportResults}
|
config={{
|
||||||
onPenaltyClick={handlePenaltyClick}
|
loading: { variant: 'skeleton', message: 'Loading race results...' },
|
||||||
importing={importing}
|
error: { variant: 'full-screen' },
|
||||||
importSuccess={importSuccess}
|
empty: {
|
||||||
importError={importError}
|
icon: Trophy,
|
||||||
showImportForm={showImportForm}
|
title: 'No results available',
|
||||||
setShowImportForm={setShowImportForm}
|
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 { useState } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
import { RaceStewardingTemplate, StewardingTab } from '@/templates/RaceStewardingTemplate';
|
||||||
import { useRaceStewardingData } from '@/hooks/useRaceStewardingService';
|
|
||||||
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
import { useLeagueMembership } from '@/hooks/useLeagueMembershipService';
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { LeagueRoleUtility } from '@/lib/utilities/LeagueRoleUtility';
|
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() {
|
export function RaceStewardingInteractive() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const raceId = params.id as string;
|
const raceId = params.id as string;
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
const { raceStewardingService } = useServices();
|
||||||
|
|
||||||
// Fetch data
|
// Fetch data using new hook
|
||||||
const { data: stewardingData, isLoading, error } = useRaceStewardingData(raceId, currentDriverId);
|
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);
|
const { data: membership } = useLeagueMembership(stewardingData?.league?.id || '', currentDriverId);
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
@@ -47,15 +58,34 @@ export function RaceStewardingInteractive() {
|
|||||||
} : undefined;
|
} : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<RaceStewardingTemplate
|
<StateContainer
|
||||||
stewardingData={templateData}
|
data={stewardingData}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
error={error}
|
error={error}
|
||||||
onBack={handleBack}
|
retry={retry}
|
||||||
onReviewProtest={handleReviewProtest}
|
config={{
|
||||||
isAdmin={isAdmin}
|
loading: { variant: 'skeleton', message: 'Loading stewarding data...' },
|
||||||
activeTab={activeTab}
|
error: { variant: 'full-screen' },
|
||||||
setActiveTab={setActiveTab}
|
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 SkillLevelSection from '@/components/teams/SkillLevelSection';
|
||||||
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
|
import FeaturedRecruiting from '@/components/teams/FeaturedRecruiting';
|
||||||
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
import TeamLeaderboardPreview from '@/components/teams/TeamLeaderboardPreview';
|
||||||
import { useAllTeams } from '@/hooks/useTeamService';
|
|
||||||
import type { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
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';
|
type SkillLevel = 'pro' | 'advanced' | 'intermediate' | 'beginner';
|
||||||
|
|
||||||
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
|
const SKILL_LEVELS: SkillLevel[] = ['pro', 'advanced', 'intermediate', 'beginner'];
|
||||||
|
|
||||||
export default function TeamsInteractive() {
|
export default function TeamsInteractive() {
|
||||||
const router = useRouter();
|
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 [searchQuery, setSearchQuery] = useState('');
|
||||||
const [showCreateForm, setShowCreateForm] = useState(false);
|
const [showCreateForm, setShowCreateForm] = useState(false);
|
||||||
|
|
||||||
@@ -35,17 +45,20 @@ export default function TeamsInteractive() {
|
|||||||
advanced: [],
|
advanced: [],
|
||||||
pro: [],
|
pro: [],
|
||||||
};
|
};
|
||||||
teams.forEach((team) => {
|
if (teams) {
|
||||||
const level = team.performanceLevel || 'intermediate';
|
teams.forEach((team) => {
|
||||||
if (byLevel[level]) {
|
const level = team.performanceLevel || 'intermediate';
|
||||||
byLevel[level].push(team);
|
if (byLevel[level]) {
|
||||||
}
|
byLevel[level].push(team);
|
||||||
});
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
return byLevel;
|
return byLevel;
|
||||||
}, [teams]);
|
}, [teams]);
|
||||||
|
|
||||||
// Select top teams by rating for the preview section
|
// Select top teams by rating for the preview section
|
||||||
const topTeams = useMemo(() => {
|
const topTeams = useMemo(() => {
|
||||||
|
if (!teams) return [];
|
||||||
const sortedByRating = [...teams].sort((a, b) => {
|
const sortedByRating = [...teams].sort((a, b) => {
|
||||||
// Rating is not currently part of TeamSummaryViewModel in this build.
|
// Rating is not currently part of TeamSummaryViewModel in this build.
|
||||||
// Keep deterministic ordering by name until a rating field is exposed.
|
// Keep deterministic ordering by name until a rating field is exposed.
|
||||||
@@ -67,7 +80,7 @@ export default function TeamsInteractive() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Filter by search query
|
// Filter by search query
|
||||||
const filteredTeams = teams.filter((team) => {
|
const filteredTeams = teams ? teams.filter((team) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
const query = searchQuery.toLowerCase();
|
const query = searchQuery.toLowerCase();
|
||||||
return (
|
return (
|
||||||
@@ -76,7 +89,7 @@ export default function TeamsInteractive() {
|
|||||||
(team.region ?? '').toLowerCase().includes(query) ||
|
(team.region ?? '').toLowerCase().includes(query) ||
|
||||||
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
|
(team.languages ?? []).some((lang) => lang.toLowerCase().includes(query))
|
||||||
);
|
);
|
||||||
});
|
}) : [];
|
||||||
|
|
||||||
// Group teams by skill level
|
// Group teams by skill level
|
||||||
const teamsByLevel = useMemo(() => {
|
const teamsByLevel = useMemo(() => {
|
||||||
@@ -97,7 +110,7 @@ export default function TeamsInteractive() {
|
|||||||
);
|
);
|
||||||
}, [groupsBySkillLevel, filteredTeams]);
|
}, [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 handleSkillLevelClick = (level: SkillLevel) => {
|
||||||
const element = document.getElementById(`level-${level}`);
|
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 (
|
return (
|
||||||
<div className="max-w-7xl mx-auto px-4 pb-12">
|
<StateContainer
|
||||||
{/* Hero Section */}
|
data={teams}
|
||||||
<TeamHeroSection
|
isLoading={loading}
|
||||||
teams={teams}
|
error={error}
|
||||||
teamsByLevel={teamsByLevel}
|
retry={retry}
|
||||||
recruitingCount={recruitingCount}
|
config={{
|
||||||
onShowCreateForm={() => setShowCreateForm(true)}
|
loading: { variant: 'spinner', message: 'Loading teams...' },
|
||||||
onBrowseTeams={handleBrowseTeams}
|
error: { variant: 'full-screen' },
|
||||||
onSkillLevelClick={handleSkillLevelClick}
|
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 */}
|
{/* Search Bar */}
|
||||||
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
|
<TeamSearchBar searchQuery={searchQuery} onSearchChange={setSearchQuery} />
|
||||||
|
|
||||||
{/* Why Join Section */}
|
{/* Why Join Section */}
|
||||||
{!searchQuery && <WhyJoinTeamSection />}
|
{!searchQuery && <WhyJoinTeamSection />}
|
||||||
|
|
||||||
{/* Team Leaderboard Preview */}
|
{/* Team Leaderboard Preview */}
|
||||||
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
|
{!searchQuery && <TeamLeaderboardPreview topTeams={topTeams} onTeamClick={handleTeamClick} />}
|
||||||
|
|
||||||
{/* Featured Recruiting */}
|
{/* Featured Recruiting */}
|
||||||
{!searchQuery && <FeaturedRecruiting teams={teams} onTeamClick={handleTeamClick} />}
|
{!searchQuery && <FeaturedRecruiting teams={teamsData} onTeamClick={handleTeamClick} />}
|
||||||
|
|
||||||
{/* Teams by Skill Level */}
|
{/* Teams by Skill Level */}
|
||||||
{teams.length === 0 ? (
|
{teamsData.length === 0 ? (
|
||||||
<Card className="text-center py-16">
|
<Card className="text-center py-16">
|
||||||
<div className="max-w-md mx-auto">
|
<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">
|
<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" />
|
<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>
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</StateContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
import { TeamSummaryViewModel } from '@/lib/view-models/TeamSummaryViewModel';
|
||||||
import TeamsTemplate from '@/templates/TeamsTemplate';
|
import TeamsTemplate from '@/templates/TeamsTemplate';
|
||||||
|
|
||||||
// This is a server component that fetches data server-side
|
// This is a static component that receives data as props
|
||||||
// It will be used by the page.tsx when server-side rendering is needed
|
// It can be used in server components or parent components that fetch data
|
||||||
|
// For client-side data fetching, use TeamsInteractive instead
|
||||||
|
|
||||||
interface TeamsStaticProps {
|
interface TeamsStaticProps {
|
||||||
teams: TeamSummaryViewModel[];
|
teams: TeamSummaryViewModel[];
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useParams, useRouter } from 'next/navigation';
|
import { useParams, useRouter } from 'next/navigation';
|
||||||
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
import TeamDetailTemplate from '@/templates/TeamDetailTemplate';
|
||||||
import { useServices } from '@/lib/services/ServiceProvider';
|
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';
|
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';
|
type Tab = 'overview' | 'roster' | 'standings' | 'admin';
|
||||||
|
|
||||||
export default function TeamDetailInteractive() {
|
export default function TeamDetailInteractive() {
|
||||||
@@ -17,43 +20,37 @@ export default function TeamDetailInteractive() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const currentDriverId = useEffectiveDriverId();
|
const currentDriverId = useEffectiveDriverId();
|
||||||
|
|
||||||
const [team, setTeam] = useState<TeamDetailsViewModel | null>(null);
|
|
||||||
const [memberships, setMemberships] = useState<TeamMemberViewModel[]>([]);
|
|
||||||
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
const [activeTab, setActiveTab] = useState<Tab>('overview');
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [isAdmin, setIsAdmin] = useState(false);
|
|
||||||
|
|
||||||
const loadTeamData = useCallback(async () => {
|
// Fetch team details
|
||||||
setLoading(true);
|
const { data: teamDetails, isLoading: teamLoading, error: teamError, retry: teamRetry } = useDataFetching({
|
||||||
try {
|
queryKey: ['teamDetails', teamId, currentDriverId],
|
||||||
const teamDetails = await teamService.getTeamDetails(teamId, currentDriverId);
|
queryFn: () => teamService.getTeamDetails(teamId, currentDriverId),
|
||||||
|
});
|
||||||
|
|
||||||
if (!teamDetails) {
|
// Fetch team members
|
||||||
setTeam(null);
|
const { data: memberships, isLoading: membersLoading, error: membersError, retry: membersRetry } = useDataFetching({
|
||||||
setMemberships([]);
|
queryKey: ['teamMembers', teamId, currentDriverId],
|
||||||
setIsAdmin(false);
|
queryFn: async () => {
|
||||||
return;
|
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 ||
|
// Determine admin status
|
||||||
teamMembers.some((m) => m.driverId === currentDriverId && (m.role === 'manager' || m.role === 'owner'));
|
const isAdmin = teamDetails?.isOwner ||
|
||||||
|
(memberships || []).some((m: any) => 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]);
|
|
||||||
|
|
||||||
const handleUpdate = () => {
|
const handleUpdate = () => {
|
||||||
loadTeamData();
|
retry();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemoveMember = async (driverId: string) => {
|
const handleRemoveMember = async (driverId: string) => {
|
||||||
@@ -111,17 +108,36 @@ export default function TeamDetailInteractive() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TeamDetailTemplate
|
<StateContainer
|
||||||
team={team}
|
data={teamDetails}
|
||||||
memberships={memberships}
|
isLoading={isLoading}
|
||||||
activeTab={activeTab}
|
error={error}
|
||||||
loading={loading}
|
retry={retry}
|
||||||
isAdmin={isAdmin}
|
config={{
|
||||||
onTabChange={setActiveTab}
|
loading: { variant: 'skeleton', message: 'Loading team details...' },
|
||||||
onUpdate={handleUpdate}
|
error: { variant: 'full-screen' },
|
||||||
onRemoveMember={handleRemoveMember}
|
empty: {
|
||||||
onChangeRole={handleChangeRole}
|
icon: Users,
|
||||||
onGoBack={handleGoBack}
|
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';
|
'use client';
|
||||||
|
|
||||||
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
import { useEffectiveDriverId } from '@/hooks/useEffectiveDriverId';
|
||||||
import { useLeagueSchedule } from '@/hooks/useLeagueService';
|
|
||||||
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
|
import { useRegisterForRace, useWithdrawFromRace } from '@/hooks/useRaceService';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo, useState } from 'react';
|
||||||
import type { LeagueScheduleRaceViewModel } from '@/lib/view-models/LeagueScheduleViewModel';
|
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 {
|
interface LeagueScheduleProps {
|
||||||
leagueId: string;
|
leagueId: string;
|
||||||
}
|
}
|
||||||
@@ -16,8 +22,13 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
const [filter, setFilter] = useState<'all' | 'upcoming' | 'past'>('upcoming');
|
||||||
|
|
||||||
const currentDriverId = useEffectiveDriverId();
|
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 registerMutation = useRegisterForRace();
|
||||||
const withdrawMutation = useWithdrawFromRace();
|
const withdrawMutation = useWithdrawFromRace();
|
||||||
|
|
||||||
@@ -71,160 +82,191 @@ export default function LeagueSchedule({ leagueId }: LeagueScheduleProps) {
|
|||||||
|
|
||||||
const displayRaces = getDisplayRaces();
|
const displayRaces = getDisplayRaces();
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
return (
|
|
||||||
<div className="text-center py-8 text-gray-400">
|
|
||||||
Loading schedule...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<StateContainer
|
||||||
{/* Filter Controls */}
|
data={schedule}
|
||||||
<div className="mb-4 flex items-center justify-between">
|
isLoading={isLoading}
|
||||||
<p className="text-sm text-gray-400">
|
error={error}
|
||||||
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
|
retry={retry}
|
||||||
</p>
|
config={{
|
||||||
<div className="flex gap-2">
|
loading: { variant: 'skeleton', message: 'Loading schedule...' },
|
||||||
<button
|
error: { variant: 'inline' },
|
||||||
onClick={() => setFilter('upcoming')}
|
empty: {
|
||||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
icon: Calendar,
|
||||||
filter === 'upcoming'
|
title: 'No races scheduled',
|
||||||
? 'bg-primary-blue text-white'
|
description: 'This league has no races yet',
|
||||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
}
|
||||||
}`}
|
}}
|
||||||
>
|
>
|
||||||
Upcoming ({upcomingRaces.length})
|
{(scheduleData) => {
|
||||||
</button>
|
const races = scheduleData?.races ?? [];
|
||||||
<button
|
const upcomingRaces = races.filter((race) => race.isUpcoming);
|
||||||
onClick={() => setFilter('past')}
|
const pastRaces = races.filter((race) => race.isPast);
|
||||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
|
||||||
filter === 'past'
|
const getDisplayRaces = () => {
|
||||||
? 'bg-primary-blue text-white'
|
switch (filter) {
|
||||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
case 'upcoming':
|
||||||
}`}
|
return upcomingRaces;
|
||||||
>
|
case 'past':
|
||||||
Past ({pastRaces.length})
|
return [...pastRaces].reverse();
|
||||||
</button>
|
case 'all':
|
||||||
<button
|
return [...upcomingRaces, ...[...pastRaces].reverse()];
|
||||||
onClick={() => setFilter('all')}
|
default:
|
||||||
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
return races;
|
||||||
filter === 'all'
|
}
|
||||||
? 'bg-primary-blue text-white'
|
};
|
||||||
: 'bg-iron-gray text-gray-400 hover:text-white'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
All ({races.length})
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Race List */}
|
const displayRaces = getDisplayRaces();
|
||||||
{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 (
|
return (
|
||||||
<div
|
<div>
|
||||||
key={race.id}
|
{/* Filter Controls */}
|
||||||
className={`p-4 rounded-lg border transition-all duration-200 cursor-pointer hover:scale-[1.02] ${
|
<div className="mb-4 flex items-center justify-between">
|
||||||
isPast
|
<p className="text-sm text-gray-400">
|
||||||
? 'bg-iron-gray/50 border-charcoal-outline/50 opacity-75'
|
{displayRaces.length} {displayRaces.length === 1 ? 'race' : 'races'}
|
||||||
: 'bg-deep-graphite border-charcoal-outline hover:border-primary-blue'
|
</p>
|
||||||
}`}
|
<div className="flex gap-2">
|
||||||
onClick={() => router.push(`/races/${race.id}`)}
|
<button
|
||||||
>
|
onClick={() => setFilter('upcoming')}
|
||||||
<div className="flex items-center justify-between gap-4">
|
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||||
<div className="flex-1">
|
filter === 'upcoming'
|
||||||
<div className="flex items-center gap-2 mb-1 flex-wrap">
|
? 'bg-primary-blue text-white'
|
||||||
<h3 className="text-white font-medium">{trackLabel}</h3>
|
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||||
{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
|
Upcoming ({upcomingRaces.length})
|
||||||
</span>
|
</button>
|
||||||
)}
|
<button
|
||||||
{isUpcoming && isRegistered && (
|
onClick={() => setFilter('past')}
|
||||||
<span className="px-2 py-0.5 text-xs font-medium bg-green-500/10 text-green-400 rounded border border-green-500/30">
|
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||||
✓ Registered
|
filter === 'past'
|
||||||
</span>
|
? 'bg-primary-blue text-white'
|
||||||
)}
|
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||||
{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
|
Past ({pastRaces.length})
|
||||||
</span>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
onClick={() => setFilter('all')}
|
||||||
<p className="text-sm text-gray-400">{carLabel}</p>
|
className={`px-3 py-1 text-sm font-medium rounded transition-colors ${
|
||||||
<div className="flex items-center gap-3 mt-2">
|
filter === 'all'
|
||||||
<p className="text-xs text-gray-500 uppercase">{sessionTypeLabel}</p>
|
? 'bg-primary-blue text-white'
|
||||||
</div>
|
: 'bg-iron-gray text-gray-400 hover:text-white'
|
||||||
</div>
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-3">
|
All ({races.length})
|
||||||
<div className="text-right">
|
</button>
|
||||||
<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>
|
||||||
})}
|
|
||||||
</div>
|
{/* Race List */}
|
||||||
)}
|
{displayRaces.length === 0 ? (
|
||||||
</div>
|
<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 Button from '@/components/ui/Button';
|
||||||
import Card from '@/components/ui/Card';
|
import Card from '@/components/ui/Card';
|
||||||
import { LeagueRoleDisplay } from '@/lib/display-objects/LeagueRoleDisplay';
|
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 { RaceViewModel } from '@/lib/view-models/RaceViewModel';
|
||||||
import type { DriverSummary } from '@/lib/view-models/LeagueDetailPageViewModel';
|
|
||||||
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
|
import { Calendar, ExternalLink, Star, Trophy, Users } from 'lucide-react';
|
||||||
import { ReactNode } from 'react';
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user