error and load state

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

View File

@@ -0,0 +1,374 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { ApiError } from '@/lib/api/base/ApiError';
import { UseDataFetchingOptions, UseDataFetchingResult } from '../types/state.types';
import { delay, retryWithBackoff } from '@/lib/utils/errorUtils';
/**
* useDataFetching Hook
*
* Unified data fetching hook with built-in state management, error handling,
* retry logic, and caching support.
*
* Features:
* - Automatic loading state management
* - Error classification and handling
* - Built-in retry with exponential backoff
* - Cache and stale time support
* - Refetch capability
* - Success/error callbacks
* - Auto-retry on mount for recoverable errors
*
* Usage Example:
* ```typescript
* const { data, isLoading, error, retry, refetch } = useDataFetching({
* queryKey: ['dashboardOverview'],
* queryFn: () => dashboardService.getDashboardOverview(),
* retryOnMount: true,
* cacheTime: 5 * 60 * 1000,
* onSuccess: (data) => console.log('Loaded:', data),
* onError: (error) => console.error('Error:', error),
* });
* ```
*/
export function useDataFetching<T>(
options: UseDataFetchingOptions<T>
): UseDataFetchingResult<T> {
const {
queryKey,
queryFn,
enabled = true,
retryOnMount = false,
cacheTime = 5 * 60 * 1000, // 5 minutes
staleTime = 1 * 60 * 1000, // 1 minute
maxRetries = 3,
retryDelay = 1000,
onSuccess,
onError,
} = options;
// State management
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFetching, setIsFetching] = useState<boolean>(false);
const [error, setError] = useState<ApiError | null>(null);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [isStale, setIsStale] = useState<boolean>(true);
// Refs for caching and retry logic
const cacheRef = useRef<{
data: T | null;
timestamp: number;
isStale: boolean;
} | null>(null);
const retryCountRef = useRef<number>(0);
const isMountedRef = useRef<boolean>(true);
// Check if cache is valid
const isCacheValid = useCallback((): boolean => {
if (!cacheRef.current) return false;
const now = Date.now();
const age = now - cacheRef.current.timestamp;
// Cache is valid if within cacheTime and not stale
return age < cacheTime && !cacheRef.current.isStale;
}, [cacheTime]);
// Update cache
const updateCache = useCallback((newData: T | null, isStale: boolean = false) => {
cacheRef.current = {
data: newData,
timestamp: Date.now(),
isStale,
};
}, []);
// Main fetch function
const fetch = useCallback(async (isRetry: boolean = false): Promise<T | null> => {
if (!enabled) {
return null;
}
// Check cache first
if (!isRetry && isCacheValid() && cacheRef.current && cacheRef.current.data !== null) {
setData(cacheRef.current.data);
setIsLoading(false);
setIsFetching(false);
setError(null);
setLastUpdated(new Date(cacheRef.current.timestamp));
setIsStale(false);
return cacheRef.current.data;
}
setIsFetching(true);
if (!isRetry) {
setIsLoading(true);
}
setError(null);
try {
// Execute the fetch with retry logic
const result = await retryWithBackoff(
async () => {
retryCountRef.current++;
return await queryFn();
},
maxRetries,
retryDelay
);
if (!isMountedRef.current) {
return null;
}
// Success - update state and cache
setData(result);
setLastUpdated(new Date());
setIsStale(false);
updateCache(result, false);
retryCountRef.current = 0; // Reset retry count on success
if (onSuccess) {
onSuccess(result);
}
return result;
} catch (err) {
if (!isMountedRef.current) {
return null;
}
// Convert to ApiError if needed
const apiError = err instanceof ApiError ? err : new ApiError(
err instanceof Error ? err.message : 'An unexpected error occurred',
'UNKNOWN_ERROR',
{
timestamp: new Date().toISOString(),
retryCount: retryCountRef.current,
wasRetry: isRetry,
},
err instanceof Error ? err : undefined
);
setError(apiError);
if (onError) {
onError(apiError);
}
// Mark cache as stale on error
if (cacheRef.current) {
cacheRef.current.isStale = true;
setIsStale(true);
}
throw apiError;
} finally {
setIsLoading(false);
setIsFetching(false);
}
}, [enabled, isCacheValid, queryFn, maxRetries, retryDelay, updateCache, onSuccess, onError]);
// Retry function
const retry = useCallback(async () => {
return await fetch(true);
}, [fetch]);
// Refetch function
const refetch = useCallback(async () => {
// Force bypass cache
cacheRef.current = null;
return await fetch(false);
}, [fetch]);
// Initial fetch and auto-retry on mount
useEffect(() => {
isMountedRef.current = true;
const initialize = async () => {
if (!enabled) return;
// Check if we should auto-retry on mount
const shouldRetryOnMount = retryOnMount && error && error.isRetryable();
if (shouldRetryOnMount) {
try {
await retry();
} catch (err) {
// Error already set by retry
}
} else if (!data && !error) {
// Initial fetch
try {
await fetch(false);
} catch (err) {
// Error already set by fetch
}
}
};
initialize();
return () => {
isMountedRef.current = false;
};
}, [enabled, retryOnMount]); // eslint-disable-line react-hooks/exhaustive-deps
// Effect to check staleness
useEffect(() => {
if (!lastUpdated) return;
const checkStale = () => {
if (!lastUpdated) return;
const now = Date.now();
const age = now - lastUpdated.getTime();
if (age > staleTime) {
setIsStale(true);
if (cacheRef.current) {
cacheRef.current.isStale = true;
}
}
};
const interval = setInterval(checkStale, 30000); // Check every 30 seconds
return () => clearInterval(interval);
}, [lastUpdated, staleTime]);
// Effect to update cache staleness
useEffect(() => {
if (isStale && cacheRef.current) {
cacheRef.current.isStale = true;
}
}, [isStale]);
// Clear cache function (useful for manual cache invalidation)
const clearCache = useCallback(() => {
cacheRef.current = null;
setIsStale(true);
}, []);
// Reset function (clears everything)
const reset = useCallback(() => {
setData(null);
setIsLoading(false);
setIsFetching(false);
setError(null);
setLastUpdated(null);
setIsStale(true);
cacheRef.current = null;
retryCountRef.current = 0;
}, []);
return {
data,
isLoading,
isFetching,
error,
retry,
refetch,
lastUpdated,
isStale,
// Additional utility functions (not part of standard interface but useful)
_clearCache: clearCache,
_reset: reset,
} as UseDataFetchingResult<T>;
}
/**
* useDataFetchingWithPagination Hook
*
* Extension of useDataFetching for paginated data
*/
export function useDataFetchingWithPagination<T>(
options: UseDataFetchingOptions<T[]> & {
initialPage?: number;
pageSize?: number;
}
) {
const {
initialPage = 1,
pageSize = 10,
queryFn,
...restOptions
} = options;
const [page, setPage] = useState<number>(initialPage);
const [hasMore, setHasMore] = useState<boolean>(true);
const paginatedQueryFn = useCallback(async () => {
const result = await queryFn();
// Check if there's more data
if (Array.isArray(result)) {
setHasMore(result.length === pageSize);
}
return result;
}, [queryFn, pageSize]);
const result = useDataFetching<T[]>({
...restOptions,
queryFn: paginatedQueryFn,
});
const loadMore = useCallback(async () => {
if (!hasMore) return;
const nextPage = page + 1;
setPage(nextPage);
// This would need to be integrated with the actual API
// For now, we'll just refetch which may not be ideal
await result.refetch();
}, [page, hasMore, result]);
const resetPagination = useCallback(() => {
setPage(initialPage);
setHasMore(true);
if (result._reset) {
result._reset();
}
}, [initialPage, result]);
return {
...result,
page,
hasMore,
loadMore,
resetPagination,
};
}
/**
* useDataFetchingWithRefresh Hook
*
* Extension with automatic refresh capability
*/
export function useDataFetchingWithRefresh<T>(
options: UseDataFetchingOptions<T> & {
refreshInterval?: number; // milliseconds
}
) {
const { refreshInterval, ...restOptions } = options;
const result = useDataFetching<T>(restOptions);
useEffect(() => {
if (!refreshInterval) return;
const interval = setInterval(() => {
if (!result.isLoading && !result.isFetching) {
result.refetch();
}
}, refreshInterval);
return () => clearInterval(interval);
}, [refreshInterval, result]);
return result;
}

View File

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

View File

@@ -0,0 +1,418 @@
'use client';
import React, { useState } from 'react';
import { useRouter } from 'next/navigation';
import { AlertTriangle, Wifi, RefreshCw, ArrowLeft, Home, X, Info } from 'lucide-react';
import { ErrorDisplayProps } from '../types/state.types';
import Button from '@/components/ui/Button';
/**
* ErrorDisplay Component
*
* Provides standardized error display with 4 variants:
* - full-screen: Full page error with navigation
* - inline: Compact inline error message
* - card: Error card for grid layouts
* - toast: Toast notification style
*
* Features:
* - Auto-detects retryable errors
* - Shows user-friendly messages
* - Provides navigation options
* - Displays technical details in development
* - Fully accessible with ARIA labels and keyboard navigation
*/
export function ErrorDisplay({
error,
onRetry,
variant = 'full-screen',
showRetry: showRetryProp,
showNavigation = true,
actions = [],
className = '',
hideTechnicalDetails = false,
ariaLabel = 'Error notification',
}: ErrorDisplayProps) {
const router = useRouter();
const [isRetrying, setIsRetrying] = useState(false);
// Auto-detect retry capability
const isRetryable = showRetryProp ?? error.isRetryable();
const isConnectivity = error.isConnectivityIssue();
const userMessage = error.getUserMessage();
const isDev = process.env.NODE_ENV === 'development' && !hideTechnicalDetails;
const handleRetry = async () => {
if (onRetry) {
setIsRetrying(true);
try {
await onRetry();
} finally {
setIsRetrying(false);
}
}
};
const handleGoBack = () => {
router.back();
};
const handleGoHome = () => {
router.push('/');
};
const handleClose = () => {
// For toast variant, this would typically close the notification
// Implementation depends on how toast notifications are managed
window.history.back();
};
// Icon based on error type
const ErrorIcon = isConnectivity ? Wifi : AlertTriangle;
// Common button styles
const buttonBase = 'flex items-center justify-center gap-2 px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50';
const primaryButton = `${buttonBase} bg-red-500 hover:bg-red-600 text-white`;
const secondaryButton = `${buttonBase} bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline`;
const ghostButton = `${buttonBase} hover:bg-iron-gray/50 text-gray-300`;
// Render different variants
switch (variant) {
case 'full-screen':
return (
<div
className="min-h-screen bg-deep-graphite flex items-center justify-center p-4"
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="max-w-md w-full bg-iron-gray border border-charcoal-outline rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="bg-red-500/10 border-b border-red-500/20 p-6">
<div className="flex items-center gap-3">
<div className="p-2 bg-red-500/20 rounded-lg">
<ErrorIcon className="w-6 h-6 text-red-400" />
</div>
<div>
<h1 className="text-xl font-bold text-white">
{isConnectivity ? 'Connection Issue' : 'Something Went Wrong'}
</h1>
<p className="text-sm text-gray-400">Error {error.context.statusCode || 'N/A'}</p>
</div>
</div>
</div>
{/* Body */}
<div className="p-6 space-y-4">
<p className="text-gray-300 leading-relaxed">{userMessage}</p>
{/* Technical Details (Development Only) */}
{isDev && (
<details className="text-xs text-gray-500 font-mono bg-deep-graphite p-3 rounded border border-charcoal-outline">
<summary className="cursor-pointer hover:text-gray-300">Technical Details</summary>
<div className="mt-2 space-y-1">
<div>Type: {error.type}</div>
<div>Endpoint: {error.context.endpoint || 'N/A'}</div>
{error.context.statusCode && <div>Status: {error.context.statusCode}</div>}
{error.context.retryCount !== undefined && (
<div>Retries: {error.context.retryCount}</div>
)}
{error.context.wasRetry && <div>Was Retry: true</div>}
</div>
</details>
)}
{/* Action Buttons */}
<div className="flex flex-col gap-2 pt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className={primaryButton}
aria-label={isRetrying ? 'Retrying...' : 'Try again'}
>
{isRetrying ? (
<>
<RefreshCw className="w-4 h-4 animate-spin" />
Retrying...
</>
) : (
<>
<RefreshCw className="w-4 h-4" />
Try Again
</>
)}
</button>
)}
{showNavigation && (
<div className="flex gap-2">
<button
onClick={handleGoBack}
className={`${secondaryButton} flex-1`}
aria-label="Go back to previous page"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</button>
<button
onClick={handleGoHome}
className={`${secondaryButton} flex-1`}
aria-label="Go to home page"
>
<Home className="w-4 h-4" />
Home
</button>
</div>
)}
{/* Custom Actions */}
{actions.length > 0 && (
<div className="flex flex-col gap-2 pt-2 border-t border-charcoal-outline/50">
{actions.map((action, index) => {
const variantClasses = {
primary: 'bg-primary-blue hover:bg-blue-600 text-white',
secondary: 'bg-iron-gray hover:bg-charcoal-outline text-gray-300 border border-charcoal-outline',
danger: 'bg-red-600 hover:bg-red-700 text-white',
ghost: 'hover:bg-iron-gray/50 text-gray-300',
}[action.variant || 'secondary'];
return (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className={`${buttonBase} ${variantClasses} ${action.disabled ? 'opacity-50 cursor-not-allowed' : ''}`}
aria-label={action.label}
>
{action.icon && <action.icon className="w-4 h-4" />}
{action.label}
</button>
);
})}
</div>
)}
</div>
</div>
{/* Footer */}
<div className="bg-iron-gray/50 border-t border-charcoal-outline p-4 text-xs text-gray-500 text-center">
If this persists, please contact support at{' '}
<a
href="mailto:support@gridpilot.com"
className="text-primary-blue hover:underline"
aria-label="Email support"
>
support@gridpilot.com
</a>
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`bg-red-500/10 border border-red-500/20 rounded-lg p-4 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="flex items-start gap-3">
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-red-200 font-medium">{userMessage}</p>
{isDev && (
<p className="text-xs text-red-300/70 mt-1 font-mono">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-3">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? 'Retrying...' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-iron-gray hover:bg-charcoal-outline text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-gray-200 p-1 rounded hover:bg-iron-gray/50"
aria-label="Dismiss error"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
);
case 'card':
return (
<div
className={`bg-iron-gray border border-red-500/30 rounded-xl overflow-hidden ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<div className="bg-red-500/10 border-b border-red-500/20 p-4">
<div className="flex items-center gap-2">
<ErrorIcon className="w-5 h-5 text-red-400" />
<h3 className="text-white font-semibold">Error</h3>
</div>
</div>
<div className="p-4 space-y-3">
<p className="text-gray-300 text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 font-mono">
{error.type} | Status: {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex flex-wrap gap-2 pt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-3 py-1.5 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50 flex items-center gap-1"
>
<RefreshCw className="w-3 h-3" />
{isRetrying ? 'Retrying' : 'Retry'}
</button>
)}
{actions.map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-3 py-1.5 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
</div>
);
case 'toast':
return (
<div
className={`bg-iron-gray border-l-4 border-red-500 rounded-r-lg shadow-lg p-4 flex items-start gap-3 ${className}`}
role="alert"
aria-label={ariaLabel}
aria-live="assertive"
>
<ErrorIcon className="w-5 h-5 text-red-400 mt-0.5 flex-shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-white font-medium text-sm">{userMessage}</p>
{isDev && (
<p className="text-xs text-gray-500 mt-1">
[{error.type}] {error.context.statusCode || 'N/A'}
</p>
)}
<div className="flex gap-2 mt-2">
{isRetryable && onRetry && (
<button
onClick={handleRetry}
disabled={isRetrying}
className="text-xs px-2.5 py-1 bg-red-500 hover:bg-red-600 text-white rounded transition-colors disabled:opacity-50"
>
{isRetrying ? '...' : 'Retry'}
</button>
)}
{actions.slice(0, 2).map((action, index) => (
<button
key={index}
onClick={action.onClick}
disabled={action.disabled}
className="text-xs px-2.5 py-1 bg-charcoal-outline hover:bg-gray-700 text-gray-300 rounded transition-colors disabled:opacity-50"
>
{action.label}
</button>
))}
</div>
</div>
<button
onClick={handleClose}
className="text-gray-400 hover:text-white p-1 rounded hover:bg-iron-gray/50 flex-shrink-0"
aria-label="Dismiss notification"
>
<X className="w-4 h-4" />
</button>
</div>
);
default:
return null;
}
}
/**
* Convenience component for full-screen error display
*/
export function FullScreenError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="full-screen"
{...props}
/>
);
}
/**
* Convenience component for inline error display
*/
export function InlineError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="inline"
{...props}
/>
);
}
/**
* Convenience component for card error display
*/
export function CardError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="card"
{...props}
/>
);
}
/**
* Convenience component for toast error display
*/
export function ToastError({ error, onRetry, ...props }: ErrorDisplayProps) {
return (
<ErrorDisplay
error={error}
onRetry={onRetry}
variant="toast"
{...props}
/>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import React from 'react';
import { LoadingWrapperProps } from '../types/state.types';
/**
* LoadingWrapper Component
*
* Provides consistent loading states with multiple variants:
* - spinner: Traditional loading spinner (default)
* - skeleton: Skeleton screens for better UX
* - full-screen: Centered in viewport
* - inline: Compact inline loading
* - card: Loading card placeholders
*
* All variants are fully accessible with ARIA labels and keyboard support.
*/
export function LoadingWrapper({
variant = 'spinner',
message = 'Loading...',
className = '',
size = 'md',
skeletonCount = 3,
cardConfig,
ariaLabel = 'Loading content',
}: LoadingWrapperProps) {
// Size mappings for different variants
const sizeClasses = {
sm: {
spinner: 'w-4 h-4 border-2',
inline: 'text-xs',
card: 'h-24',
},
md: {
spinner: 'w-10 h-10 border-2',
inline: 'text-sm',
card: 'h-32',
},
lg: {
spinner: 'w-16 h-16 border-4',
inline: 'text-base',
card: 'h-40',
},
};
const spinnerSize = sizeClasses[size].spinner;
const inlineSize = sizeClasses[size].inline;
const cardHeight = cardConfig?.height || sizeClasses[size].card;
// Render different variants
switch (variant) {
case 'spinner':
return (
<div
className={`flex items-center justify-center min-h-[200px] ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="flex flex-col items-center gap-3">
<div
className={`${spinnerSize} border-primary-blue border-t-transparent rounded-full animate-spin`}
/>
<p className="text-gray-400 text-sm">{message}</p>
</div>
</div>
);
case 'skeleton':
return (
<div
className={`space-y-3 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: skeletonCount }).map((_, index) => (
<div
key={index}
className="w-full bg-iron-gray/40 rounded-lg animate-pulse"
style={{ height: cardHeight }}
/>
))}
</div>
);
case 'full-screen':
return (
<div
className="fixed inset-0 z-50 bg-deep-graphite/90 backdrop-blur-sm flex items-center justify-center p-4"
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="text-center max-w-md">
<div className="flex flex-col items-center gap-4">
<div className="w-16 h-16 border-4 border-primary-blue border-t-transparent rounded-full animate-spin" />
<p className="text-white text-lg font-medium">{message}</p>
<p className="text-gray-400 text-sm">This may take a moment...</p>
</div>
</div>
</div>
);
case 'inline':
return (
<div
className={`inline-flex items-center gap-2 ${inlineSize} ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
<div className="w-4 h-4 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
<span className="text-gray-400">{message}</span>
</div>
);
case 'card':
const cardCount = cardConfig?.count || 3;
const cardClassName = cardConfig?.className || '';
return (
<div
className={`grid gap-4 ${className}`}
role="status"
aria-label={ariaLabel}
aria-live="polite"
>
{Array.from({ length: cardCount }).map((_, index) => (
<div
key={index}
className={`bg-iron-gray/40 rounded-xl overflow-hidden border border-charcoal-outline/50 ${cardClassName}`}
style={{ height: cardHeight }}
>
<div className="h-full w-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-primary-blue border-t-transparent rounded-full animate-spin" />
</div>
</div>
))}
</div>
);
default:
return null;
}
}
/**
* Convenience component for full-screen loading
*/
export function FullScreenLoading({ message = 'Loading...', className = '' }: Pick<LoadingWrapperProps, 'message' | 'className'>) {
return (
<LoadingWrapper
variant="full-screen"
message={message}
className={className}
/>
);
}
/**
* Convenience component for inline loading
*/
export function InlineLoading({ message = 'Loading...', size = 'sm', className = '' }: Pick<LoadingWrapperProps, 'message' | 'size' | 'className'>) {
return (
<LoadingWrapper
variant="inline"
message={message}
size={size}
className={className}
/>
);
}
/**
* Convenience component for skeleton loading
*/
export function SkeletonLoading({ skeletonCount = 3, className = '' }: Pick<LoadingWrapperProps, 'skeletonCount' | 'className'>) {
return (
<LoadingWrapper
variant="skeleton"
skeletonCount={skeletonCount}
className={className}
/>
);
}
/**
* Convenience component for card loading
*/
export function CardLoading({ cardConfig, className = '' }: Pick<LoadingWrapperProps, 'cardConfig' | 'className'>) {
return (
<LoadingWrapper
variant="card"
cardConfig={cardConfig}
className={className}
/>
);
}

View File

@@ -0,0 +1,389 @@
'use client';
import React, { ReactNode } from 'react';
import { StateContainerProps, StateContainerConfig } from '../types/state.types';
import { LoadingWrapper } from './LoadingWrapper';
import { ErrorDisplay } from './ErrorDisplay';
import { EmptyState } from './EmptyState';
/**
* StateContainer Component
*
* Combined wrapper that automatically handles all states (loading, error, empty, success)
* based on the provided data and state values.
*
* Features:
* - Automatic state detection and rendering
* - Customizable configuration for each state
* - Custom render functions for advanced use cases
* - Consistent behavior across all pages
*
* Usage Example:
* ```typescript
* <StateContainer
* data={data}
* isLoading={isLoading}
* error={error}
* retry={retry}
* config={{
* loading: { variant: 'skeleton', message: 'Loading...' },
* error: { variant: 'full-screen' },
* empty: {
* icon: Trophy,
* title: 'No data found',
* description: 'Try refreshing the page',
* action: { label: 'Refresh', onClick: retry }
* }
* }}
* >
* {(content) => <MyContent data={content} />}
* </StateContainer>
* ```
*/
export function StateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
showEmpty = true,
isEmpty,
}: StateContainerProps<T>) {
// Determine if data is empty
const isDataEmpty = (data: T | null): boolean => {
if (data === null || data === undefined) return true;
if (isEmpty) return isEmpty(data);
// Default empty checks
if (Array.isArray(data)) return data.length === 0;
if (typeof data === 'object' && data !== null) {
return Object.keys(data).length === 0;
}
return false;
};
// Priority order: Loading > Error > Empty > Success
if (isLoading) {
const loadingConfig = config?.loading || {};
// Custom render
if (config?.customRender?.loading) {
return <>{config.customRender.loading()}</>;
}
return (
<div className={className}>
<LoadingWrapper
variant={loadingConfig.variant || 'spinner'}
message={loadingConfig.message || 'Loading...'}
size={loadingConfig.size || 'md'}
skeletonCount={loadingConfig.skeletonCount}
/>
</div>
);
}
if (error) {
const errorConfig = config?.error || {};
// Custom render
if (config?.customRender?.error) {
return <>{config.customRender.error(error)}</>;
}
return (
<div className={className}>
<ErrorDisplay
error={error}
onRetry={retry}
variant={errorConfig.variant || 'full-screen'}
actions={errorConfig.actions}
showRetry={errorConfig.showRetry}
showNavigation={errorConfig.showNavigation}
hideTechnicalDetails={errorConfig.hideTechnicalDetails}
/>
</div>
);
}
if (showEmpty && isDataEmpty(data)) {
const emptyConfig = config?.empty;
// Custom render
if (config?.customRender?.empty) {
return <>{config.customRender.empty()}</>;
}
// If no empty config provided, show nothing (or could show default empty state)
if (!emptyConfig) {
return (
<div className={className}>
<EmptyState
icon={require('lucide-react').Inbox}
title="No data available"
description="There is nothing to display here"
/>
</div>
);
}
return (
<div className={className}>
<EmptyState
icon={emptyConfig.icon}
title={emptyConfig.title}
description={emptyConfig.description}
action={emptyConfig.action}
variant="default"
/>
</div>
);
}
// Success state - render children with data
if (data === null || data === undefined) {
// This shouldn't happen if we've handled all cases above, but as a fallback
return (
<div className={className}>
<EmptyState
icon={require('lucide-react').AlertCircle}
title="Unexpected state"
description="No data available but no error or loading state"
/>
</div>
);
}
// At this point, data is guaranteed to be non-null
return <>{children(data as T)}</>;
}
/**
* ListStateContainer - Specialized for list data
* Automatically handles empty arrays with appropriate messaging
*/
export function ListStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const listConfig: StateContainerConfig<T[]> = {
...config,
empty: emptyConfig || {
icon: require('lucide-react').List,
title: 'No items found',
description: 'This list is currently empty',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={listConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}
/**
* DetailStateContainer - Specialized for detail pages
* Includes back/refresh functionality
*/
export function DetailStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
onBack,
onRefresh,
}: StateContainerProps<T> & {
onBack?: () => void;
onRefresh?: () => void;
}) {
const detailConfig: StateContainerConfig<T> = {
...config,
error: {
...config?.error,
actions: [
...(config?.error?.actions || []),
...(onBack ? [{ label: 'Go Back', onClick: onBack, variant: 'secondary' as const }] : []),
...(onRefresh ? [{ label: 'Refresh', onClick: onRefresh, variant: 'primary' as const }] : []),
],
showNavigation: config?.error?.showNavigation ?? true,
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={detailConfig}
className={className}
>
{children}
</StateContainer>
);
}
/**
* PageStateContainer - Full page state management
* Wraps content in proper page structure
*/
export function PageStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
title,
description,
}: StateContainerProps<T> & {
title?: string;
description?: string;
}) {
const pageConfig: StateContainerConfig<T> = {
loading: {
variant: 'full-screen',
message: title ? `Loading ${title}...` : 'Loading...',
...config?.loading,
},
error: {
variant: 'full-screen',
...config?.error,
},
empty: config?.empty,
};
if (isLoading) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (error) {
return <StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
if (config?.empty) {
return (
<div className="min-h-screen bg-deep-graphite py-12">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-8">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
}
return (
<div className="min-h-screen bg-deep-graphite py-8">
<div className="max-w-4xl mx-auto px-4">
{title && (
<div className="mb-6">
<h1 className="text-3xl font-bold text-white mb-2">{title}</h1>
{description && (
<p className="text-gray-400">{description}</p>
)}
</div>
)}
<StateContainer data={data} isLoading={isLoading} error={error} retry={retry} config={pageConfig}>
{children}
</StateContainer>
</div>
</div>
);
}
/**
* GridStateContainer - Specialized for grid layouts
* Handles card-based empty states
*/
export function GridStateContainer<T>({
data,
isLoading,
error,
retry,
children,
config,
className = '',
emptyConfig,
}: StateContainerProps<T[]> & {
emptyConfig?: {
icon: any;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
}) {
const gridConfig: StateContainerConfig<T[]> = {
loading: {
variant: 'card',
...config?.loading,
},
...config,
empty: emptyConfig || {
icon: require('lucide-react').Grid,
title: 'No items to display',
description: 'Try adjusting your filters or search',
},
};
return (
<StateContainer
data={data}
isLoading={isLoading}
error={error}
retry={retry}
config={gridConfig}
className={className}
isEmpty={(arr) => !arr || arr.length === 0}
>
{children}
</StateContainer>
);
}

View File

@@ -0,0 +1,59 @@
/**
* Basic test file to verify state components are properly exported and typed
*/
import { LoadingWrapper } from '../LoadingWrapper';
import { ErrorDisplay } from '../ErrorDisplay';
import { EmptyState } from '../EmptyState';
import { StateContainer } from '../StateContainer';
import { useDataFetching } from '../../hooks/useDataFetching';
import { ApiError } from '@/lib/api/base/ApiError';
// This file just verifies that all components can be imported and are properly typed
// Full testing would be done in separate test files
describe('State Components - Basic Type Checking', () => {
it('should export all components', () => {
expect(LoadingWrapper).toBeDefined();
expect(ErrorDisplay).toBeDefined();
expect(EmptyState).toBeDefined();
expect(StateContainer).toBeDefined();
expect(useDataFetching).toBeDefined();
});
it('should have proper component signatures', () => {
// LoadingWrapper accepts props
const loadingProps = {
variant: 'spinner' as const,
message: 'Loading...',
size: 'md' as const,
};
expect(loadingProps).toBeDefined();
// ErrorDisplay accepts ApiError
const mockError = new ApiError(
'Test error',
'NETWORK_ERROR',
{ timestamp: new Date().toISOString() }
);
expect(mockError).toBeDefined();
expect(mockError.isRetryable()).toBe(true);
// EmptyState accepts icon and title
const emptyProps = {
icon: require('lucide-react').Activity,
title: 'No data',
};
expect(emptyProps).toBeDefined();
// StateContainer accepts data and state
const stateProps = {
data: null,
isLoading: false,
error: null,
retry: async () => {},
children: (data: any) => <div>{JSON.stringify(data)}</div>,
};
expect(stateProps).toBeDefined();
});
});

View File

@@ -0,0 +1,386 @@
/**
* TypeScript Interfaces for State Management Components
*
* Provides comprehensive type definitions for loading, error, and empty states
* across the GridPilot website application.
*/
import { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
import { ApiError } from '@/lib/api/base/ApiError';
// ============================================================================
// Core State Interfaces
// ============================================================================
/**
* Basic state for any data fetching operation
*/
export interface PageState<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
}
/**
* Extended state with metadata for advanced use cases
*/
export interface PageStateWithMeta<T> extends PageState<T> {
isFetching: boolean;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
}
// ============================================================================
// Hook Interfaces
// ============================================================================
/**
* Options for useDataFetching hook
*/
export interface UseDataFetchingOptions<T> {
/** Unique key for caching and invalidation */
queryKey: string[];
/** Function to fetch data */
queryFn: () => Promise<T>;
/** Enable/disable the query */
enabled?: boolean;
/** Auto-retry on mount for recoverable errors */
retryOnMount?: boolean;
/** Cache time in milliseconds */
cacheTime?: number;
/** Stale time in milliseconds */
staleTime?: number;
/** Maximum retry attempts */
maxRetries?: number;
/** Delay between retries in milliseconds */
retryDelay?: number;
/** Success callback */
onSuccess?: (data: T) => void;
/** Error callback */
onError?: (error: ApiError) => void;
}
/**
* Result from useDataFetching hook
*/
export interface UseDataFetchingResult<T> {
data: T | null;
isLoading: boolean;
isFetching: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
lastUpdated: Date | null;
isStale: boolean;
// Internal methods (not part of public API but needed for extensions)
_clearCache?: () => void;
_reset?: () => void;
}
// ============================================================================
// LoadingWrapper Component
// ============================================================================
export type LoadingVariant = 'spinner' | 'skeleton' | 'full-screen' | 'inline' | 'card';
export type LoadingSize = 'sm' | 'md' | 'lg';
export interface LoadingWrapperProps {
/** Visual variant of loading state */
variant?: LoadingVariant;
/** Custom message to display */
message?: string;
/** Additional CSS classes */
className?: string;
/** Size of loading indicator */
size?: LoadingSize;
/** For skeleton variant - number of skeleton items to show */
skeletonCount?: number;
/** For card variant - card layout configuration */
cardConfig?: {
height?: number;
count?: number;
className?: string;
};
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// ErrorDisplay Component
// ============================================================================
export type ErrorVariant = 'full-screen' | 'inline' | 'card' | 'toast';
export interface ErrorAction {
/** Button label */
label: string;
/** Click handler */
onClick: () => void;
/** Visual variant */
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
/** Optional icon */
icon?: LucideIcon;
/** Disabled state */
disabled?: boolean;
}
export interface ErrorDisplayProps {
/** The error to display */
error: ApiError;
/** Retry callback */
onRetry?: () => void;
/** Visual variant */
variant?: ErrorVariant;
/** Show retry button (auto-detected from error.isRetryable()) */
showRetry?: boolean;
/** Show navigation buttons */
showNavigation?: boolean;
/** Additional custom actions */
actions?: ErrorAction[];
/** Additional CSS classes */
className?: string;
/** Hide technical details in production */
hideTechnicalDetails?: boolean;
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// EmptyState Component
// ============================================================================
export type EmptyVariant = 'default' | 'minimal' | 'full-page';
export type EmptyIllustration = 'racing' | 'league' | 'team' | 'sponsor' | 'driver';
export interface EmptyStateProps {
/** Icon to display */
icon: LucideIcon;
/** Title text */
title: string;
/** Description text */
description?: string;
/** Primary action */
action?: {
label: string;
onClick: () => void;
icon?: LucideIcon;
variant?: 'primary' | 'secondary';
};
/** Visual variant */
variant?: EmptyVariant;
/** Additional CSS classes */
className?: string;
/** Illustration instead of icon */
illustration?: EmptyIllustration;
/** ARIA label for accessibility */
ariaLabel?: string;
}
// ============================================================================
// StateContainer Component
// ============================================================================
export interface StateContainerConfig<T> {
/** Loading state configuration */
loading?: {
variant?: LoadingVariant;
message?: string;
size?: LoadingSize;
skeletonCount?: number;
};
/** Error state configuration */
error?: {
variant?: ErrorVariant;
actions?: ErrorAction[];
showRetry?: boolean;
showNavigation?: boolean;
hideTechnicalDetails?: boolean;
};
/** Empty state configuration */
empty?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
/** Custom render functions for advanced use cases */
customRender?: {
loading?: () => ReactNode;
error?: (error: ApiError) => ReactNode;
empty?: () => ReactNode;
};
}
export interface StateContainerProps<T> {
/** Current data */
data: T | null;
/** Loading state */
isLoading: boolean;
/** Error state */
error: ApiError | null;
/** Retry function */
retry: () => Promise<void>;
/** Child render function */
children: (data: T) => ReactNode;
/** Configuration for all states */
config?: StateContainerConfig<T>;
/** Additional CSS classes */
className?: string;
/** Whether to show empty state (default: true) */
showEmpty?: boolean;
/** Custom function to determine if data is empty */
isEmpty?: (data: T) => boolean;
}
// ============================================================================
// Retry Configuration
// ============================================================================
export interface RetryConfig {
/** Maximum retry attempts */
maxAttempts?: number;
/** Base delay in milliseconds */
baseDelay?: number;
/** Backoff multiplier */
backoffMultiplier?: number;
/** Auto-retry on mount */
retryOnMount?: boolean;
}
// ============================================================================
// Notification Configuration
// ============================================================================
export interface NotificationConfig {
/** Show toast on success */
showToastOnSuccess?: boolean;
/** Show toast on error */
showToastOnError?: boolean;
/** Custom success message */
successMessage?: string;
/** Custom error message */
errorMessage?: string;
/** Auto-dismiss delay in milliseconds */
autoDismissDelay?: number;
}
// ============================================================================
// Analytics Configuration
// ============================================================================
export interface StateAnalytics {
/** Called when state changes */
onStateChange?: (from: string, to: string, data?: unknown) => void;
/** Called on error */
onError?: (error: ApiError, context: string) => void;
/** Called on retry */
onRetry?: (attempt: number, maxAttempts: number) => void;
}
// ============================================================================
// Performance Metrics
// ============================================================================
export interface PerformanceMetrics {
/** Time to first render in milliseconds */
timeToFirstRender?: number;
/** Time to data load in milliseconds */
timeToDataLoad?: number;
/** Number of retry attempts */
retryCount?: number;
/** Whether cache was hit */
cacheHit?: boolean;
}
// ============================================================================
// Advanced Configuration
// ============================================================================
export interface AdvancedStateConfig<T> extends StateContainerConfig<T> {
retry?: RetryConfig;
notifications?: NotificationConfig;
analytics?: StateAnalytics;
performance?: PerformanceMetrics;
}
// ============================================================================
// Page Template Interfaces
// ============================================================================
/**
* Generic page template props
*/
export interface PageTemplateProps<T> {
data: T | null;
isLoading: boolean;
error: ApiError | null;
retry: () => Promise<void>;
refetch: () => Promise<void>;
title?: string;
description?: string;
children: (data: T) => ReactNode;
config?: StateContainerConfig<T>;
}
/**
* List page template props
*/
export interface ListPageTemplateProps<T> extends PageTemplateProps<T[]> {
emptyConfig?: {
icon: LucideIcon;
title: string;
description?: string;
action?: {
label: string;
onClick: () => void;
};
};
showSkeleton?: boolean;
skeletonCount?: number;
}
/**
* Detail page template props
*/
export interface DetailPageTemplateProps<T> extends PageTemplateProps<T> {
onBack?: () => void;
onRefresh?: () => void;
}
// ============================================================================
// Default Configuration
// ============================================================================
export const DEFAULT_CONFIG = {
loading: {
variant: 'spinner' as LoadingVariant,
message: 'Loading...',
size: 'md' as LoadingSize,
},
error: {
variant: 'full-screen' as ErrorVariant,
showRetry: true,
showNavigation: true,
},
empty: {
title: 'No data available',
description: 'There is nothing to display here',
},
retry: {
maxAttempts: 3,
baseDelay: 1000,
backoffMultiplier: 2,
retryOnMount: true,
},
notifications: {
showToastOnSuccess: false,
showToastOnError: true,
autoDismissDelay: 5000,
},
} as const;