diff --git a/tenant-dashboard/src/app/chat/loading.tsx b/tenant-dashboard/src/app/chat/loading.tsx new file mode 100644 index 0000000..f351383 --- /dev/null +++ b/tenant-dashboard/src/app/chat/loading.tsx @@ -0,0 +1,36 @@ +import { Skeleton } from '@/components/loading/Skeleton'; + +export default function ChatLoading() { + return ( +
+ {/* Sidebar skeleton */} +
+ +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+
+ + {/* Chat area skeleton */} +
+ {/* Messages area */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+
+ ))} +
+ + {/* Input area skeleton */} +
+ +
+
+
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/app/cost/loading.tsx b/tenant-dashboard/src/app/cost/loading.tsx new file mode 100644 index 0000000..6f58931 --- /dev/null +++ b/tenant-dashboard/src/app/cost/loading.tsx @@ -0,0 +1,40 @@ +import { Skeleton } from '@/components/loading/Skeleton'; +import { GridSkeleton } from '@/components/loading/DataTable'; + +export default function CostLoading() { + return ( +
+ {/* Header */} +
+ + +
+ + {/* Summary cards */} +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ + +
+ + +
+ ))} +
+ + {/* Chart */} +
+ + +
+ + {/* Table */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/app/dashboard/loading.tsx b/tenant-dashboard/src/app/dashboard/loading.tsx new file mode 100644 index 0000000..cdf54b0 --- /dev/null +++ b/tenant-dashboard/src/app/dashboard/loading.tsx @@ -0,0 +1,31 @@ +import { SectionLoader } from '@/components/loading/PageLoader'; +import { Skeleton } from '@/components/loading/Skeleton'; + +export default function DashboardLoading() { + return ( +
+ {/* Header skeleton */} +
+ + +
+ + {/* Stats cards skeleton */} +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + {/* Chart skeleton */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/app/globals.css b/tenant-dashboard/src/app/globals.css index 859f9c3..c25eadf 100644 --- a/tenant-dashboard/src/app/globals.css +++ b/tenant-dashboard/src/app/globals.css @@ -118,6 +118,15 @@ } } +@keyframes shimmer { + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } +} + .animate-fade-in { animation: fade-in 0.3s ease-out; } @@ -130,6 +139,17 @@ animation: pulse-subtle 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; } +.animate-shimmer { + background: linear-gradient( + 90deg, + transparent 25%, + rgba(255, 255, 255, 0.2) 50%, + transparent 75% + ); + background-size: 200% 100%; + animation: shimmer 1.5s ease-in-out infinite; +} + /* Focus styles for accessibility */ .focus-ring { @apply focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background; diff --git a/tenant-dashboard/src/app/loading.tsx b/tenant-dashboard/src/app/loading.tsx new file mode 100644 index 0000000..3e27435 --- /dev/null +++ b/tenant-dashboard/src/app/loading.tsx @@ -0,0 +1,5 @@ +import { PageLoader } from '@/components/loading/PageLoader'; + +export default function Loading() { + return ; +} \ No newline at end of file diff --git a/tenant-dashboard/src/app/settings/loading.tsx b/tenant-dashboard/src/app/settings/loading.tsx new file mode 100644 index 0000000..d7e6fda --- /dev/null +++ b/tenant-dashboard/src/app/settings/loading.tsx @@ -0,0 +1,30 @@ +import { Skeleton } from '@/components/loading/Skeleton'; + +export default function SettingsLoading() { + return ( +
+
+ + +
+ +
+ {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ + +
+
+
+ + +
+
+ ))} +
+
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/components/loading/DataTable.tsx b/tenant-dashboard/src/components/loading/DataTable.tsx new file mode 100644 index 0000000..cf75edc --- /dev/null +++ b/tenant-dashboard/src/components/loading/DataTable.tsx @@ -0,0 +1,93 @@ +'use client'; + +import React from 'react'; +import { Skeleton } from './Skeleton'; +import { cn } from '@/lib/utils'; + +interface TableSkeletonProps { + rows?: number; + columns?: number; + showHeader?: boolean; + className?: string; +} + +export function TableSkeleton({ + rows = 5, + columns = 4, + showHeader = true, + className, +}: TableSkeletonProps) { + return ( +
+
+
+ + {showHeader && ( + + + {Array.from({ length: columns }).map((_, i) => ( + + ))} + + + )} + + {Array.from({ length: rows }).map((_, rowIndex) => ( + + {Array.from({ length: columns }).map((_, colIndex) => ( + + ))} + + ))} + +
+ +
+ +
+
+
+
+ ); +} + +export function GridSkeleton({ + items = 6, + columns = 3, + className, +}: { + items?: number; + columns?: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: items }).map((_, i) => ( +
+ + +
+ + +
+
+ ))} +
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/components/loading/LoadingButton.tsx b/tenant-dashboard/src/components/loading/LoadingButton.tsx new file mode 100644 index 0000000..5d2c983 --- /dev/null +++ b/tenant-dashboard/src/components/loading/LoadingButton.tsx @@ -0,0 +1,47 @@ +'use client'; + +import React from 'react'; +import { Button, ButtonProps } from '@/components/ui/button'; +import { LoadingSpinner } from './LoadingSpinner'; +import { cn } from '@/lib/utils'; + +interface LoadingButtonProps extends ButtonProps { + loading?: boolean; + loadingText?: string; +} + +export function LoadingButton({ + children, + loading = false, + loadingText, + disabled, + className, + ...props +}: LoadingButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/components/loading/LoadingSpinner.tsx b/tenant-dashboard/src/components/loading/LoadingSpinner.tsx new file mode 100644 index 0000000..765116c --- /dev/null +++ b/tenant-dashboard/src/components/loading/LoadingSpinner.tsx @@ -0,0 +1,37 @@ +'use client'; + +import React from 'react'; +import { Loader2 } from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg' | 'xl'; + className?: string; + label?: string; +} + +const sizeClasses = { + sm: 'h-4 w-4', + md: 'h-6 w-6', + lg: 'h-8 w-8', + xl: 'h-12 w-12', +}; + +export function LoadingSpinner({ + size = 'md', + className, + label = 'Loading...' +}: LoadingSpinnerProps) { + return ( +
+ + {label} +
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/components/loading/PageLoader.tsx b/tenant-dashboard/src/components/loading/PageLoader.tsx new file mode 100644 index 0000000..ed35c04 --- /dev/null +++ b/tenant-dashboard/src/components/loading/PageLoader.tsx @@ -0,0 +1,45 @@ +'use client'; + +import React from 'react'; +import { LoadingSpinner } from './LoadingSpinner'; + +interface PageLoaderProps { + message?: string; +} + +export function PageLoader({ message = 'Loading page...' }: PageLoaderProps) { + return ( +
+ +

+ {message} +

+
+ ); +} + +export function SectionLoader({ message = 'Loading...' }: PageLoaderProps) { + return ( +
+ + {message && ( +

+ {message} +

+ )} +
+ ); +} + +export function InlineLoader({ message }: PageLoaderProps) { + return ( +
+ + {message && ( + + {message} + + )} +
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/components/loading/Skeleton.tsx b/tenant-dashboard/src/components/loading/Skeleton.tsx new file mode 100644 index 0000000..c58d042 --- /dev/null +++ b/tenant-dashboard/src/components/loading/Skeleton.tsx @@ -0,0 +1,84 @@ +'use client'; + +import React from 'react'; +import { cn } from '@/lib/utils'; + +interface SkeletonProps extends React.HTMLAttributes { + variant?: 'text' | 'circular' | 'rectangular' | 'rounded'; + width?: string | number; + height?: string | number; + animation?: 'pulse' | 'wave' | 'none'; +} + +export function Skeleton({ + className, + variant = 'rectangular', + width, + height, + animation = 'pulse', + ...props +}: SkeletonProps) { + const variantClasses = { + text: 'rounded', + circular: 'rounded-full', + rectangular: 'rounded-none', + rounded: 'rounded-md', + }; + + const animationClasses = { + pulse: 'animate-pulse', + wave: 'animate-shimmer', + none: '', + }; + + return ( +
+ ); +} + +export function SkeletonText({ + lines = 3, + className +}: { + lines?: number; + className?: string; +}) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); +} + +export function SkeletonCard({ className }: { className?: string }) { + return ( +
+ +
+ + + + +
+
+ ); +} \ No newline at end of file diff --git a/tenant-dashboard/src/hooks/useLoadingState.ts b/tenant-dashboard/src/hooks/useLoadingState.ts new file mode 100644 index 0000000..7f2cc41 --- /dev/null +++ b/tenant-dashboard/src/hooks/useLoadingState.ts @@ -0,0 +1,68 @@ +'use client'; + +import { useState, useCallback, useRef, useEffect } from 'react'; + +interface LoadingState { + isLoading: boolean; + error: Error | null; + startLoading: () => void; + stopLoading: () => void; + setError: (error: Error | null) => void; + reset: () => void; +} + +export function useLoadingState(initialLoading = false): LoadingState { + const [isLoading, setIsLoading] = useState(initialLoading); + const [error, setError] = useState(null); + + const startLoading = useCallback(() => { + setIsLoading(true); + setError(null); + }, []); + + const stopLoading = useCallback(() => { + setIsLoading(false); + }, []); + + const reset = useCallback(() => { + setIsLoading(false); + setError(null); + }, []); + + return { + isLoading, + error, + startLoading, + stopLoading, + setError, + reset, + }; +} + +export function useDelayedLoading(delay = 200) { + const [showLoading, setShowLoading] = useState(false); + const timeoutRef = useRef(); + + const startLoading = useCallback(() => { + timeoutRef.current = setTimeout(() => { + setShowLoading(true); + }, delay); + }, [delay]); + + const stopLoading = useCallback(() => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + setShowLoading(false); + }, []); + + useEffect(() => { + return () => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return { showLoading, startLoading, stopLoading }; +} \ No newline at end of file