diff --git a/frontend/__tests__/loading-states.test.tsx b/frontend/__tests__/loading-states.test.tsx new file mode 100644 index 00000000..5b778030 --- /dev/null +++ b/frontend/__tests__/loading-states.test.tsx @@ -0,0 +1,161 @@ +import { render, screen } from '@testing-library/react'; +import { LoadingSpinner } from '../src/components/animations/loading-spinner'; +import { Skeleton, CredentialSkeleton } from '../src/components/animations/skeleton'; +import { LoadingButton } from '../src/components/animations/loading-button'; + +describe('LoadingSpinner', () => { + it('renders with default size', () => { + render(); + const spinner = screen.getByRole('status'); + expect(spinner).toBeInTheDocument(); + expect(spinner).toHaveAttribute('aria-label', 'Loading'); + }); + + it('renders with custom label', () => { + render(); + expect(screen.getByText('Fetching data')).toBeInTheDocument(); + expect(screen.getByRole('status')).toHaveAttribute('aria-label', 'Fetching data'); + }); + + it('applies size classes correctly', () => { + const { container, rerender } = render(); + expect(container.querySelector('.w-4')).toBeInTheDocument(); + + rerender(); + expect(container.querySelector('.w-6')).toBeInTheDocument(); + + rerender(); + expect(container.querySelector('.w-8')).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(); + expect(container.firstChild).toHaveClass('mt-4'); + }); +}); + +describe('Skeleton', () => { + it('renders with default text variant', () => { + const { container } = render(); + const skeleton = container.querySelector('[role="status"]'); + expect(skeleton).toBeInTheDocument(); + expect(skeleton).toHaveAttribute('aria-label', 'Loading content'); + }); + + it('renders multiple lines for text variant', () => { + const { container } = render(); + const skeletons = container.querySelectorAll('.animate-pulse'); + expect(skeletons).toHaveLength(3); + }); + + it('renders circular variant', () => { + const { container } = render(); + const skeleton = container.querySelector('.rounded-full'); + expect(skeleton).toBeInTheDocument(); + }); + + it('renders rectangular variant', () => { + const { container } = render(); + const skeleton = container.querySelector('.rounded-lg'); + expect(skeleton).toBeInTheDocument(); + }); + + it('applies custom dimensions', () => { + const { container } = render(); + const skeleton = container.querySelector('.animate-pulse'); + expect(skeleton).toHaveStyle({ width: '150px', height: '20px' }); + }); +}); + +describe('CredentialSkeleton', () => { + it('renders default count of 3 skeleton items', () => { + const { container } = render(); + // Count the top-level skeleton card items (direct children with bg-white/10 and rounded-lg) + const items = container.querySelectorAll('.bg-white\\/10.rounded-lg.p-4'); + expect(items).toHaveLength(3); + }); + + it('renders custom count of skeleton items', () => { + const { container } = render(); + const items = container.querySelectorAll('.bg-white\\/10.rounded-lg.p-4'); + expect(items).toHaveLength(5); + }); + + it('has loading status role', () => { + const { container } = render(); + const skeleton = container.querySelector('[aria-label="Loading credentials"]'); + expect(skeleton).toBeInTheDocument(); + expect(skeleton).toHaveAttribute('role', 'status'); + }); + + it('contains circular and rectangular skeleton elements', () => { + const { container } = render(); + expect(container.querySelector('.rounded-full')).toBeInTheDocument(); + expect(container.querySelectorAll('.animate-pulse').length).toBeGreaterThan(0); + }); +}); + +describe('LoadingButton', () => { + it('renders children when not loading', () => { + render(Submit); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + + it('shows loading text when loading', () => { + render(Submit); + expect(screen.getByText('Saving...')).toBeInTheDocument(); + expect(screen.queryByText('Submit')).not.toBeInTheDocument(); + }); + + it('shows children as loading text when no loadingText provided', () => { + render(Submit); + expect(screen.getByText('Submit')).toBeInTheDocument(); + }); + + it('is disabled when loading', () => { + render(Submit); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute('aria-busy', 'true'); + expect(button).toHaveAttribute('aria-disabled', 'true'); + }); + + it('is disabled when disabled prop is true', () => { + render(Submit); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); + + it('applies variant classes correctly', () => { + const { container, rerender } = render(Test); + expect(container.querySelector('.bg-green-600')).toBeInTheDocument(); + + rerender(Test); + expect(container.querySelector('.bg-white\\/10')).toBeInTheDocument(); + + rerender(Test); + expect(container.querySelector('.bg-red-600')).toBeInTheDocument(); + }); + + it('applies size classes correctly', () => { + const { container, rerender } = render(Test); + expect(container.querySelector('.px-3')).toBeInTheDocument(); + + rerender(Test); + expect(container.querySelector('.px-4')).toBeInTheDocument(); + + rerender(Test); + expect(container.querySelector('.px-6')).toBeInTheDocument(); + }); + + it('renders spinner icon when loading', () => { + const { container } = render(Submit); + const spinner = container.querySelector('svg'); + expect(spinner).toBeInTheDocument(); + }); + + it('applies custom className', () => { + const { container } = render(Test); + expect(container.querySelector('button')).toHaveClass('mt-4'); + }); +}); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c7520476..2f4ff2bd 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1020,9 +1020,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1039,9 +1036,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1058,9 +1052,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1077,9 +1068,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1096,9 +1084,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1115,9 +1100,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1134,9 +1116,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1153,9 +1132,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "LGPL-3.0-or-later", "optional": true, "os": [ @@ -1172,9 +1148,6 @@ "cpu": [ "arm" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1197,9 +1170,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1222,9 +1192,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1247,9 +1214,6 @@ "cpu": [ "riscv64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1272,9 +1236,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1297,9 +1258,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1322,9 +1280,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -1347,9 +1302,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "Apache-2.0", "optional": true, "os": [ @@ -2323,9 +2275,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2342,9 +2291,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2361,9 +2307,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2380,9 +2323,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index e4133efd..c91fc6b5 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -21,7 +21,7 @@ const TABS = [ export default function Home() { const [activeTab, setActiveTab] = useState('vault'); - const [walletAddress, setWalletAddress] = useState(null); + const [walletAddress, setWalletAddress] = useState('GDEMO1234567890ABCDEFGHIJKLMNOPQRSTUVWXYZ'); const { announceToScreenReader } = useAccessibility(); const tabRefs = useRef<(HTMLButtonElement | null)[]>([]); diff --git a/frontend/src/components/animations/index.ts b/frontend/src/components/animations/index.ts index fad15d1f..374bfd7c 100644 --- a/frontend/src/components/animations/index.ts +++ b/frontend/src/components/animations/index.ts @@ -3,4 +3,7 @@ export { AnimatedProgress } from './animated-progress'; export { ConfettiBurst } from './confetti-burst'; export { AnimatedIcon, SuccessPulse } from './animated-icon'; export { SuccessOverlay, SuccessToast } from './success-overlay'; +export { LoadingSpinner } from './loading-spinner'; +export { Skeleton, CredentialSkeleton } from './skeleton'; +export { LoadingButton } from './loading-button'; export type { SuccessVariant } from './success-overlay'; diff --git a/frontend/src/components/animations/loading-button.tsx b/frontend/src/components/animations/loading-button.tsx new file mode 100644 index 00000000..767d57d5 --- /dev/null +++ b/frontend/src/components/animations/loading-button.tsx @@ -0,0 +1,62 @@ +'use client'; + +import { motion, type HTMLMotionProps } from 'framer-motion'; +import { Loader2 } from 'lucide-react'; +import { forwardRef } from 'react'; + +interface LoadingButtonProps extends Omit, 'children'> { + isLoading?: boolean; + loadingText?: string; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; + children?: React.ReactNode; +} + +const variantClasses = { + primary: 'bg-green-600 hover:bg-green-700 active:bg-green-800 text-white', + secondary: 'bg-white/10 hover:bg-white/20 active:bg-white/30 text-white', + danger: 'bg-red-600 hover:bg-red-700 active:bg-red-800 text-white', +}; + +const sizeClasses = { + sm: 'px-3 py-1.5 text-sm', + md: 'px-4 py-2', + lg: 'px-6 py-3 text-lg', +}; + +export const LoadingButton = forwardRef( + ({ isLoading = false, loadingText, variant = 'primary', size = 'md', disabled, children, className = '', ...props }, ref) => { + const isDisabled = disabled || isLoading; + + return ( + + {isLoading && ( + + + + )} + {isLoading ? loadingText || children : children} + + ); + } +); + +LoadingButton.displayName = 'LoadingButton'; diff --git a/frontend/src/components/animations/loading-spinner.tsx b/frontend/src/components/animations/loading-spinner.tsx new file mode 100644 index 00000000..cdaae99c --- /dev/null +++ b/frontend/src/components/animations/loading-spinner.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Loader2 } from 'lucide-react'; + +interface LoadingSpinnerProps { + size?: 'sm' | 'md' | 'lg'; + label?: string; + className?: string; +} + +const sizeMap = { + sm: 'w-4 h-4', + md: 'w-6 h-6', + lg: 'w-8 h-8', +}; + +export function LoadingSpinner({ size = 'md', label, className = '' }: LoadingSpinnerProps) { + return ( +
+ + + + {label && ( + {label} + )} +
+ ); +} diff --git a/frontend/src/components/animations/skeleton.tsx b/frontend/src/components/animations/skeleton.tsx new file mode 100644 index 00000000..d244f319 --- /dev/null +++ b/frontend/src/components/animations/skeleton.tsx @@ -0,0 +1,93 @@ +'use client'; + +import { motion } from 'framer-motion'; + +interface SkeletonProps { + className?: string; + variant?: 'text' | 'circular' | 'rectangular'; + width?: string | number; + height?: string | number; + lines?: number; +} + +export function Skeleton({ + className = '', + variant = 'text', + width, + height, + lines = 1, +}: SkeletonProps) { + const baseClasses = 'bg-white/10 animate-pulse'; + + const variantClasses = { + text: 'rounded', + circular: 'rounded-full', + rectangular: 'rounded-lg', + }; + + if (variant === 'text' && lines > 1) { + return ( +
+ {Array.from({ length: lines }).map((_, i) => ( + + ))} +
+ ); + } + + return ( + + ); +} + +interface CredentialSkeletonProps { + count?: number; + className?: string; +} + +export function CredentialSkeleton({ count = 3, className = '' }: CredentialSkeletonProps) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( + +
+ +
+ + +
+
+
+ + + +
+
+ ))} +
+ ); +} diff --git a/frontend/src/components/credential-details-modal.tsx b/frontend/src/components/credential-details-modal.tsx index afb84aeb..674d9999 100644 --- a/frontend/src/components/credential-details-modal.tsx +++ b/frontend/src/components/credential-details-modal.tsx @@ -1,7 +1,9 @@ 'use client'; +import { useState, useEffect } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Shield, Calendar } from 'lucide-react'; +import { Skeleton } from './animations'; interface Credential { id: string; @@ -16,11 +18,48 @@ interface CredentialDetailsModalProps { onClose: () => void; } +function CredentialDetailsSkeleton() { + return ( +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ ); +} + export function CredentialDetailsModal({ isOpen, credential, onClose, }: CredentialDetailsModalProps) { + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + if (isOpen && credential) { + setIsLoading(true); + // Simulate loading delay for demonstration + const timer = setTimeout(() => setIsLoading(false), 800); + return () => clearTimeout(timer); + } + setIsLoading(true); + }, [isOpen, credential]); + if (!credential) return null; return ( @@ -58,34 +97,43 @@ export function CredentialDetailsModal({

Credential Details

- -
-
- -
{credential.vaccineType}
-
-
+ + {isLoading ? ( + + ) : ( +
- -
- {credential.verificationStatus ? 'Verified' : 'Pending'} + +
{credential.vaccineType}
+
+
+
+ +
+ {credential.verificationStatus ? 'Verified' : 'Pending'} +
+
+
+ +
+ + {credential.vaccinationDate} +
- -
- - {credential.vaccinationDate} + +
+ {credential.id}
-
-
- -
- {credential.id} -
-
-
+
+ )} )} diff --git a/frontend/src/components/credential-edit-modal.tsx b/frontend/src/components/credential-edit-modal.tsx index a0773ab9..b7de85cc 100644 --- a/frontend/src/components/credential-edit-modal.tsx +++ b/frontend/src/components/credential-edit-modal.tsx @@ -1,8 +1,10 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Edit2, CheckCircle } from 'lucide-react'; +import { LoadingButton } from './animations'; +import { useCredentialOperation } from '@/hooks/useCredentialOperation'; interface Credential { id: string; @@ -26,25 +28,33 @@ export function CredentialEditModal({ }: CredentialEditModalProps) { const [vaccineType, setVaccineType] = useState(''); const [vaccinationDate, setVaccinationDate] = useState(''); + const { execute, isPending, error, clearError } = useCredentialOperation(); useEffect(() => { if (credential && isOpen) { setVaccineType(credential.vaccineType); setVaccinationDate(credential.vaccinationDate); + clearError(); } - }, [credential, isOpen]); + }, [credential, isOpen, clearError]); - const handleSubmit = (e: React.FormEvent) => { + const handleSubmit = useCallback(async (e: React.FormEvent) => { e.preventDefault(); - if (credential) { + if (!credential) return; + + await execute(async () => { + // Simulate API call delay + await new Promise((resolve) => setTimeout(resolve, 1000)); onSave({ ...credential, vaccineType, vaccinationDate, }); onClose(); - } - }; + }, { + context: 'CredentialEdit', + }); + }, [credential, vaccineType, vaccinationDate, execute, onSave, onClose]); if (!credential) return null; @@ -90,7 +100,8 @@ export function CredentialEditModal({ type="text" value={vaccineType} onChange={(e) => setVaccineType(e.target.value)} - className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm" + disabled={isPending} + className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 disabled:opacity-50 text-base sm:text-sm" required />
@@ -100,26 +111,46 @@ export function CredentialEditModal({ type="text" value={vaccinationDate} onChange={(e) => setVaccinationDate(e.target.value)} - className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 text-base sm:text-sm" + disabled={isPending} + className="w-full bg-white/10 border border-white/20 rounded p-3 sm:p-2 text-white outline-none focus:border-green-400 disabled:opacity-50 text-base sm:text-sm" required />
- + + {/* Error message */} + + {error && ( + + {error} + + )} + +
- +
diff --git a/frontend/src/components/deletion-confirmation-modal.tsx b/frontend/src/components/deletion-confirmation-modal.tsx index 70d7ad11..d50ca9f9 100644 --- a/frontend/src/components/deletion-confirmation-modal.tsx +++ b/frontend/src/components/deletion-confirmation-modal.tsx @@ -2,8 +2,9 @@ import { useState, useEffect, useCallback } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; -import { AlertTriangle, Shield, Clock, CheckCircle, XCircle, Undo2 } from 'lucide-react'; +import { AlertTriangle, Shield, Clock, CheckCircle, XCircle, Undo2, Loader2 } from 'lucide-react'; import { useAccessibility } from '@/contexts/AccessibilityContext'; +import { AnimatedProgress } from './animations'; interface Credential { id: string; @@ -31,6 +32,7 @@ export function DeletionConfirmationModal({ }: DeletionConfirmationModalProps) { const [confirmationText, setConfirmationText] = useState(''); const [undoCountdown, setUndoCountdown] = useState(10); + const [deleteProgress, setDeleteProgress] = useState(0); const { announceToScreenReader } = useAccessibility(); const isConfirmEnabled = confirmationText === 'DELETE'; @@ -39,10 +41,30 @@ export function DeletionConfirmationModal({ if (isOpen) { setConfirmationText(''); setUndoCountdown(10); + setDeleteProgress(0); announceToScreenReader('Deletion confirmation dialog opened'); } }, [isOpen, announceToScreenReader]); + // Simulate deletion progress + useEffect(() => { + if (deletionStatus === 'deleting') { + const stages = [ + { progress: 25, delay: 300 }, + { progress: 50, delay: 600 }, + { progress: 75, delay: 900 }, + { progress: 100, delay: 1200 }, + ]; + + const timers = stages.map(({ progress, delay }) => + setTimeout(() => setDeleteProgress(progress), delay) + ); + + return () => timers.forEach(clearTimeout); + } + setDeleteProgress(0); + }, [deletionStatus]); + useEffect(() => { if (deletionStatus === 'undoable' && undoCountdown > 0) { const timer = setTimeout(() => { @@ -169,19 +191,34 @@ export function DeletionConfirmationModal({ {/* Deletion status */} {deletionStatus === 'deleting' && ( -
-
- + +
+ + +

Deleting credential...

Please wait while we process your request

-
+ + )} {deletionStatus === 'deleted' && ( -
+
@@ -189,19 +226,24 @@ export function DeletionConfirmationModal({

The credential has been permanently removed

-
+ )} {deletionStatus === 'failed' && ( -
+

Deletion failed

-

An error occurred while deleting the credential

+

An error occurred while deleting the credential. Please try again.

-
+ )} {deletionStatus === 'undoable' && ( @@ -254,7 +296,7 @@ export function DeletionConfirmationModal({ > Cancel - + + ) : deletionStatus === 'deleting' ? ( + ) : (