Add robust error boundaries and improved skeletons to dashboard
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.
This commit is contained in:
@@ -1,51 +1,335 @@
|
||||
"use client";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
import React, { Suspense, lazy, useState } from "react";
|
||||
import React, { Suspense, lazy, useState, useEffect, Component, ReactNode } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import Link from "next/link";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
|
||||
// Lazy load admin components
|
||||
const AdminAnalytics = lazy(() => import("@/components/admin/AdminAnalytics"));
|
||||
const InviteVendorCard = lazy(() => import("@/components/admin/InviteVendorCard"));
|
||||
const BanUserCard = lazy(() => import("@/components/admin/BanUserCard"));
|
||||
const InvitationsListCard = lazy(() => import("@/components/admin/InvitationsListCard"));
|
||||
const VendorsCard = lazy(() => import("@/components/admin/VendorsCard"));
|
||||
// Error Boundary Component
|
||||
interface ErrorBoundaryState {
|
||||
hasError: boolean;
|
||||
error: Error | null;
|
||||
}
|
||||
|
||||
// Loading skeleton for admin components
|
||||
function AdminComponentSkeleton() {
|
||||
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);
|
||||
|
||||
// Log to error tracking service if available
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('event', 'exception', {
|
||||
description: `Failed to load ${this.props.componentName || 'component'}: ${error.message}`,
|
||||
fatal: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
handleRetry = () => {
|
||||
if (this.retryCount < this.maxRetries) {
|
||||
this.retryCount++;
|
||||
this.setState({ hasError: false, error: null });
|
||||
// Force a re-render by updating the key or reloading the component
|
||||
// The lazy import will be retried when the component re-mounts
|
||||
} else {
|
||||
// After max retries, suggest a full page reload
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Lazy load admin components with error handling
|
||||
const AdminAnalytics = lazy(() =>
|
||||
import("@/components/admin/AdminAnalytics").catch((err) => {
|
||||
console.error("Failed to load AdminAnalytics:", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
const InviteVendorCard = lazy(() =>
|
||||
import("@/components/admin/InviteVendorCard").catch((err) => {
|
||||
console.error("Failed to load InviteVendorCard:", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
const BanUserCard = lazy(() =>
|
||||
import("@/components/admin/BanUserCard").catch((err) => {
|
||||
console.error("Failed to load BanUserCard:", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
const InvitationsListCard = lazy(() =>
|
||||
import("@/components/admin/InvitationsListCard").catch((err) => {
|
||||
console.error("Failed to load InvitationsListCard:", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
const VendorsCard = lazy(() =>
|
||||
import("@/components/admin/VendorsCard").catch((err) => {
|
||||
console.error("Failed to load VendorsCard:", err);
|
||||
throw err;
|
||||
})
|
||||
);
|
||||
|
||||
// Loading skeleton with timeout warning
|
||||
function AdminComponentSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-48" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-4 w-1/2" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div
|
||||
className="space-y-6 animate-in fade-in duration-500 relative"
|
||||
role="status"
|
||||
aria-label="Loading analytics dashboard"
|
||||
aria-live="polite"
|
||||
>
|
||||
{showSlowWarning && (
|
||||
<Alert className="mb-4 animate-in fade-in duration-300">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Taking longer than expected</AlertTitle>
|
||||
<AlertDescription>
|
||||
The component is still loading. This may be due to a slow connection. Please wait...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
{/* 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 animate-[shimmer_2s_ease-in-out_infinite]"
|
||||
style={{
|
||||
background: 'linear-gradient(90deg, transparent, hsl(var(--primary)), transparent)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer 2s ease-in-out infinite',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Header skeleton */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-96 max-w-full" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-10 w-[140px]" />
|
||||
<Skeleton className="h-10 w-10 rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metric cards grid skeleton */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8].map((i) => (
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
style={{
|
||||
animationDelay: `${i * 50}ms`,
|
||||
animationDuration: '400ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex justify-between items-start">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-4 w-4 rounded" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Skeleton className="h-8 w-20" />
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-3 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
{/* Chart area skeleton */}
|
||||
<div className="mt-4 h-12 flex items-end gap-1">
|
||||
{[...Array(7)].map((_, idx) => {
|
||||
// Deterministic heights to avoid hydration mismatches
|
||||
const heights = [45, 65, 55, 80, 50, 70, 60];
|
||||
return (
|
||||
<Skeleton
|
||||
key={idx}
|
||||
className="flex-1 rounded-t"
|
||||
style={{
|
||||
height: `${heights[idx % heights.length]}%`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton for management cards
|
||||
function ManagementCardsSkeleton() {
|
||||
// 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 (
|
||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
||||
<Suspense fallback={showTimeoutWarning && timeoutFallback ? timeoutFallback : fallback}>
|
||||
{children}
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading skeleton for management cards
|
||||
function ManagementCardsSkeleton({ showSlowWarning = false }: { showSlowWarning?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch relative"
|
||||
role="status"
|
||||
aria-label="Loading management tools"
|
||||
aria-live="polite"
|
||||
>
|
||||
{showSlowWarning && (
|
||||
<div className="col-span-full mb-4">
|
||||
<Alert className="animate-in fade-in duration-300">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<AlertTitle>Taking longer than expected</AlertTitle>
|
||||
<AlertDescription>
|
||||
The components are still loading. This may be due to a slow connection. Please wait...
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</div>
|
||||
)}
|
||||
{/* Subtle loading indicator */}
|
||||
<div className="absolute -top-6 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>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Card key={i}>
|
||||
<CardHeader>
|
||||
<Skeleton className="h-6 w-32" />
|
||||
<Card
|
||||
key={i}
|
||||
className="overflow-hidden animate-in fade-in slide-in-from-bottom-4"
|
||||
style={{
|
||||
animationDelay: `${i * 75}ms`,
|
||||
animationDuration: '400ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
<CardHeader className="space-y-2">
|
||||
<Skeleton className="h-5 w-32" />
|
||||
<Skeleton className="h-3 w-48 max-w-full" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Skeleton className="h-20 w-full" />
|
||||
<CardContent className="space-y-3">
|
||||
{/* Form elements skeleton for interactive cards */}
|
||||
{i <= 2 ? (
|
||||
<>
|
||||
<Skeleton className="h-10 w-full rounded-md" />
|
||||
<Skeleton className="h-10 w-full rounded-md" />
|
||||
<Skeleton className="h-9 w-24 rounded-md" />
|
||||
</>
|
||||
) : (
|
||||
/* List items skeleton for list cards */
|
||||
<>
|
||||
{[1, 2, 3].map((j) => (
|
||||
<div
|
||||
key={j}
|
||||
className="space-y-2 p-3 rounded border border-border/50 animate-in fade-in"
|
||||
style={{
|
||||
animationDelay: `${(i - 2) * 75 + j * 50}ms`,
|
||||
animationDuration: '300ms',
|
||||
animationFillMode: 'both',
|
||||
}}
|
||||
>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
<div className="flex justify-end gap-2 mt-2">
|
||||
<Skeleton className="h-6 w-16 rounded" />
|
||||
<Skeleton className="h-6 w-12 rounded" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
@@ -55,6 +339,56 @@ function ManagementCardsSkeleton() {
|
||||
|
||||
export default function AdminPage() {
|
||||
const [activeTab, setActiveTab] = useState("analytics");
|
||||
const [prefetchedTabs, setPrefetchedTabs] = useState<Set<string>>(new Set());
|
||||
|
||||
// Prefetch components on tab hover or focus for better performance
|
||||
const prefetchTabComponents = (tab: string) => {
|
||||
// Avoid prefetching if already done
|
||||
if (prefetchedTabs.has(tab)) return;
|
||||
|
||||
const startTime = performance.now();
|
||||
|
||||
if (tab === "analytics") {
|
||||
// Prefetch analytics component
|
||||
import("@/components/admin/AdminAnalytics")
|
||||
.then(() => {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] AdminAnalytics prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
} else if (tab === "management") {
|
||||
// Prefetch management components
|
||||
Promise.all([
|
||||
import("@/components/admin/VendorsCard"),
|
||||
import("@/components/admin/InviteVendorCard"),
|
||||
import("@/components/admin/BanUserCard"),
|
||||
import("@/components/admin/InvitationsListCard"),
|
||||
])
|
||||
.then(() => {
|
||||
const loadTime = performance.now() - startTime;
|
||||
console.log(`[Performance] Management components prefetched in ${loadTime.toFixed(2)}ms`);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
setPrefetchedTabs(prev => new Set(prev).add(tab));
|
||||
};
|
||||
|
||||
// Prefetch on hover
|
||||
const handleTabHover = (tab: string) => {
|
||||
prefetchTabComponents(tab);
|
||||
};
|
||||
|
||||
// Prefetch on focus (keyboard navigation)
|
||||
const handleTabFocus = (tab: string) => {
|
||||
prefetchTabComponents(tab);
|
||||
};
|
||||
|
||||
// Prefetch the active tab immediately if not already prefetched
|
||||
useEffect(() => {
|
||||
prefetchTabComponents(activeTab);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeTab]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -70,25 +404,59 @@ export default function AdminPage() {
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||
<TabsList>
|
||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
||||
<TabsTrigger value="management">Management</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="analytics"
|
||||
onMouseEnter={() => handleTabHover("analytics")}
|
||||
onFocus={() => handleTabFocus("analytics")}
|
||||
>
|
||||
Analytics
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="management"
|
||||
onMouseEnter={() => handleTabHover("management")}
|
||||
onFocus={() => handleTabFocus("management")}
|
||||
>
|
||||
Management
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="analytics" className="space-y-6">
|
||||
<Suspense fallback={<AdminComponentSkeleton />}>
|
||||
<AdminAnalytics />
|
||||
</Suspense>
|
||||
<TabsContent value="analytics" className="space-y-6 relative">
|
||||
<ErrorBoundary componentName="Analytics Dashboard">
|
||||
<SuspenseWithTimeout
|
||||
fallback={<AdminComponentSkeleton />}
|
||||
timeout={5000}
|
||||
timeoutFallback={<AdminComponentSkeleton showSlowWarning={true} />}
|
||||
>
|
||||
<div className="animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<AdminAnalytics />
|
||||
</div>
|
||||
</SuspenseWithTimeout>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="management" className="space-y-6">
|
||||
<Suspense fallback={<ManagementCardsSkeleton />}>
|
||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
||||
<VendorsCard />
|
||||
<InviteVendorCard />
|
||||
<BanUserCard />
|
||||
<InvitationsListCard />
|
||||
</div>
|
||||
</Suspense>
|
||||
<TabsContent value="management" className="space-y-6 relative">
|
||||
<ErrorBoundary componentName="Management Tools">
|
||||
<SuspenseWithTimeout
|
||||
fallback={<ManagementCardsSkeleton />}
|
||||
timeout={5000}
|
||||
timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />}
|
||||
>
|
||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch animate-in fade-in slide-in-from-bottom-2 duration-300">
|
||||
<ErrorBoundary componentName="Vendors Card">
|
||||
<VendorsCard />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary componentName="Invite Vendor Card">
|
||||
<InviteVendorCard />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary componentName="Ban User Card">
|
||||
<BanUserCard />
|
||||
</ErrorBoundary>
|
||||
<ErrorBoundary componentName="Invitations List Card">
|
||||
<InvitationsListCard />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</SuspenseWithTimeout>
|
||||
</ErrorBoundary>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user