All checks were successful
Build Frontend / build (push) Successful in 1m7s
Enhanced the admin dashboard tab styling for better clarity. Refactored InviteVendorCard with improved UI, feedback, and clipboard copy functionality. Fixed vendor store ID update to send raw object instead of JSON string. Ensured product price formatting is robust against non-numeric values.
469 lines
16 KiB
TypeScript
469 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 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
|
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 border-border/40 bg-background/50 backdrop-blur-sm shadow-sm"
|
|
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 animate-in fade-in slide-in-from-top-2 duration-300">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight text-foreground">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" className="border-border/50 bg-background/50 backdrop-blur-sm hover:bg-background/80 transition-all">
|
|
<Link href="/dashboard">Back to Dashboard</Link>
|
|
</Button>
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-6">
|
|
<TabsList className="bg-muted/20 p-1 border border-border/40 backdrop-blur-sm h-auto">
|
|
<TabsTrigger
|
|
value="analytics"
|
|
onMouseEnter={() => handleTabHover("analytics")}
|
|
onFocus={() => handleTabFocus("analytics")}
|
|
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
|
>
|
|
Analytics
|
|
</TabsTrigger>
|
|
<TabsTrigger
|
|
value="management"
|
|
onMouseEnter={() => handleTabHover("management")}
|
|
onFocus={() => handleTabFocus("management")}
|
|
className="data-[state=active]:bg-background/80 data-[state=active]:backdrop-blur-sm data-[state=active]:text-foreground data-[state=active]:shadow-sm px-6 py-2 transition-all"
|
|
>
|
|
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>
|
|
);
|
|
}
|
|
|
|
|