From b3948d6df9027242216779c17854f7e1c460386d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Mon, 24 Aug 2026 10:37:11 +0100 Subject: [PATCH 1/8] feat(animations): add reusable loading components Add LoadingSpinner, Skeleton, CredentialSkeleton, and LoadingButton components to the animations module. These provide consistent loading indicators across the application with proper accessibility support. --- frontend/src/components/animations/index.ts | 3 + .../components/animations/loading-button.tsx | 61 ++++++++++++ .../components/animations/loading-spinner.tsx | 32 +++++++ .../src/components/animations/skeleton.tsx | 93 +++++++++++++++++++ 4 files changed, 189 insertions(+) create mode 100644 frontend/src/components/animations/loading-button.tsx create mode 100644 frontend/src/components/animations/loading-spinner.tsx create mode 100644 frontend/src/components/animations/skeleton.tsx 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..eaeae9e0 --- /dev/null +++ b/frontend/src/components/animations/loading-button.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { motion } from 'framer-motion'; +import { Loader2 } from 'lucide-react'; +import { forwardRef } from 'react'; + +interface LoadingButtonProps extends React.ButtonHTMLAttributes { + isLoading?: boolean; + loadingText?: string; + variant?: 'primary' | 'secondary' | 'danger'; + size?: 'sm' | 'md' | 'lg'; +} + +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) => ( + +
+ +
+ + +
+
+
+ + + +
+
+ ))} +
+ ); +} From fd880f625619bf9c3dff1052b0844b9246df2182 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Mon, 24 Aug 2026 10:37:19 +0100 Subject: [PATCH 2/8] feat(credentials): add loading states to edit modal Integrate LoadingButton and useCredentialOperation hook into the credential edit modal. Form inputs are now disabled during save operations, and error messages are displayed if the save fails. --- .../src/components/credential-edit-modal.tsx | 57 ++++++++++++++----- 1 file changed, 44 insertions(+), 13 deletions(-) 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} + + )} + +
- +
From 1ffc2976bdadaba265252422640b8750477cacc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Mon, 24 Aug 2026 10:37:27 +0100 Subject: [PATCH 3/8] feat(credentials): add skeleton loading to details modal Show skeleton placeholders while credential details are loading. The skeleton mimics the layout of the actual content for a smooth visual transition when data appears. --- .../components/credential-details-modal.tsx | 92 ++++++++++++++----- 1 file changed, 70 insertions(+), 22 deletions(-) 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} -
-
-
+
+ )} )} From 19bbdae98700d7e42816542485cde23eeff2b1d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Crhemy-arc=E2=80=9D?= <“rhemaadzer@gmail.com”> Date: Mon, 24 Aug 2026 10:37:36 +0100 Subject: [PATCH 4/8] feat(credentials): enhance deletion modal with progress Add animated progress bar during credential deletion, improved spinner animation, and better visual feedback for all deletion states (idle, deleting, deleted, failed, undoable). --- .../deletion-confirmation-modal.tsx | 81 ++++++++++++++++--- 1 file changed, 69 insertions(+), 12 deletions(-) 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' ? ( + ) : (