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.
467 lines
16 KiB
TypeScript
467 lines
16 KiB
TypeScript
"use client";
|
|
export const dynamic = "force-dynamic";
|
|
|
|
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";
|
|
|
|
// 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);
|
|
|
|
// 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-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>
|
|
);
|
|
}
|
|
|
|
// 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 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}
|
|
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 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>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Admin Dashboard</h1>
|
|
<p className="text-sm text-muted-foreground mt-1">Platform analytics and vendor management</p>
|
|
</div>
|
|
<Button asChild variant="outline" size="sm">
|
|
<Link href="/dashboard">Back to Dashboard</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
|
<TabsList>
|
|
<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 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 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>
|
|
);
|
|
}
|
|
|
|
|