refactor: Code splitting, page templates, and PageHeader consistency
Some checks failed
Deploy to Production / deploy (push) Failing after 0s
Test / backend-test (push) Failing after 0s
Test / frontend-test (push) Failing after 0s

- Lazy load all pages for 24% bundle size reduction (575KB → 436KB)
- Created templates: PageTemplate, ComponentTemplate, WidgetTemplate
- Updated RoomsPage and IPMDashboardPage with PageHeader
- All routes wrapped in Suspense with PageLoader component
- Fixed malformed router.tsx structure
This commit is contained in:
fullsizemalt 2025-12-11 11:18:29 -08:00
parent 010ed94b31
commit 35162d565d
6 changed files with 447 additions and 95 deletions

View file

@ -2,6 +2,7 @@ import { useState, useEffect } from 'react';
import { batchesApi, Batch } from '../lib/batchesApi'; import { batchesApi, Batch } from '../lib/batchesApi';
import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi'; import { touchPointsApi, IPMSchedule } from '../lib/touchPointsApi';
import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react'; import { Loader2, AlertTriangle, CheckCircle, Calendar, Shield } from 'lucide-react';
import { PageHeader } from '../components/layout/PageHeader';
export default function IPMDashboardPage() { export default function IPMDashboardPage() {
const [batches, setBatches] = useState<Batch[]>([]); const [batches, setBatches] = useState<Batch[]>([]);
@ -64,12 +65,12 @@ export default function IPMDashboardPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header> <PageHeader
<h1 className="text-2xl font-bold flex items-center gap-2"> title="IPM Dashboard"
<Shield className="text-emerald-600" /> IPM Dashboard description="Root drench schedule and integrated pest management tracking"
</h1> icon={Shield}
<p className="text-slate-500">Root Drench Schedule (Veg Room)</p> iconColor="text-emerald-600"
</header> />
<div className="grid gap-4"> <div className="grid gap-4">
{batches.map(batch => { {batches.map(batch => {

View file

@ -1,58 +1,114 @@
import React, { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { Home, Plus } from 'lucide-react';
import api from '../lib/api'; import api from '../lib/api';
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
import { usePermissions } from '../hooks/usePermissions';
export default function RoomsPage() { export default function RoomsPage() {
const { isManager } = usePermissions();
const [rooms, setRooms] = useState<any[]>([]); const [rooms, setRooms] = useState<any[]>([]);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => { useEffect(() => {
fetchRooms(); fetchRooms();
}, []); }, []);
const fetchRooms = async () => { const fetchRooms = async () => {
setIsLoading(true);
try { try {
const { data } = await api.get('/rooms'); const { data } = await api.get('/rooms');
setRooms(data); setRooms(data);
} catch (e) { } catch (e) {
console.error(e); console.error(e);
} finally {
setIsLoading(false);
} }
}; };
const getRoomTypeColor = (type: string) => {
const colors: Record<string, string> = {
VEG: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400',
FLOWER: 'bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-400',
DRY: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
CURE: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
MOTHER: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
TRIM: 'bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-400',
};
return colors[type] || 'bg-slate-100 text-slate-700 dark:bg-slate-700 dark:text-slate-300';
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<header className="flex justify-between items-center"> <PageHeader
<h2 className="text-2xl font-bold text-neutral-800">Cultivation Rooms</h2> title="Cultivation Rooms"
<button className="px-4 py-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 transition-colors"> description="Manage grow rooms and monitor their status"
+ New Room icon={Home}
</button> iconColor="text-green-600"
</header> actions={
isManager && (
<PageHeaderButton variant="primary">
<Plus size={18} />
Add Room
</PageHeaderButton>
)
}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> {isLoading ? (
<div className="flex items-center justify-center p-12">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{rooms.map(room => ( {rooms.map(room => (
<div key={room.id} className="bg-white p-6 rounded-xl shadow-sm border border-neutral-200"> <div
key={room.id}
className="bg-white dark:bg-slate-800 p-5 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 hover:shadow-md transition-shadow"
>
<div className="flex justify-between items-start mb-4"> <div className="flex justify-between items-start mb-4">
<h3 className="font-bold text-lg text-emerald-950">{room.name}</h3> <h3 className="font-semibold text-lg text-slate-900 dark:text-white">
<span className="px-2 py-1 bg-neutral-100 text-neutral-600 text-xs rounded uppercase font-bold tracking-wider"> {room.name?.replace('[DEMO] ', '')}
</h3>
<span className={`px-2 py-1 text-[10px] rounded font-bold uppercase tracking-wider ${getRoomTypeColor(room.type)}`}>
{room.type} {room.type}
</span> </span>
</div> </div>
<div className="space-y-2 text-sm text-neutral-600"> <div className="space-y-2 text-sm">
<div className="flex justify-between"> <div className="flex justify-between text-slate-600 dark:text-slate-400">
<span>Size:</span> <span>Size:</span>
<span className="font-medium">{room.sqft} sqft</span> <span className="font-medium text-slate-900 dark:text-white">{room.sqft?.toLocaleString()} sqft</span>
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between text-slate-600 dark:text-slate-400">
<span>Capacity:</span>
<span className="font-medium text-slate-900 dark:text-white">{room.capacity || '—'} plants</span>
</div>
<div className="flex justify-between text-slate-600 dark:text-slate-400">
<span>Active Batches:</span> <span>Active Batches:</span>
<span className="font-medium">{room.batches?.length || 0}</span> <span className="font-medium text-emerald-600 dark:text-emerald-400">{room.batches?.length || 0}</span>
</div> </div>
</div> </div>
</div> {room.targetTemp && (
))} <div className="mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex gap-4 text-xs text-slate-500 dark:text-slate-400">
{rooms.length === 0 && ( <span>🌡 {room.targetTemp}°F</span>
<div className="col-span-full text-center py-20 bg-neutral-50 rounded-xl border border-dashed border-neutral-300"> <span>💧 {room.targetHumidity}%</span>
<p className="text-neutral-500">No rooms found. Seed the database or create one.</p>
</div> </div>
)} )}
</div> </div>
))}
{rooms.length === 0 && (
<div className="col-span-full flex flex-col items-center justify-center py-16 bg-slate-50 dark:bg-slate-800/50 rounded-xl border border-dashed border-slate-300 dark:border-slate-600">
<Home size={48} className="text-slate-300 dark:text-slate-600 mb-4" />
<p className="text-slate-500 dark:text-slate-400 mb-4">No rooms found</p>
{isManager && (
<button className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 text-white rounded-lg font-medium hover:bg-emerald-700">
<Plus size={18} />
Create First Room
</button>
)}
</div>
)}
</div>
)}
</div> </div>
); );
} }

View file

@ -4,22 +4,26 @@ import Layout from './components/Layout';
import ProtectedRoute from './components/ProtectedRoute'; import ProtectedRoute from './components/ProtectedRoute';
import LoginPage from './pages/LoginPage'; import LoginPage from './pages/LoginPage';
import { RouterErrorPage, NotFoundPage } from './pages/ErrorPages'; import { RouterErrorPage, NotFoundPage } from './pages/ErrorPages';
import DashboardPage from './pages/DashboardPage';
import DailyWalkthroughPage from './pages/DailyWalkthroughPage';
import RoomsPage from './pages/RoomsPage';
import BatchesPage from './pages/BatchesPage';
import TimeclockPage from './pages/TimeclockPage';
import SuppliesPage from './pages/SuppliesPage';
import TasksPage from './pages/TasksPage';
import TaskTemplatesPage from './pages/TaskTemplatesPage';
import WalkthroughSettingsPage from './pages/WalkthroughSettingsPage';
import RolesPage from './pages/RolesPage';
import TouchPointPage from './pages/TouchPointPage';
import IPMDashboardPage from './pages/IPMDashboardPage';
import SettingsPage from './pages/SettingsPage';
import ReportsPage from './pages/ReportsPage';
// Lazy load heavy components // Core pages - loaded immediately
import DashboardPage from './pages/DashboardPage';
// Lazy load all other pages to reduce initial bundle
const DailyWalkthroughPage = lazy(() => import('./pages/DailyWalkthroughPage'));
const RoomsPage = lazy(() => import('./pages/RoomsPage'));
const BatchesPage = lazy(() => import('./pages/BatchesPage'));
const TimeclockPage = lazy(() => import('./pages/TimeclockPage'));
const SuppliesPage = lazy(() => import('./pages/SuppliesPage'));
const TasksPage = lazy(() => import('./pages/TasksPage'));
const TaskTemplatesPage = lazy(() => import('./pages/TaskTemplatesPage'));
const WalkthroughSettingsPage = lazy(() => import('./pages/WalkthroughSettingsPage'));
const RolesPage = lazy(() => import('./pages/RolesPage'));
const TouchPointPage = lazy(() => import('./pages/TouchPointPage'));
const IPMDashboardPage = lazy(() => import('./pages/IPMDashboardPage'));
const SettingsPage = lazy(() => import('./pages/SettingsPage'));
const ReportsPage = lazy(() => import('./pages/ReportsPage'));
// Heavy components
const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage')); const LayoutDesignerPage = lazy(() => import('./features/layout-designer/LayoutDesignerPage'));
const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage')); const VisitorKioskPage = lazy(() => import('./pages/VisitorKioskPage'));
const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage')); const VisitorManagementPage = lazy(() => import('./pages/VisitorManagementPage'));
@ -33,6 +37,13 @@ const EnvironmentDashboard = lazy(() => import('./pages/EnvironmentDashboard'));
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard')); const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard')); const InsightsDashboard = lazy(() => import('./pages/InsightsDashboard'));
// Loading spinner component for Suspense fallbacks
const PageLoader = () => (
<div className="flex items-center justify-center p-8">
<div className="animate-spin w-6 h-6 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
);
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: '/login', path: '/login',
@ -67,105 +78,81 @@ export const router = createBrowserRouter([
}, },
{ {
path: 'walkthrough', path: 'walkthrough',
element: <DailyWalkthroughPage />, element: <Suspense fallback={<PageLoader />}><DailyWalkthroughPage /></Suspense>,
}, },
{ {
path: 'touch-points', path: 'touch-points',
element: <TouchPointPage />, element: <Suspense fallback={<PageLoader />}><TouchPointPage /></Suspense>,
}, },
{ {
path: 'ipm', path: 'ipm',
element: <IPMDashboardPage />, element: <Suspense fallback={<PageLoader />}><IPMDashboardPage /></Suspense>,
}, },
{ {
path: 'rooms', path: 'rooms',
element: <RoomsPage />, element: <Suspense fallback={<PageLoader />}><RoomsPage /></Suspense>,
}, },
{ {
path: 'batches', path: 'batches',
element: <BatchesPage />, element: <Suspense fallback={<PageLoader />}><BatchesPage /></Suspense>,
}, },
{ {
path: 'timeclock', path: 'timeclock',
element: <TimeclockPage />, element: <Suspense fallback={<PageLoader />}><TimeclockPage /></Suspense>,
}, },
{ {
path: 'supplies', path: 'supplies',
element: <SuppliesPage />, element: <Suspense fallback={<PageLoader />}><SuppliesPage /></Suspense>,
}, },
{ {
path: 'tasks', path: 'tasks',
element: <TasksPage />, element: <Suspense fallback={<PageLoader />}><TasksPage /></Suspense>,
}, },
{ {
path: 'tasks/templates', path: 'tasks/templates',
element: <TaskTemplatesPage />, element: <Suspense fallback={<PageLoader />}><TaskTemplatesPage /></Suspense>,
}, },
{ {
path: 'reports', path: 'reports',
element: <ReportsPage />, element: <Suspense fallback={<PageLoader />}><ReportsPage /></Suspense>,
}, },
{ {
path: 'roles', path: 'roles',
element: <RolesPage />, element: <Suspense fallback={<PageLoader />}><RolesPage /></Suspense>,
}, },
{ {
path: 'visitors', path: 'visitors',
element: ( element: <Suspense fallback={<PageLoader />}><VisitorManagementPage /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<VisitorManagementPage />
</Suspense>
),
}, },
{ {
path: 'settings', path: 'settings',
element: <SettingsPage />, element: <Suspense fallback={<PageLoader />}><SettingsPage /></Suspense>,
}, },
{ {
path: 'settings/walkthrough', path: 'settings/walkthrough',
element: <WalkthroughSettingsPage />, element: <Suspense fallback={<PageLoader />}><WalkthroughSettingsPage /></Suspense>,
}, },
// Phase 10: Compliance & Audit // Phase 10: Compliance & Audit
{ {
path: 'compliance/audit', path: 'compliance/audit',
element: ( element: <Suspense fallback={<PageLoader />}><AuditLogPage /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<AuditLogPage />
</Suspense>
),
}, },
{ {
path: 'compliance/documents', path: 'compliance/documents',
element: ( element: <Suspense fallback={<PageLoader />}><DocumentsPage /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<DocumentsPage />
</Suspense>
),
}, },
// Phase 13: Advanced Features // Phase 13: Advanced Features
{ {
path: 'environment', path: 'environment',
element: ( element: <Suspense fallback={<PageLoader />}><EnvironmentDashboard /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<EnvironmentDashboard />
</Suspense>
),
}, },
{ {
path: 'financial', path: 'financial',
element: ( element: <Suspense fallback={<PageLoader />}><FinancialDashboard /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<FinancialDashboard />
</Suspense>
),
}, },
{ {
path: 'insights', path: 'insights',
element: ( element: <Suspense fallback={<PageLoader />}><InsightsDashboard /></Suspense>,
<Suspense fallback={<div className="p-4">Loading...</div>}>
<InsightsDashboard />
</Suspense>
),
}, },
// 404 catch-all // 404 catch-all
{ {

View file

@ -0,0 +1,62 @@
/**
* Component Template
*
* Copy this file to create a new component:
* 1. cp src/templates/ComponentTemplate.tsx src/components/MyComponent.tsx
* 2. Rename the component and update props interface
*/
import { ReactNode } from 'react';
interface ComponentTemplateProps {
/** Required: Main content or children */
children?: ReactNode;
/** Optional: Custom CSS classes */
className?: string;
/** Optional: Variant style */
variant?: 'default' | 'outlined' | 'ghost';
/** Optional: Size variant */
size?: 'sm' | 'md' | 'lg';
/** Optional: Click handler */
onClick?: () => void;
/** Optional: Disabled state */
disabled?: boolean;
}
const variantStyles = {
default: 'bg-emerald-600 text-white hover:bg-emerald-700',
outlined: 'border border-emerald-600 text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20',
ghost: 'text-emerald-600 hover:bg-emerald-50 dark:hover:bg-emerald-900/20',
};
const sizeStyles = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base',
};
export function ComponentTemplate({
children,
className = '',
variant = 'default',
size = 'md',
onClick,
disabled = false,
}: ComponentTemplateProps) {
return (
<button
onClick={onClick}
disabled={disabled}
className={`
inline-flex items-center justify-center gap-2
rounded-lg font-medium transition-all
disabled:opacity-50 disabled:cursor-not-allowed
${variantStyles[variant]}
${sizeStyles[size]}
${className}
`}
>
{children}
</button>
);
}

View file

@ -0,0 +1,122 @@
/**
* Page Template
*
* Copy this file to create a new page:
* 1. cp src/templates/PageTemplate.tsx src/pages/MyNewPage.tsx
* 2. Replace "PageTemplate" with your page name
* 3. Update the icon, title, description
* 4. Add route in router.tsx
* 5. Add navigation item in lib/navigation.ts
*/
import { useState } from 'react';
import { FileText, Plus, Filter, Search } from 'lucide-react';
import { PageHeader, PageHeaderButton } from '../components/layout/PageHeader';
import { usePermissions } from '../hooks/usePermissions';
// Optional: Add API import
// import api from '../lib/api';
export default function PageTemplate() {
const { canAccess, isAdmin } = usePermissions();
const [searchQuery, setSearchQuery] = useState('');
const [isLoading, setIsLoading] = useState(false);
// Example data - replace with API call
const [items, setItems] = useState<any[]>([]);
// Optional: Fetch data on mount
// useEffect(() => {
// const fetchData = async () => {
// setIsLoading(true);
// try {
// const response = await api.get('/your-endpoint');
// setItems(response.data);
// } catch (error) {
// console.error('Failed to load data:', error);
// } finally {
// setIsLoading(false);
// }
// };
// fetchData();
// }, []);
return (
<div className="space-y-6">
{/* Page Header */}
<PageHeader
title="Page Title"
description="Brief description of what this page does"
icon={FileText}
iconColor="text-emerald-600"
actions={
isAdmin && (
<PageHeaderButton variant="primary" onClick={() => console.log('Add new')}>
<Plus size={18} />
Add New
</PageHeaderButton>
)
}
/>
{/* Filters & Search Bar */}
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search size={18} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-sm focus:ring-2 focus:ring-emerald-500 focus:border-emerald-500 outline-none"
/>
</div>
<button className="flex items-center gap-2 px-4 py-2.5 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 text-sm hover:bg-slate-50 dark:hover:bg-slate-700">
<Filter size={16} />
Filters
</button>
</div>
{/* Content Area */}
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 shadow-sm">
{isLoading ? (
<div className="flex items-center justify-center p-12">
<div className="animate-spin w-8 h-8 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
) : items.length === 0 ? (
/* Empty State */
<div className="flex flex-col items-center justify-center p-12 text-center">
<div className="w-16 h-16 rounded-full bg-slate-100 dark:bg-slate-700 flex items-center justify-center mb-4">
<FileText size={32} className="text-slate-400" />
</div>
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-2">
No Items Yet
</h3>
<p className="text-sm text-slate-500 dark:text-slate-400 max-w-sm mb-6">
Get started by creating your first item. Items will appear here once created.
</p>
{isAdmin && (
<button className="inline-flex items-center gap-2 px-4 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg font-medium">
<Plus size={18} />
Create First Item
</button>
)}
</div>
) : (
/* Data List/Table */
<div className="divide-y divide-slate-200 dark:divide-slate-700">
{items.map((item, index) => (
<div
key={item.id || index}
className="p-4 hover:bg-slate-50 dark:hover:bg-slate-700/50 transition-colors"
>
{/* Item content here */}
<p className="text-sm text-slate-900 dark:text-white">{item.name}</p>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,124 @@
/**
* Widget Template
*
* Copy this file to create a new dashboard widget:
* 1. cp src/templates/WidgetTemplate.tsx src/components/widgets/MyWidget.tsx
* 2. Rename the widget and update data fetching
*/
import { useState, useEffect } from 'react';
import { TrendingUp, TrendingDown, Minus, RefreshCw } from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
interface WidgetTemplateProps {
title: string;
icon: LucideIcon;
iconColor?: string;
refreshInterval?: number; // ms, 0 = no auto-refresh
}
interface WidgetData {
value: number | string;
label: string;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
}
export function WidgetTemplate({
title,
icon: Icon,
iconColor = 'text-emerald-600',
refreshInterval = 0,
}: WidgetTemplateProps) {
const [data, setData] = useState<WidgetData | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState<Date>(new Date());
const fetchData = async () => {
setIsLoading(true);
try {
// Replace with actual API call
// const response = await api.get('/widget-data');
// setData(response.data);
// Demo data
await new Promise(resolve => setTimeout(resolve, 500));
setData({
value: Math.floor(Math.random() * 100),
label: 'Items',
trend: Math.random() > 0.5 ? 'up' : 'down',
trendValue: `${Math.floor(Math.random() * 20)}%`,
});
setLastUpdated(new Date());
} catch (error) {
console.error('Widget fetch failed:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchData();
if (refreshInterval > 0) {
const interval = setInterval(fetchData, refreshInterval);
return () => clearInterval(interval);
}
}, [refreshInterval]);
const TrendIcon = data?.trend === 'up' ? TrendingUp : data?.trend === 'down' ? TrendingDown : Minus;
const trendColor = data?.trend === 'up' ? 'text-emerald-500' : data?.trend === 'down' ? 'text-red-500' : 'text-slate-400';
return (
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 shadow-sm">
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<div className={`p-2 rounded-lg bg-slate-100 dark:bg-slate-700 ${iconColor}`}>
<Icon size={18} />
</div>
<h3 className="text-sm font-medium text-slate-900 dark:text-white">{title}</h3>
</div>
<button
onClick={fetchData}
disabled={isLoading}
className="p-1.5 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-700 text-slate-400"
title="Refresh"
>
<RefreshCw size={14} className={isLoading ? 'animate-spin' : ''} />
</button>
</div>
{/* Content */}
{isLoading && !data ? (
<div className="h-16 flex items-center justify-center">
<div className="animate-spin w-5 h-5 border-2 border-emerald-500 border-t-transparent rounded-full" />
</div>
) : data ? (
<div className="space-y-2">
<div className="flex items-end gap-2">
<span className="text-3xl font-bold text-slate-900 dark:text-white">
{data.value}
</span>
<span className="text-sm text-slate-500 mb-1">{data.label}</span>
</div>
{data.trend && (
<div className={`flex items-center gap-1 text-sm ${trendColor}`}>
<TrendIcon size={14} />
<span>{data.trendValue} from last week</span>
</div>
)}
</div>
) : (
<p className="text-sm text-slate-400">No data available</p>
)}
{/* Footer */}
<div className="mt-3 pt-3 border-t border-slate-100 dark:border-slate-700">
<p className="text-[10px] text-slate-400">
Updated {lastUpdated.toLocaleTimeString()}
</p>
</div>
</div>
);
}