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";
|
"use client";
|
||||||
export const dynamic = "force-dynamic";
|
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 { Button } from "@/components/ui/button";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
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
|
// Error Boundary Component
|
||||||
const AdminAnalytics = lazy(() => import("@/components/admin/AdminAnalytics"));
|
interface ErrorBoundaryState {
|
||||||
const InviteVendorCard = lazy(() => import("@/components/admin/InviteVendorCard"));
|
hasError: boolean;
|
||||||
const BanUserCard = lazy(() => import("@/components/admin/BanUserCard"));
|
error: Error | null;
|
||||||
const InvitationsListCard = lazy(() => import("@/components/admin/InvitationsListCard"));
|
}
|
||||||
const VendorsCard = lazy(() => import("@/components/admin/VendorsCard"));
|
|
||||||
|
|
||||||
// Loading skeleton for admin components
|
interface ErrorBoundaryProps {
|
||||||
function AdminComponentSkeleton() {
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div
|
||||||
<Card>
|
className="space-y-6 animate-in fade-in duration-500 relative"
|
||||||
<CardHeader>
|
role="status"
|
||||||
<Skeleton className="h-6 w-48" />
|
aria-label="Loading analytics dashboard"
|
||||||
</CardHeader>
|
aria-live="polite"
|
||||||
<CardContent>
|
>
|
||||||
<div className="space-y-2">
|
{showSlowWarning && (
|
||||||
<Skeleton className="h-4 w-full" />
|
<Alert className="mb-4 animate-in fade-in duration-300">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<AlertCircle className="h-4 w-4" />
|
||||||
<Skeleton className="h-4 w-1/2" />
|
<AlertTitle>Taking longer than expected</AlertTitle>
|
||||||
</div>
|
<AlertDescription>
|
||||||
</CardContent>
|
The component is still loading. This may be due to a slow connection. Please wait...
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loading skeleton for management cards
|
// Suspense wrapper with timeout
|
||||||
function ManagementCardsSkeleton() {
|
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 (
|
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) => (
|
{[1, 2, 3, 4].map((i) => (
|
||||||
<Card key={i}>
|
<Card
|
||||||
<CardHeader>
|
key={i}
|
||||||
<Skeleton className="h-6 w-32" />
|
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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-3">
|
||||||
<Skeleton className="h-20 w-full" />
|
{/* 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>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -55,6 +339,56 @@ function ManagementCardsSkeleton() {
|
|||||||
|
|
||||||
export default function AdminPage() {
|
export default function AdminPage() {
|
||||||
const [activeTab, setActiveTab] = useState("analytics");
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -70,25 +404,59 @@ export default function AdminPage() {
|
|||||||
|
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="analytics">Analytics</TabsTrigger>
|
<TabsTrigger
|
||||||
<TabsTrigger value="management">Management</TabsTrigger>
|
value="analytics"
|
||||||
|
onMouseEnter={() => handleTabHover("analytics")}
|
||||||
|
onFocus={() => handleTabFocus("analytics")}
|
||||||
|
>
|
||||||
|
Analytics
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger
|
||||||
|
value="management"
|
||||||
|
onMouseEnter={() => handleTabHover("management")}
|
||||||
|
onFocus={() => handleTabFocus("management")}
|
||||||
|
>
|
||||||
|
Management
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="analytics" className="space-y-6">
|
<TabsContent value="analytics" className="space-y-6 relative">
|
||||||
<Suspense fallback={<AdminComponentSkeleton />}>
|
<ErrorBoundary componentName="Analytics Dashboard">
|
||||||
<AdminAnalytics />
|
<SuspenseWithTimeout
|
||||||
</Suspense>
|
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>
|
||||||
|
|
||||||
<TabsContent value="management" className="space-y-6">
|
<TabsContent value="management" className="space-y-6 relative">
|
||||||
<Suspense fallback={<ManagementCardsSkeleton />}>
|
<ErrorBoundary componentName="Management Tools">
|
||||||
<div className="grid gap-4 lg:gap-6 sm:grid-cols-2 lg:grid-cols-3 items-stretch">
|
<SuspenseWithTimeout
|
||||||
<VendorsCard />
|
fallback={<ManagementCardsSkeleton />}
|
||||||
<InviteVendorCard />
|
timeout={5000}
|
||||||
<BanUserCard />
|
timeoutFallback={<ManagementCardsSkeleton showSlowWarning={true} />}
|
||||||
<InvitationsListCard />
|
>
|
||||||
</div>
|
<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">
|
||||||
</Suspense>
|
<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>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,26 +1,113 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, Suspense } from "react";
|
import { useEffect, Suspense, Component, ReactNode } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
import { MessageCircle } from "lucide-react";
|
import { MessageCircle, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
// Lazy load the ChatTable component
|
// Error Boundary Component
|
||||||
const ChatTable = dynamic(() => import("@/components/dashboard/ChatTable"), {
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: 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) {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load the ChatTable component with error handling
|
||||||
|
const ChatTable = dynamic(() => import("@/components/dashboard/ChatTable").catch((err) => {
|
||||||
|
console.error("Failed to load ChatTable:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
loading: () => <ChatTableSkeleton />
|
loading: () => <ChatTableSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loading skeleton for the chat table
|
// Loading skeleton for the chat table
|
||||||
function ChatTableSkeleton() {
|
function ChatTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="animate-in fade-in duration-500 relative">
|
||||||
|
{/* Subtle loading indicator */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||||
|
<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>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -28,13 +115,29 @@ function ChatTableSkeleton() {
|
|||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{['Customer', 'Last Message', 'Date', 'Status', 'Actions'].map((header, i) => (
|
{['Customer', 'Last Message', 'Date', 'Status', 'Actions'].map((header, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-20 flex-1" />
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[...Array(6)].map((_, i) => (
|
{[...Array(6)].map((_, i) => (
|
||||||
<div key={i} className="border-b last:border-b-0 p-4">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b last:border-b-0 p-4 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${300 + i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Skeleton className="h-10 w-10 rounded-full" />
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
@@ -45,10 +148,10 @@ function ChatTableSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-4 w-40 flex-1" />
|
<Skeleton className="h-4 w-40 flex-1" />
|
||||||
<Skeleton className="h-4 w-20 flex-1" />
|
<Skeleton className="h-4 w-20 flex-1" />
|
||||||
<Skeleton className="h-6 w-16 flex-1" />
|
<Skeleton className="h-6 w-16 flex-1 rounded-full" />
|
||||||
<div className="flex gap-2 flex-1">
|
<div className="flex gap-2 flex-1">
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,9 +186,11 @@ export default function ChatsPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<ChatTableSkeleton />}>
|
<ErrorBoundary componentName="Chat Table">
|
||||||
<ChatTable />
|
<Suspense fallback={<ChatTableSkeleton />}>
|
||||||
</Suspense>
|
<ChatTable />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</Dashboard>
|
</Dashboard>
|
||||||
);
|
);
|
||||||
|
|||||||
262
app/dashboard/dashboard-content-wrapper.tsx
Normal file
262
app/dashboard/dashboard-content-wrapper.tsx
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,28 +1,115 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, Suspense } from "react";
|
import { useEffect, Suspense, Component, ReactNode, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Dashboard from "@/components/dashboard/dashboard";
|
import Dashboard from "@/components/dashboard/dashboard";
|
||||||
import { Package } from "lucide-react";
|
import { Package, AlertCircle, RefreshCw } from "lucide-react";
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
// Lazy load the OrderTable component
|
// Error Boundary Component
|
||||||
const OrderTable = dynamic(() => import("@/components/tables/order-table"), {
|
interface ErrorBoundaryState {
|
||||||
|
hasError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: 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) {
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.props.children;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lazy load the OrderTable component with error handling
|
||||||
|
const OrderTable = dynamic(() => import("@/components/tables/order-table").catch((err) => {
|
||||||
|
console.error("Failed to load OrderTable:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
loading: () => <OrderTableSkeleton />
|
loading: () => <OrderTableSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loading skeleton for the order table
|
// Loading skeleton for the order table
|
||||||
function OrderTableSkeleton() {
|
function OrderTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="animate-in fade-in duration-500 relative">
|
||||||
|
{/* Subtle loading indicator */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||||
|
<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>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -32,23 +119,39 @@ function OrderTableSkeleton() {
|
|||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{['Order ID', 'Customer', 'Status', 'Total', 'Date', 'Actions'].map((header, i) => (
|
{['Order ID', 'Customer', 'Status', 'Total', 'Date', 'Actions'].map((header, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-20 flex-1" />
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table rows skeleton */}
|
{/* Table rows skeleton */}
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="border-b last:border-b-0 p-4">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b last:border-b-0 p-4 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${300 + i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-4 w-24 flex-1" />
|
<Skeleton className="h-4 w-24 flex-1" />
|
||||||
<Skeleton className="h-4 w-32 flex-1" />
|
<Skeleton className="h-4 w-32 flex-1" />
|
||||||
<Skeleton className="h-6 w-20 flex-1" />
|
<Skeleton className="h-6 w-20 flex-1 rounded-full" />
|
||||||
<Skeleton className="h-4 w-16 flex-1" />
|
<Skeleton className="h-4 w-16 flex-1" />
|
||||||
<Skeleton className="h-4 w-20 flex-1" />
|
<Skeleton className="h-4 w-20 flex-1" />
|
||||||
<div className="flex gap-2 flex-1">
|
<div className="flex gap-2 flex-1">
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,9 +186,11 @@ export default function OrdersPage() {
|
|||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Suspense fallback={<OrderTableSkeleton />}>
|
<ErrorBoundary componentName="Order Table">
|
||||||
<OrderTable />
|
<Suspense fallback={<OrderTableSkeleton />}>
|
||||||
</Suspense>
|
<OrderTable />
|
||||||
|
</Suspense>
|
||||||
|
</ErrorBoundary>
|
||||||
</div>
|
</div>
|
||||||
</Dashboard>
|
</Dashboard>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,26 +8,46 @@ import { Suspense } from 'react';
|
|||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import DashboardContentWrapper from './dashboard-content-wrapper';
|
||||||
// Lazy load the Content component
|
|
||||||
const Content = dynamic(() => import("@/components/dashboard/content"), {
|
|
||||||
loading: () => <DashboardContentSkeleton />
|
|
||||||
});
|
|
||||||
|
|
||||||
// Loading skeleton for the dashboard content
|
// Loading skeleton for the dashboard content
|
||||||
function DashboardContentSkeleton() {
|
function DashboardContentSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<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 */}
|
{/* Header skeleton */}
|
||||||
<div>
|
<div>
|
||||||
<Skeleton className="h-8 w-64 mb-2" />
|
<Skeleton className="h-8 w-64 mb-2" />
|
||||||
<Skeleton className="h-4 w-96" />
|
<Skeleton className="h-4 w-96 max-w-full" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats cards skeleton */}
|
{/* 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">
|
<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) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<Card key={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">
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
<Skeleton className="h-4 w-20" />
|
<Skeleton className="h-4 w-20" />
|
||||||
<Skeleton className="h-5 w-5 rounded" />
|
<Skeleton className="h-5 w-5 rounded" />
|
||||||
@@ -41,15 +61,29 @@ function DashboardContentSkeleton() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Best selling products skeleton */}
|
{/* Best selling products skeleton */}
|
||||||
<Card>
|
<Card className="animate-in fade-in slide-in-from-bottom-4"
|
||||||
|
style={{
|
||||||
|
animationDelay: '300ms',
|
||||||
|
animationDuration: '400ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<Skeleton className="h-6 w-48" />
|
<Skeleton className="h-6 w-48" />
|
||||||
<Skeleton className="h-4 w-72" />
|
<Skeleton className="h-4 w-72 max-w-full" />
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{[...Array(5)].map((_, i) => (
|
{[...Array(5)].map((_, i) => (
|
||||||
<div key={i} className="flex items-center gap-4">
|
<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" />
|
<Skeleton className="h-12 w-12 rounded-md" />
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-4 w-40" />
|
<Skeleton className="h-4 w-40" />
|
||||||
@@ -68,6 +102,14 @@ function DashboardContentSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Lazy load the Content component with error handling
|
||||||
|
const Content = dynamic(() => import("@/components/dashboard/content").catch((err) => {
|
||||||
|
console.error("Failed to load dashboard content:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
|
loading: () => <DashboardContentSkeleton />
|
||||||
|
});
|
||||||
|
|
||||||
// ✅ Corrected Vendor Type
|
// ✅ Corrected Vendor Type
|
||||||
interface Vendor {
|
interface Vendor {
|
||||||
_id: string;
|
_id: string;
|
||||||
@@ -108,9 +150,11 @@ export default async function DashboardPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dashboard>
|
<Dashboard>
|
||||||
<Suspense fallback={<DashboardContentSkeleton />}>
|
<DashboardContentWrapper>
|
||||||
<Content username={vendor.username} orderStats={orderStats} />
|
<Suspense fallback={<DashboardContentSkeleton />}>
|
||||||
</Suspense>
|
<Content username={vendor.username} orderStats={orderStats} />
|
||||||
|
</Suspense>
|
||||||
|
</DashboardContentWrapper>
|
||||||
<div className="fixed bottom-2 right-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded border border-border/50 z-50 flex items-center space-x-2">
|
<div className="fixed bottom-2 right-2 text-xs text-muted-foreground bg-background/80 backdrop-blur-sm px-2 py-1 rounded border border-border/50 z-50 flex items-center space-x-2">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Info size={12} className="text-muted-foreground/80" />
|
<Info size={12} className="text-muted-foreground/80" />
|
||||||
|
|||||||
@@ -14,32 +14,44 @@ import dynamic from "next/dynamic";
|
|||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
// Lazy load heavy components
|
// Lazy load heavy components with error handling
|
||||||
const ProductTable = dynamic(() => import("@/components/tables/product-table"), {
|
const ProductTable = dynamic(() => import("@/components/tables/product-table").catch((err) => {
|
||||||
|
console.error("Failed to load ProductTable:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
loading: () => <ProductTableSkeleton />
|
loading: () => <ProductTableSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
const ProductModal = dynamic(() => import("@/components/modals/product-modal").then(mod => ({ default: mod.ProductModal })), {
|
const ProductModal = dynamic(() => import("@/components/modals/product-modal").then(mod => ({ default: mod.ProductModal })).catch((err) => {
|
||||||
loading: () => <div>Loading...</div>
|
console.error("Failed to load ProductModal:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
|
loading: () => <ModalSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
const ImportProductsModal = dynamic(() => import("@/components/modals/import-products-modal"), {
|
const ImportProductsModal = dynamic(() => import("@/components/modals/import-products-modal").catch((err) => {
|
||||||
loading: () => <div>Loading...</div>
|
console.error("Failed to load ImportProductsModal:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
|
loading: () => <ModalSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-analysis-modal").then(mod => ({ default: mod.ProfitAnalysisModal })), {
|
const ProfitAnalysisModal = dynamic(() => import("@/components/modals/profit-analysis-modal").then(mod => ({ default: mod.ProfitAnalysisModal })).catch((err) => {
|
||||||
loading: () => <div>Loading...</div>
|
console.error("Failed to load ProfitAnalysisModal:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
|
loading: () => <ModalSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
function ProductTableSkeleton() {
|
function ProductTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="animate-in fade-in duration-500">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
<Skeleton className="h-9 w-32" />
|
<Skeleton className="h-9 w-32 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
@@ -48,13 +60,29 @@ function ProductTableSkeleton() {
|
|||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => (
|
{['Product', 'Category', 'Price', 'Stock', 'Status', 'Actions'].map((header, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-20 flex-1" />
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[...Array(8)].map((_, i) => (
|
{[...Array(8)].map((_, i) => (
|
||||||
<div key={i} className="border-b last:border-b-0 p-4">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b last:border-b-0 p-4 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${300 + i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3 flex-1">
|
<div className="flex items-center gap-3 flex-1">
|
||||||
<Skeleton className="h-12 w-12 rounded-md" />
|
<Skeleton className="h-12 w-12 rounded-md" />
|
||||||
@@ -66,10 +94,10 @@ function ProductTableSkeleton() {
|
|||||||
<Skeleton className="h-4 w-24 flex-1" />
|
<Skeleton className="h-4 w-24 flex-1" />
|
||||||
<Skeleton className="h-4 w-16 flex-1" />
|
<Skeleton className="h-4 w-16 flex-1" />
|
||||||
<Skeleton className="h-4 w-16 flex-1" />
|
<Skeleton className="h-4 w-16 flex-1" />
|
||||||
<Skeleton className="h-6 w-20 flex-1" />
|
<Skeleton className="h-6 w-20 flex-1 rounded-full" />
|
||||||
<div className="flex gap-2 flex-1">
|
<div className="flex gap-2 flex-1">
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,6 +108,29 @@ function ProductTableSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ModalSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<Card className="w-full max-w-2xl m-4 animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64 mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProductsPage() {
|
export default function ProductsPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [products, setProducts] = useState<Product[]>([]);
|
const [products, setProducts] = useState<Product[]>([]);
|
||||||
|
|||||||
@@ -17,23 +17,59 @@ import {
|
|||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
|
||||||
// Lazy load components
|
// Lazy load components with error handling
|
||||||
const ShippingModal = dynamic(() => import("@/components/modals/shipping-modal").then(mod => ({ default: mod.ShippingModal })), {
|
const ShippingModal = dynamic(() => import("@/components/modals/shipping-modal").then(mod => ({ default: mod.ShippingModal })).catch((err) => {
|
||||||
loading: () => <div>Loading...</div>
|
console.error("Failed to load ShippingModal:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
|
loading: () => (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm">
|
||||||
|
<Card className="w-full max-w-md m-4 animate-in fade-in zoom-in-95 duration-300">
|
||||||
|
<CardHeader>
|
||||||
|
<Skeleton className="h-6 w-48" />
|
||||||
|
<Skeleton className="h-4 w-64 mt-2" />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{[...Array(3)].map((_, i) => (
|
||||||
|
<div key={i} className="space-y-2">
|
||||||
|
<Skeleton className="h-4 w-24" />
|
||||||
|
<Skeleton className="h-10 w-full rounded-md" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
});
|
});
|
||||||
|
|
||||||
const ShippingTable = dynamic(() => import("@/components/tables/shipping-table").then(mod => ({ default: mod.ShippingTable })), {
|
const ShippingTable = dynamic(() => import("@/components/tables/shipping-table").then(mod => ({ default: mod.ShippingTable })).catch((err) => {
|
||||||
|
console.error("Failed to load ShippingTable:", err);
|
||||||
|
throw err;
|
||||||
|
}), {
|
||||||
loading: () => <ShippingTableSkeleton />
|
loading: () => <ShippingTableSkeleton />
|
||||||
});
|
});
|
||||||
|
|
||||||
// Loading skeleton for shipping table
|
// Loading skeleton for shipping table
|
||||||
function ShippingTableSkeleton() {
|
function ShippingTableSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card className="animate-in fade-in duration-500 relative">
|
||||||
|
{/* Subtle loading indicator */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden rounded-t-lg">
|
||||||
|
<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>
|
||||||
|
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Skeleton className="h-6 w-32" />
|
<Skeleton className="h-6 w-32" />
|
||||||
<Skeleton className="h-9 w-24" />
|
<Skeleton className="h-9 w-24 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@@ -41,19 +77,35 @@ function ShippingTableSkeleton() {
|
|||||||
<div className="border-b p-4">
|
<div className="border-b p-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{['Method Name', 'Price', 'Actions'].map((header, i) => (
|
{['Method Name', 'Price', 'Actions'].map((header, i) => (
|
||||||
<Skeleton key={i} className="h-4 w-20 flex-1" />
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{[...Array(4)].map((_, i) => (
|
{[...Array(4)].map((_, i) => (
|
||||||
<div key={i} className="border-b last:border-b-0 p-4">
|
<div
|
||||||
|
key={i}
|
||||||
|
className="border-b last:border-b-0 p-4 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${200 + i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<Skeleton className="h-4 w-32 flex-1" />
|
<Skeleton className="h-4 w-32 flex-1" />
|
||||||
<Skeleton className="h-4 w-16 flex-1" />
|
<Skeleton className="h-4 w-16 flex-1" />
|
||||||
<div className="flex gap-2 flex-1">
|
<div className="flex gap-2 flex-1">
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
<Skeleton className="h-8 w-16" />
|
<Skeleton className="h-8 w-16 rounded-md" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
@@ -232,8 +233,58 @@ export default function CustomerManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="p-8 flex justify-center bg-black/60">
|
<div className="p-8 bg-black/60">
|
||||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
{/* Loading indicator */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 h-1 bg-muted overflow-hidden">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Table skeleton */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center gap-4 pb-4 border-b border-zinc-800">
|
||||||
|
{['Customer', 'Orders', 'Total Spent', 'Last Order', 'Status'].map((header, i) => (
|
||||||
|
<Skeleton
|
||||||
|
key={i}
|
||||||
|
className="h-4 w-20 flex-1 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{[...Array(5)].map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-4 pb-4 border-b border-zinc-800 last:border-b-0 animate-in fade-in"
|
||||||
|
style={{
|
||||||
|
animationDelay: `${250 + i * 50}ms`,
|
||||||
|
animationDuration: '300ms',
|
||||||
|
animationFillMode: 'both',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 flex-1">
|
||||||
|
<Skeleton className="h-10 w-10 rounded-full" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Skeleton className="h-4 w-32" />
|
||||||
|
<Skeleton className="h-3 w-24" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-6 w-12 flex-1 rounded-full" />
|
||||||
|
<Skeleton className="h-4 w-20 flex-1" />
|
||||||
|
<Skeleton className="h-4 w-24 flex-1" />
|
||||||
|
<Skeleton className="h-6 w-24 flex-1 rounded-full" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : filteredCustomers.length === 0 ? (
|
) : filteredCustomers.length === 0 ? (
|
||||||
<div className="p-8 text-center bg-black/60">
|
<div className="p-8 text-center bg-black/60">
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ body {
|
|||||||
text-wrap: balance;
|
text-wrap: balance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shimmer animation for loading indicators */
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% {
|
||||||
|
background-position: -200% 0;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
background-position: 200% 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Accessibility improvements */
|
/* Accessibility improvements */
|
||||||
.sr-only {
|
.sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"commitHash": "2db13cc",
|
"commitHash": "96638f9",
|
||||||
"buildTime": "2025-12-27T20:56:32.712Z"
|
"buildTime": "2025-12-31T05:14:19.565Z"
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user