Introduces reusable error boundary and suspense timeout components across dashboard pages for better error handling and user feedback. Enhances loading skeletons with subtle progress indicators, animation, and slow-loading warnings. All dynamic imports now include error handling and improved fallback skeletons, and a shared DashboardContentWrapper is added for consistent dashboard content loading experience.
263 lines
8.1 KiB
TypeScript
263 lines
8.1 KiB
TypeScript
"use client";
|
|
|
|
import { Component, ReactNode, useState, useEffect, Suspense } from "react";
|
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
|
import { Button } from "@/components/ui/button";
|
|
import { AlertCircle, RefreshCw } from "lucide-react";
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
|
|
|
// Error Boundary Component
|
|
interface ErrorBoundaryState {
|
|
hasError: boolean;
|
|
error: Error | null;
|
|
}
|
|
|
|
interface ErrorBoundaryProps {
|
|
children: ReactNode;
|
|
fallback?: ReactNode;
|
|
componentName?: string;
|
|
}
|
|
|
|
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
|
private retryCount = 0;
|
|
private maxRetries = 2;
|
|
|
|
constructor(props: ErrorBoundaryProps) {
|
|
super(props);
|
|
this.state = { hasError: false, error: null };
|
|
}
|
|
|
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
|
return { hasError: true, error };
|
|
}
|
|
|
|
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
|
|
console.error(`Error loading ${this.props.componentName || 'component'}:`, error, errorInfo);
|
|
}
|
|
|
|
handleRetry = () => {
|
|
if (this.retryCount < this.maxRetries) {
|
|
this.retryCount++;
|
|
this.setState({ hasError: false, error: null });
|
|
} else {
|
|
window.location.reload();
|
|
}
|
|
};
|
|
|
|
render() {
|
|
if (this.state.hasError) {
|
|
if (this.props.fallback) {
|
|
return this.props.fallback;
|
|
}
|
|
|
|
return (
|
|
<Alert variant="destructive" className="animate-in fade-in duration-300">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Failed to load {this.props.componentName || 'component'}</AlertTitle>
|
|
<AlertDescription className="mt-2">
|
|
<p className="mb-3">
|
|
{this.state.error?.message || 'An unexpected error occurred while loading this component.'}
|
|
</p>
|
|
{this.retryCount < this.maxRetries && (
|
|
<p className="text-xs text-muted-foreground mb-3">
|
|
Retry attempt {this.retryCount + 1} of {this.maxRetries + 1}
|
|
</p>
|
|
)}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={this.handleRetry}
|
|
>
|
|
<RefreshCw className="h-3 w-3 mr-2" />
|
|
{this.retryCount < this.maxRetries ? 'Try Again' : 'Reload Page'}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => window.location.reload()}
|
|
>
|
|
Full Reload
|
|
</Button>
|
|
</div>
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return this.props.children;
|
|
}
|
|
}
|
|
|
|
// Suspense wrapper with timeout
|
|
function SuspenseWithTimeout({
|
|
children,
|
|
fallback,
|
|
timeout = 5000,
|
|
timeoutFallback
|
|
}: {
|
|
children: ReactNode;
|
|
fallback: ReactNode;
|
|
timeout?: number;
|
|
timeoutFallback?: ReactNode;
|
|
}) {
|
|
const [showTimeoutWarning, setShowTimeoutWarning] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
setShowTimeoutWarning(true);
|
|
}, timeout);
|
|
|
|
return () => clearTimeout(timer);
|
|
}, [timeout]);
|
|
|
|
return (
|
|
<Suspense fallback={showTimeoutWarning && timeoutFallback ? timeoutFallback : fallback}>
|
|
{children}
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
// Loading skeleton with timeout warning
|
|
function DashboardContentSkeletonWithWarning() {
|
|
return (
|
|
<div className="space-y-6 animate-in fade-in duration-500 relative">
|
|
<Alert className="mb-4 animate-in fade-in duration-300">
|
|
<AlertCircle className="h-4 w-4" />
|
|
<AlertTitle>Taking longer than expected</AlertTitle>
|
|
<AlertDescription>
|
|
The dashboard is still loading. This may be due to a slow connection. Please wait...
|
|
</AlertDescription>
|
|
</Alert>
|
|
<div className="space-y-6">
|
|
<div>
|
|
<Skeleton className="h-8 w-64 mb-2" />
|
|
<Skeleton className="h-4 w-96 max-w-full" />
|
|
</div>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
|
{[...Array(4)].map((_, i) => (
|
|
<Card key={i}>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-5 w-5 rounded" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-7 w-16 mb-1" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// Import the skeleton from the page
|
|
function DashboardContentSkeleton() {
|
|
return (
|
|
<div
|
|
className="space-y-6 animate-in fade-in duration-500 relative"
|
|
role="status"
|
|
aria-label="Loading dashboard content"
|
|
aria-live="polite"
|
|
>
|
|
{/* Subtle loading indicator */}
|
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-full">
|
|
<div className="h-full bg-primary w-1/3"
|
|
style={{
|
|
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
|
backgroundSize: '200% 100%',
|
|
animation: 'shimmer 2s ease-in-out infinite',
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
{/* Header skeleton */}
|
|
<div>
|
|
<Skeleton className="h-8 w-64 mb-2" />
|
|
<Skeleton className="h-4 w-96 max-w-full" />
|
|
</div>
|
|
|
|
{/* Stats cards skeleton */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4 lg:gap-6">
|
|
{[...Array(4)].map((_, i) => (
|
|
<Card
|
|
key={i}
|
|
className="animate-in fade-in slide-in-from-bottom-4"
|
|
style={{
|
|
animationDelay: `${i * 75}ms`,
|
|
animationDuration: '400ms',
|
|
animationFillMode: 'both',
|
|
}}
|
|
>
|
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
|
<Skeleton className="h-4 w-20" />
|
|
<Skeleton className="h-5 w-5 rounded" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Skeleton className="h-7 w-16 mb-1" />
|
|
<Skeleton className="h-3 w-24" />
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{/* Best selling products skeleton */}
|
|
<Card className="animate-in fade-in slide-in-from-bottom-4"
|
|
style={{
|
|
animationDelay: '300ms',
|
|
animationDuration: '400ms',
|
|
animationFillMode: 'both',
|
|
}}
|
|
>
|
|
<CardHeader>
|
|
<Skeleton className="h-6 w-48" />
|
|
<Skeleton className="h-4 w-72 max-w-full" />
|
|
</CardHeader>
|
|
<CardContent>
|
|
<div className="space-y-4">
|
|
{[...Array(5)].map((_, i) => (
|
|
<div
|
|
key={i}
|
|
className="flex items-center gap-4 animate-in fade-in"
|
|
style={{
|
|
animationDelay: `${400 + i * 50}ms`,
|
|
animationDuration: '300ms',
|
|
animationFillMode: 'both',
|
|
}}
|
|
>
|
|
<Skeleton className="h-12 w-12 rounded-md" />
|
|
<div className="space-y-2">
|
|
<Skeleton className="h-4 w-40" />
|
|
<Skeleton className="h-4 w-20" />
|
|
</div>
|
|
<div className="ml-auto text-right">
|
|
<Skeleton className="h-4 w-16 ml-auto" />
|
|
<Skeleton className="h-4 w-16 ml-auto mt-2" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function DashboardContentWrapper({ children }: { children: ReactNode }) {
|
|
return (
|
|
<ErrorBoundary componentName="Dashboard Content">
|
|
<SuspenseWithTimeout
|
|
fallback={<DashboardContentSkeleton />}
|
|
timeout={5000}
|
|
timeoutFallback={<DashboardContentSkeletonWithWarning />}
|
|
>
|
|
{children}
|
|
</SuspenseWithTimeout>
|
|
</ErrorBoundary>
|
|
);
|
|
}
|
|
|