Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
206 changes: 206 additions & 0 deletions frontend/src/components/AnimatedModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
import React, { useEffect, useRef } from 'react';
import { motion, AnimatePresence } from 'framer-motion';

/**
* AnimatedModal
*
* A standardized modal wrapper that provides consistent enter/exit animations
* across the application. Uses framer-motion for smooth transitions and
* respects prefers-reduced-motion for accessibility.
*
* Usage:
* ```tsx
* <AnimatedModal isOpen={showModal} onClose={() => setShowModal(false)}>
* <div className="p-6">Modal content</div>
* </AnimatedModal>
* ```
*
* Animation Pattern:
* - Backdrop: fades in/out (opacity 0 → 1)
* - Content: scales up from 95% with subtle Y translation (translateY(8px) → 0)
* - Duration: 200ms enter, 150ms exit
* - Easing: cubic-bezier(0.22, 1, 0.36, 1) for natural feel
*
* Accessibility:
* - Respects prefers-reduced-motion media query
* - Traps focus within modal when open
* - Closes on Escape key
* - Closes on backdrop click (unless disableBackdropClose is set)
*/

interface AnimatedModalProps {
/** Whether the modal is currently visible */
isOpen: boolean;
/** Callback when the modal should close */
onClose: () => void;
/** Modal content */
children: React.ReactNode;
/** Additional CSS classes for the content container */
className?: string;
/** Disable closing on backdrop click */
disableBackdropClose?: boolean;
/** Maximum width of the modal content */
maxWidth?: string;
/** Whether the modal is in a loading/executing state (prevents close) */
isProcessing?: boolean;
}

/**
* Hook to detect prefers-reduced-motion preference
*/
function usePrefersReducedMotion(): boolean {
const [prefersReduced, setPrefersReduced] = React.useState(false);

useEffect(() => {
const mql = window.matchMedia('(prefers-reduced-motion: reduce)');
setPrefersReduced(mql.matches);

const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, []);

return prefersReduced;
}

/**
* Animation variants for framer-motion
*/
function getAnimationVariants(prefersReduced: boolean) {
if (prefersReduced) {
return {
backdrop: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
content: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
},
};
}

return {
backdrop: {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0 },
transition: { duration: 0.2, ease: [0.22, 1, 0.36, 1] },
},
content: {
initial: { opacity: 0, scale: 0.95, y: 8 },
animate: { opacity: 1, scale: 1, y: 0 },
exit: { opacity: 0, scale: 0.95, y: 8 },
transition: {
duration: 0.2,
ease: [0.22, 1, 0.36, 1],
delay: 0.05,
},
},
};
}

export default function AnimatedModal({
isOpen,
onClose,
children,
className = '',
disableBackdropClose = false,
maxWidth = 'max-w-2xl',
isProcessing = false,
}: AnimatedModalProps) {
const prefersReduced = usePrefersReducedMotion();
const contentRef = useRef<HTMLDivElement>(null);

// Close on Escape key
useEffect(() => {
if (!isOpen) return;

function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape' && !isProcessing) {
onClose();
}
}

document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, isProcessing]);

// Trap focus within modal
useEffect(() => {
if (!isOpen || !contentRef.current) return;

const focusableElements = contentRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);

if (focusableElements.length === 0) return;

const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];

firstElement?.focus();

function handleTab(e: KeyboardEvent) {
if (e.key !== 'Tab') return;

if (e.shiftKey) {
if (document.activeElement === firstElement) {
e.preventDefault();
lastElement?.focus();
}
} else {
if (document.activeElement === lastElement) {
e.preventDefault();
firstElement?.focus();
}
}
}

document.addEventListener('keydown', handleTab);
return () => document.removeEventListener('keydown', handleTab);
}, [isOpen]);

const variants = getAnimationVariants(prefersReduced);

function handleBackdropClick() {
if (!disableBackdropClose && !isProcessing) {
onClose();
}
}

return (
<AnimatePresence>
{isOpen && (
<motion.div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
initial={variants.backdrop.initial}
animate={variants.backdrop.animate}
exit={variants.backdrop.exit}
transition={prefersReduced ? { duration: 0 } : variants.backdrop.transition}
onClick={handleBackdropClick}
>
{/* Backdrop */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" />

{/* Content */}
<motion.div
ref={contentRef}
className={`relative w-full ${maxWidth} bg-[var(--surface)] border border-[var(--border-hi)] rounded-2xl shadow-2xl overflow-hidden max-h-[95vh] flex flex-col ${className}`}
initial={variants.content.initial}
animate={variants.content.animate}
exit={variants.content.exit}
transition={prefersReduced ? { duration: 0 } : variants.content.transition}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
>
{children}
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
173 changes: 173 additions & 0 deletions frontend/src/components/EmployeeProfileModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import React from 'react';
import {
X,
Mail,
Wallet,
Calendar,
DollarSign,
TrendingUp,
Clock,
ExternalLink,
} from 'lucide-react';
import AnimatedModal from './AnimatedModal';
import { formatCurrency } from '../services/currencyConversion';

/**
* EmployeeProfileModal
*
* Reference integration for the standardized modal animation pattern.
* Displays employee profile information with consistent enter/exit animations.
*
* This component demonstrates:
* - Using AnimatedModal for standardized animations
* - Respecting prefers-reduced-motion
* - Proper accessibility (ARIA labels, focus management)
* - Consistent styling with the design system
*/

interface EmployeeProfileData {
id: string;
name: string;
email: string;
walletAddress: string;
position: string;
department: string;
startDate: string;
totalPaid: number;
lastPayment: string;
status: 'active' | 'inactive';
}

interface EmployeeProfileModalProps {
isOpen: boolean;
onClose: () => void;
employee: EmployeeProfileData | null;
}

export default function EmployeeProfileModal({
isOpen,
onClose,
employee,
}: EmployeeProfileModalProps) {
if (!employee) return null;

return (
<AnimatedModal isOpen={isOpen} onClose={onClose}>
{/* Modal Header */}
<div className="flex items-center justify-between px-6 pt-6 pb-4 border-b border-[var(--border)]">
<div className="min-w-0 flex-1">
<h2 className="text-lg font-black tracking-tight truncate">{employee.name}</h2>
<p className="text-xs text-[var(--muted)] font-mono mt-0.5 truncate">
{employee.position} · {employee.department}
</p>
</div>
<button
onClick={onClose}
className="p-2 rounded-lg hover:bg-white/5 text-[var(--muted)] hover:text-[var(--text)] transition-colors"
style={{ minHeight: '44px', minWidth: '44px' }}
aria-label="Close"
>
<X className="w-5 h-5" />
</button>
</div>

{/* Modal Content */}
<div className="px-6 py-6 overflow-y-auto flex-1">
<div className="flex flex-col gap-5">
{/* Status Badge */}
<div className="flex items-center gap-2">
<span
className={`px-3 py-1 rounded text-xs font-black uppercase tracking-widest border ${
employee.status === 'active'
? 'bg-[var(--success)]/20 text-[var(--success)] border-[var(--success)]/30'
: 'bg-[var(--muted)]/20 text-[var(--muted)] border-[var(--muted)]/30'
}`}
>
{employee.status}
</span>
</div>

{/* Contact Info */}
<div className="p-4 bg-black/20 border border-[var(--border)] rounded-xl">
<p className="block text-xs font-bold uppercase tracking-widest text-[var(--muted)] mb-3 ml-1">
Contact Information
</p>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 text-sm">
<Mail className="w-4 h-4 text-[var(--muted)]" />
<span className="text-[var(--text)]">{employee.email}</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Wallet className="w-4 h-4 text-[var(--muted)]" />
<span className="text-[var(--text)] font-mono text-xs break-all">
{employee.walletAddress}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Calendar className="w-4 h-4 text-[var(--muted)]" />
<span className="text-[var(--text)]">
Started {new Date(employee.startDate).toLocaleDateString()}
</span>
</div>
</div>
</div>

{/* Payment Summary */}
<div className="p-4 bg-black/20 border border-[var(--border)] rounded-xl">
<p className="block text-xs font-bold uppercase tracking-widest text-[var(--muted)] mb-3 ml-1">
Payment Summary
</p>
<div className="grid grid-cols-2 gap-4">
<div>
<div className="flex items-center gap-2 text-[var(--muted)] text-xs mb-1">
<DollarSign className="w-3.5 h-3.5" />
Total Paid
</div>
<div className="text-lg font-black text-[var(--success)]">
{formatCurrency(employee.totalPaid, 'USD')}
</div>
</div>
<div>
<div className="flex items-center gap-2 text-[var(--muted)] text-xs mb-1">
<Clock className="w-3.5 h-3.5" />
Last Payment
</div>
<div className="text-sm font-bold">
{employee.lastPayment
? new Date(employee.lastPayment).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
})
: '—'}
</div>
</div>
</div>
</div>

{/* Actions */}
<div className="flex gap-3 mt-2">
<button
onClick={onClose}
className="flex-1 py-3 border border-[var(--border-hi)] rounded-xl text-sm font-bold text-[var(--muted)] hover:text-[var(--text)] hover:bg-white/5 transition-all uppercase tracking-widest"
style={{ minHeight: '44px' }}
>
Close
</button>
<a
href={`https://stellar.expert/explorer/public/account/${employee.walletAddress}`}
target="_blank"
rel="noopener noreferrer"
className="flex-1 flex items-center justify-center gap-2 py-3 bg-[var(--accent)]/20 text-[var(--accent)] border border-[var(--accent)]/40 rounded-xl text-sm font-black hover:bg-[var(--accent)] hover:text-black transition-all uppercase tracking-widest"
style={{ minHeight: '44px' }}
>
<TrendingUp className="w-4 h-4" />
View on Explorer
<ExternalLink className="w-3.5 h-3.5" />
</a>
</div>
</div>
</div>
</AnimatedModal>
);
}
Loading
Loading