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 (
+
+ );
+}
+
+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