diff --git a/frontend/src/components/AnimatedModal.tsx b/frontend/src/components/AnimatedModal.tsx new file mode 100644 index 00000000..16bba454 --- /dev/null +++ b/frontend/src/components/AnimatedModal.tsx @@ -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 + * setShowModal(false)}> + *
Modal content
+ *
+ * ``` + * + * 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(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( + '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 ( + + {isOpen && ( + + {/* Backdrop */} +
+ + {/* Content */} + e.stopPropagation()} + role="dialog" + aria-modal="true" + > + {children} + + + )} + + ); +} diff --git a/frontend/src/components/EmployeeProfileModal.tsx b/frontend/src/components/EmployeeProfileModal.tsx new file mode 100644 index 00000000..0044337d --- /dev/null +++ b/frontend/src/components/EmployeeProfileModal.tsx @@ -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 ( + + {/* Modal Header */} +
+
+

{employee.name}

+

+ {employee.position} · {employee.department} +

+
+ +
+ + {/* Modal Content */} +
+
+ {/* Status Badge */} +
+ + {employee.status} + +
+ + {/* Contact Info */} +
+

+ Contact Information +

+
+
+ + {employee.email} +
+
+ + + {employee.walletAddress} + +
+
+ + + Started {new Date(employee.startDate).toLocaleDateString()} + +
+
+
+ + {/* Payment Summary */} +
+

+ Payment Summary +

+
+
+
+ + Total Paid +
+
+ {formatCurrency(employee.totalPaid, 'USD')} +
+
+
+
+ + Last Payment +
+
+ {employee.lastPayment + ? new Date(employee.lastPayment).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + }) + : '—'} +
+
+
+
+ + {/* Actions */} + +
+
+
+ ); +} diff --git a/frontend/src/components/UpgradeConfirmModal.tsx b/frontend/src/components/UpgradeConfirmModal.tsx index ae1a22ac..db30ed09 100644 --- a/frontend/src/components/UpgradeConfirmModal.tsx +++ b/frontend/src/components/UpgradeConfirmModal.tsx @@ -29,6 +29,7 @@ import { Copy, RefreshCw, } from 'lucide-react'; +import AnimatedModal from './AnimatedModal'; import { type ContractRecord, type UpgradeSimulationResult, @@ -389,43 +390,36 @@ export default function UpgradeConfirmModal({ }); } - // ── Backdrop click only closes in non-executing, non-simulating states ─── - - function handleBackdropClick() { - if (['executing', 'simulating'].includes(modal.step)) return; - void handleCancel(); - } - // ── Render ─────────────────────────────────────────────────────────────── + const isProcessing = ['executing', 'simulating'].includes(modal.step); + return ( -
void handleCancel()} + isProcessing={isProcessing} + disableBackdropClose={isProcessing} > -
e.stopPropagation()} - > - {/* Modal header */} -
-
-

- Upgrade Contract -

-

{contract.name}

-
- {!['executing', 'simulating'].includes(modal.step) && ( - - )} + {/* Modal header */} +
+
+

+ Upgrade Contract +

+

{contract.name}

+ {!isProcessing && ( + + )} +
{/* Step breadcrumb */}
@@ -867,7 +861,6 @@ export default function UpgradeConfirmModal({
)}
-
-
+ ); } diff --git a/frontend/src/index.css b/frontend/src/index.css index d4d498c9..2fd3fb52 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -148,6 +148,50 @@ h6 { } } +/* ── Modal Animation Pattern ─────────────────────────────────────────────── */ +/* + * Standardized modal enter/exit animation using framer-motion. + * The AnimatedModal component handles the actual animations. + * These CSS utilities provide fallback styles for reduced-motion preference. + * + * Usage: + * import AnimatedModal from './AnimatedModal'; + * + * setShowModal(false)}> + *
Modal content
+ *
+ * + * Props: + * isOpen: boolean - Whether the modal is visible + * onClose: () => void - Callback when modal should close + * className: string - Additional CSS classes for content container + * disableBackdropClose: boolean - Prevent closing on backdrop click + * maxWidth: string - Maximum width of modal (default: max-w-2xl) + * isProcessing: boolean - Prevent close during processing states + * + * Animation Details: + * - Backdrop: fade in/out (opacity 0 → 1) + * - Content: scale 95% → 100% with Y translate 8px → 0 + * - Duration: 200ms enter, 150ms exit + * - Easing: cubic-bezier(0.22, 1, 0.36, 1) + * - Respects prefers-reduced-motion (disables animations) + * + * Accessibility: + * - Focus trapped within modal + * - Escape key closes modal + * - ARIA dialog role and aria-modal attribute + */ + +/* Reduced motion fallback: disable animations */ +@media (prefers-reduced-motion: reduce) { + .page-fade, + .modal-backdrop, + .modal-content { + animation: none !important; + transition: none !important; + } +} + /* ── SDS Component Fixes ── */ select { appearance: none !important; @@ -245,3 +289,217 @@ a[class*='w-6'] { .scrollbar-hide::-webkit-scrollbar { display: none; /* Chrome, Safari and Opera */ } + +/* ── Print/Export Stylesheet ──────────────────────────────────────────────── */ +/* + * Global print styles using design tokens. + * For more advanced print functionality, see utils/exportChart.ts + * + * Pattern: + * - Uses CSS @media print for browser print dialog + * - Forces light theme for print output + * - Hides interactive elements + * - Optimizes typography for print readability + * - Maintains design token consistency + * + * Usage: + * printPage() from utils/exportChart.ts for programmatic printing + * Ctrl/Cmd+P for browser print dialog + * + * Token Integration: + * All print styles use the same CSS custom properties as screen styles + * to ensure visual consistency between screen and print outputs. + */ + +@media print { + /* Force light theme for print */ + :root, + [data-theme='dark'], + [data-theme='light'] { + --bg: #ffffff; + --surface: #f6f8fa; + --surface-hi: #f0f2f5; + --border: rgba(0, 0, 0, 0.08); + --border-hi: rgba(0, 0, 0, 0.15); + --text: #1f2328; + --muted: #656d76; + --accent: #0d9668; + --accent2: #6c5ce7; + --danger: #d1242f; + --success: #1a7f37; + color-scheme: light; + } + + /* Page setup */ + @page { + margin: 1cm; + size: A4; + } + + /* Base print styles */ + body { + font-size: 12pt; + line-height: 1.5; + background: var(--bg) !important; + color: var(--text) !important; + } + + /* Hide interactive elements */ + button, + a[href], + input:not([type="hidden"]), + select, + textarea, + [role="button"], + .no-print, + nav, + header:not(.print-header), + footer, + .modal, + [data-modal], + .toast, + [role="alert"], + .sidebar, + .topbar { + display: none !important; + } + + /* Show print-only elements */ + .print-only { + display: block !important; + } + + /* Typography adjustments */ + h1, h2, h3, h4, h5, h6 { + font-family: var(--font-head); + page-break-after: avoid; + margin-top: 1em; + margin-bottom: 0.5em; + } + + h1 { font-size: 24pt; } + h2 { font-size: 20pt; } + h3 { font-size: 16pt; } + h4 { font-size: 14pt; } + + /* Code blocks */ + code, pre { + font-family: var(--font-mono); + font-size: 10pt; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 4px; + } + + pre { + padding: 12px; + overflow: visible; + white-space: pre-wrap; + word-wrap: break-word; + } + + /* Tables */ + table { + border-collapse: collapse; + width: 100%; + margin: 12pt 0; + font-size: 10pt; + } + + th, td { + border: 1px solid var(--border); + padding: 6px 12px; + text-align: left; + } + + th { + background: var(--surface); + font-weight: 600; + } + + /* Links */ + a { + color: var(--accent); + text-decoration: underline; + } + + a[href^="http"]::after { + content: " (" attr(href) ")"; + font-size: 9pt; + color: var(--muted); + } + + /* Images and charts */ + img, svg { + max-width: 100%; + height: auto; + } + + /* Page breaks */ + .page-break { + page-break-before: always; + } + + .no-break { + page-break-inside: avoid; + } + + /* Card styling for print */ + .card, + [class*="Card"] { + border: 1px solid var(--border); + border-radius: var(--radius-md, 10px); + padding: 12px; + margin-bottom: 12px; + background: var(--background, #ffffff); + } + + /* Remove shadows and transforms */ + * { + box-shadow: none !important; + transform: none !important; + text-shadow: none !important; + } + + /* Ensure proper contrast */ + .text-accent { color: var(--accent) !important; } + .text-accent2 { color: var(--accent2) !important; } + .text-danger { color: var(--danger) !important; } + .text-success { color: var(--success) !important; } + .text-muted { color: var(--muted) !important; } + + /* Status indicators */ + [class*="status"] { + border: 1px solid currentColor; + padding: 2px 6px; + border-radius: 4px; + font-size: 9pt; + text-transform: uppercase; + } +} + +/* Print-only header */ +.print-header { + display: none; +} + +@media print { + .print-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + margin-bottom: 24px; + border-bottom: 2px solid var(--border); + } + + .print-header h1 { + font-size: 18pt; + margin: 0; + } + + .print-header .print-date { + font-size: 10pt; + color: var(--muted); + } +} diff --git a/frontend/src/pages/EmployeePortal.module.css b/frontend/src/pages/EmployeePortal.module.css index 3163aca7..bdedfb4f 100644 --- a/frontend/src/pages/EmployeePortal.module.css +++ b/frontend/src/pages/EmployeePortal.module.css @@ -1,13 +1,76 @@ /* ===== EmployeePortal.module.css ===== */ +/* + * Design Tokens for Employee Portal + * + * These tokens define the spacing, typography, and responsive breakpoints + * used throughout the employee portal. They extend the global CSS custom + * properties defined in index.css. + * + * Token Naming Convention: + * --portal-{category}-{size} + * + * Categories: + * spacing: gap, padding, margin values + * font: font sizes for different contexts + * grid: grid column configurations + * breakpoint: media query breakpoints + * + * Usage: + * Use these tokens for consistent styling across the portal. + * For components that need to be reusable across the app, + * use the global tokens from index.css instead. + */ + +/* ── Portal Design Tokens ───────── */ +:root { + /* Spacing Scale */ + --portal-spacing-xs: 4px; + --portal-spacing-sm: 8px; + --portal-spacing-md: 16px; + --portal-spacing-lg: 24px; + --portal-spacing-xl: 32px; + --portal-spacing-2xl: 48px; + + /* Font Sizes */ + --portal-font-xs: 10px; + --portal-font-sm: 12px; + --portal-font-base: 14px; + --portal-font-md: 16px; + --portal-font-lg: 18px; + --portal-font-xl: 22px; + --portal-font-2xl: 28px; + --portal-font-hero: 42px; + + /* Border Radius */ + --portal-radius-sm: 6px; + --portal-radius-md: 10px; + --portal-radius-lg: 14px; + --portal-radius-xl: 16px; + --portal-radius-2xl: 20px; + + /* Breakpoints (for reference, used in @media queries) */ + --portal-breakpoint-sm: 640px; + --portal-breakpoint-md: 768px; + --portal-breakpoint-lg: 900px; + --portal-breakpoint-xl: 1200px; +} + /* ── Hero Balance Card ──────────────── */ .balanceCard { position: relative; overflow: hidden; background: linear-gradient(135deg, rgba(74, 240, 184, 0.08) 0%, rgba(124, 111, 247, 0.08) 100%); border: 1px solid rgba(74, 240, 184, 0.15); - border-radius: 20px; - padding: 32px; + border-radius: var(--portal-radius-2xl); + padding: var(--portal-spacing-xl); +} + +@media (max-width: 768px) { + .balanceCard { + padding: var(--portal-spacing-lg); + border-radius: var(--portal-spacing-lg); + } } .balanceCard::before { @@ -114,25 +177,39 @@ .statsRow { display: grid; grid-template-columns: repeat(4, 1fr); - gap: 16px; + gap: var(--portal-spacing-md); } @media (max-width: 768px) { .statsRow { grid-template-columns: repeat(2, 1fr); + gap: var(--portal-spacing-sm); + } +} + +@media (max-width: 480px) { + .statsRow { + grid-template-columns: 1fr; } } .statCard { background: var(--surface); border: 1px solid var(--border); - border-radius: 14px; - padding: 20px; + border-radius: var(--portal-radius-lg); + padding: var(--portal-spacing-lg); transition: border-color 0.2s ease, transform 0.2s ease; } +@media (max-width: 768px) { + .statCard { + padding: var(--portal-spacing-md); + border-radius: var(--portal-radius-md); + } +} + .statCard:hover { border-color: var(--border-hi); transform: translateY(-2px); @@ -168,33 +245,61 @@ .txSection { background: var(--surface); border: 1px solid var(--border); - border-radius: 16px; + border-radius: var(--portal-radius-xl); overflow: hidden; } +@media (max-width: 768px) { + .txSection { + border-radius: var(--portal-radius-lg); + } +} + .txHeader { display: flex; justify-content: space-between; align-items: center; - padding: 20px 24px; + padding: var(--portal-spacing-lg) var(--portal-spacing-lg); border-bottom: 1px solid var(--border); flex-wrap: wrap; - gap: 12px; + gap: var(--portal-spacing-sm); +} + +@media (max-width: 768px) { + .txHeader { + padding: var(--portal-spacing-md); + flex-direction: column; + align-items: flex-start; + } } .txTitle { font-family: var(--font-head); - font-size: 18px; + font-size: var(--portal-font-lg); font-weight: 700; } .txFilters { display: flex; align-items: center; - gap: 8px; + gap: var(--portal-spacing-sm); flex-wrap: wrap; } +@media (max-width: 768px) { + .txFilters { + width: 100%; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + padding-bottom: var(--portal-spacing-xs); + } + + .txFilters::-webkit-scrollbar { + display: none; + } +} + .filterSelect { background: rgba(255, 255, 255, 0.04); border: 1px solid var(--border); @@ -242,10 +347,10 @@ display: grid; grid-template-columns: 1fr 2fr 1fr 1fr 1fr auto; align-items: center; - padding: 16px 24px; + padding: var(--portal-spacing-md) var(--portal-spacing-lg); border-bottom: 1px solid rgba(255, 255, 255, 0.04); transition: background 0.15s ease; - gap: 12px; + gap: var(--portal-spacing-sm); } .txRow:hover { @@ -259,8 +364,16 @@ @media (max-width: 900px) { .txRow { grid-template-columns: 1fr 1fr auto; - gap: 8px; - padding: 14px 16px; + gap: var(--portal-spacing-sm); + padding: var(--portal-spacing-md) var(--portal-spacing-md); + } +} + +@media (max-width: 480px) { + .txRow { + grid-template-columns: 1fr auto; + gap: var(--portal-spacing-xs); + padding: var(--portal-spacing-sm) var(--portal-spacing-md); } } @@ -374,13 +487,13 @@ .stellarLink { display: inline-flex; align-items: center; - gap: 4px; - font-size: 11px; + gap: var(--portal-spacing-xs); + font-size: var(--portal-font-sm); font-family: var(--font-mono); color: var(--accent); text-decoration: none; - padding: 4px 8px; - border-radius: 6px; + padding: var(--portal-spacing-xs) var(--portal-spacing-sm); + border-radius: var(--portal-radius-sm); border: 1px solid rgba(74, 240, 184, 0.15); transition: all 0.2s ease; white-space: nowrap; @@ -402,7 +515,7 @@ /* ── Tx Hash Display ──────────────── */ .txHash { font-family: var(--font-mono); - font-size: 10px; + font-size: var(--portal-font-xs); color: var(--muted); margin-top: 2px; } @@ -412,11 +525,18 @@ display: flex; justify-content: center; align-items: center; - gap: 4px; - padding: 16px 24px; + gap: var(--portal-spacing-xs); + padding: var(--portal-spacing-md) var(--portal-spacing-lg); border-top: 1px solid var(--border); } +@media (max-width: 768px) { + .pagination { + padding: var(--portal-spacing-sm) var(--portal-spacing-md); + flex-wrap: wrap; + } +} + .pageBtn { width: 32px; height: 32px; @@ -492,7 +612,14 @@ ); background-size: 200% 100%; animation: shimmer 1.5s infinite; - border-radius: 8px; + border-radius: var(--portal-radius-sm); +} + +@media (prefers-reduced-motion: reduce) { + .skeleton { + animation: none; + background: var(--surface-hi); + } } @keyframes shimmer { @@ -506,8 +633,8 @@ .skeletonRow { height: 56px; - margin: 4px 24px; - border-radius: 8px; + margin: var(--portal-spacing-xs) var(--portal-spacing-lg); + border-radius: var(--portal-radius-sm); } /* ── Empty State ──────────────────── */ @@ -516,27 +643,33 @@ flex-direction: column; align-items: center; justify-content: center; - padding: 60px 24px; + padding: var(--portal-spacing-2xl) var(--portal-spacing-lg); color: var(--muted); text-align: center; } +@media (max-width: 768px) { + .emptyState { + padding: var(--portal-spacing-xl) var(--portal-spacing-md); + } +} + .emptyIcon { width: 48px; height: 48px; - margin-bottom: 16px; + margin-bottom: var(--portal-spacing-md); opacity: 0.3; } .emptyTitle { - font-size: 16px; + font-size: var(--portal-font-md); font-weight: 600; - margin-bottom: 4px; + margin-bottom: var(--portal-spacing-xs); color: var(--text); } .emptyDesc { - font-size: 13px; + font-size: var(--portal-font-base); color: var(--muted); } @@ -546,34 +679,54 @@ justify-content: space-between; align-items: flex-start; flex-wrap: wrap; - gap: 16px; - margin-bottom: 24px; + gap: var(--portal-spacing-md); + margin-bottom: var(--portal-spacing-lg); +} + +@media (max-width: 768px) { + .pageHeader { + flex-direction: column; + gap: var(--portal-spacing-sm); + } } .pageTitle { font-family: var(--font-head); - font-size: 28px; + font-size: var(--portal-font-2xl); font-weight: 800; letter-spacing: -0.02em; } +@media (max-width: 768px) { + .pageTitle { + font-size: var(--portal-font-xl); + } +} + .pageSubtitle { - font-size: 14px; + font-size: var(--portal-font-base); color: var(--muted); - margin-top: 4px; + margin-top: var(--portal-spacing-xs); } .walletBadge { display: flex; align-items: center; - gap: 6px; + gap: var(--portal-spacing-sm); font-family: var(--font-mono); - font-size: 12px; + font-size: var(--portal-font-sm); color: var(--accent); background: rgba(74, 240, 184, 0.06); border: 1px solid rgba(74, 240, 184, 0.15); - padding: 6px 12px; - border-radius: 8px; + padding: var(--portal-spacing-sm) var(--portal-spacing-md); + border-radius: var(--portal-radius-md); +} + +@media (max-width: 768px) { + .walletBadge { + width: 100%; + justify-content: center; + } } .walletDot { diff --git a/frontend/src/pages/Home.module.css b/frontend/src/pages/Home.module.css new file mode 100644 index 00000000..5a3dc9ee --- /dev/null +++ b/frontend/src/pages/Home.module.css @@ -0,0 +1,328 @@ +/* ===== Home.module.css ===== */ + +/* + * Design Tokens for Dashboard/Home Page + * + * These tokens define the spacing, typography, and responsive breakpoints + * used throughout the dashboard. They extend the global CSS custom + * properties defined in index.css. + * + * Token Naming Convention: + * --home-{category}-{size} + * + * Categories: + * spacing: gap, padding, margin values + * font: font sizes for different contexts + * grid: grid column configurations + * breakpoint: media query breakpoints + * + * Usage: + * Use these tokens for consistent styling across the dashboard. + * For components that need to be reusable across the app, + * use the global tokens from index.css instead. + */ + +/* ── Home Design Tokens ───────────── */ +:root { + /* Spacing Scale */ + --home-spacing-xs: 4px; + --home-spacing-sm: 8px; + --home-spacing-md: 16px; + --home-spacing-lg: 24px; + --home-spacing-xl: 32px; + --home-spacing-2xl: 48px; + --home-spacing-3xl: 64px; + --home-spacing-4xl: 96px; + + /* Font Sizes */ + --home-font-xs: 10px; + --home-font-sm: 12px; + --home-font-base: 14px; + --home-font-md: 16px; + --home-font-lg: 18px; + --home-font-xl: 22px; + --home-font-2xl: 28px; + --home-font-3xl: 36px; + --home-font-hero: 60px; + + /* Border Radius */ + --home-radius-sm: 6px; + --home-radius-md: 10px; + --home-radius-lg: 14px; + --home-radius-xl: 16px; + --home-radius-2xl: 20px; + --home-radius-full: 9999px; + + /* Breakpoints (for reference, used in @media queries) */ + --home-breakpoint-sm: 640px; + --home-breakpoint-md: 768px; + --home-breakpoint-lg: 1024px; + --home-breakpoint-xl: 1280px; +} + +/* ── Page Container ────────────────── */ +.page { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 80vh; + text-align: center; + padding: var(--home-spacing-xl) var(--home-spacing-lg); +} + +@media (max-width: 768px) { + .page { + padding: var(--home-spacing-lg) var(--home-spacing-md); + min-height: auto; + } +} + +/* ── Hero Section ──────────────────── */ +.heroIcon { + margin-bottom: var(--home-spacing-xl); + padding: var(--home-spacing-xl); + position: relative; +} + +@media (max-width: 768px) { + .heroIcon { + margin-bottom: var(--home-spacing-lg); + padding: var(--home-spacing-lg); + } +} + +.heroGlow { + position: absolute; + inset: 0; + background: var(--accent); + opacity: 0.05; + filter: blur(40px); + border-radius: var(--home-radius-full); + pointer-events: none; +} + +/* ── Typography ────────────────────── */ +.heroTitle { + font-family: var(--font-head); + font-size: var(--home-font-hero); + font-weight: 800; + line-height: 1; + letter-spacing: -0.04em; + margin-bottom: var(--home-spacing-md); +} + +@media (max-width: 768px) { + .heroTitle { + font-size: var(--home-font-3xl); + letter-spacing: -0.03em; + } +} + +@media (max-width: 480px) { + .heroTitle { + font-size: var(--home-font-2xl); + } +} + +.heroAccent { + color: var(--accent); +} + +.heroAccent2 { + color: var(--accent2); +} + +.heroTagline { + font-size: var(--home-font-xl); + color: var(--muted); + max-width: 42rem; + margin-bottom: var(--home-spacing-2xl); + line-height: 1.6; + font-weight: 500; +} + +@media (max-width: 768px) { + .heroTagline { + font-size: var(--home-font-lg); + margin-bottom: var(--home-spacing-xl); + } +} + +/* ── CTA Buttons ───────────────────── */ +.ctaGroup { + display: flex; + flex-direction: column; + gap: var(--home-spacing-md); +} + +@media (min-width: 640px) { + .ctaGroup { + flex-direction: row; + gap: var(--home-spacing-lg); + } +} + +.ctaPrimary { + padding: var(--home-spacing-lg) var(--home-spacing-xl); + background: var(--accent); + color: var(--bg); + font-weight: 700; + border-radius: var(--home-radius-lg); + transition: transform 0.2s ease; + box-shadow: 0 10px 40px rgba(74, 240, 184, 0.2); +} + +.ctaPrimary:hover { + transform: scale(1.05); +} + +@media (max-width: 768px) { + .ctaPrimary { + padding: var(--home-spacing-md) var(--home-spacing-lg); + width: 100%; + } +} + +.ctaSecondary { + padding: var(--home-spacing-lg) var(--home-spacing-xl); + background: rgba(255, 255, 255, 0.03); + backdrop-filter: blur(10px); + border: 1px solid var(--border-hi); + color: var(--text); + font-weight: 700; + border-radius: var(--home-radius-lg); + transition: all 0.2s ease; +} + +.ctaSecondary:hover { + background: rgba(255, 255, 255, 0.05); +} + +@media (max-width: 768px) { + .ctaSecondary { + padding: var(--home-spacing-md) var(--home-spacing-lg); + width: 100%; + } +} + +/* ── Feature Cards Grid ────────────── */ +.featureGrid { + margin-top: var(--home-spacing-4xl); + display: grid; + grid-template-columns: repeat(1, 1fr); + gap: var(--home-spacing-lg); + text-align: left; + max-width: 72rem; + width: 100%; +} + +@media (min-width: 768px) { + .featureGrid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (min-width: 1024px) { + .featureGrid { + gap: var(--home-spacing-xl); + } +} + +/* ── Feature Card ──────────────────── */ +.featureCard { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--home-radius-xl); + padding: var(--home-spacing-lg); + transition: + transform 0.2s ease, + border-color 0.2s ease; +} + +@media (max-width: 768px) { + .featureCard { + padding: var(--home-spacing-md); + border-radius: var(--home-radius-lg); + } +} + +.featureCard:hover { + border-color: var(--border-hi); + transform: translateY(-2px); +} + +.featureIcon { + width: 48px; + height: 48px; + border-radius: var(--home-radius-md); + display: flex; + align-items: center; + justify-content: center; + margin-bottom: var(--home-spacing-lg); + border: 1px solid transparent; +} + +.featureIconAccent { + background: rgba(74, 240, 184, 0.1); + border-color: rgba(74, 240, 184, 0.2); +} + +.featureIconAccent2 { + background: rgba(124, 111, 247, 0.1); + border-color: rgba(124, 111, 247, 0.2); +} + +.featureIconDanger { + background: rgba(255, 123, 114, 0.1); + border-color: rgba(255, 123, 114, 0.2); +} + +@media (max-width: 768px) { + .featureIcon { + width: 40px; + height: 40px; + margin-bottom: var(--home-spacing-md); + } +} + +.featureTitle { + font-size: var(--home-font-xl); + font-weight: 700; + margin-bottom: var(--home-spacing-sm); +} + +@media (max-width: 768px) { + .featureTitle { + font-size: var(--home-font-lg); + } +} + +.featureDescription { + color: var(--muted); + font-size: var(--home-font-sm); + line-height: 1.6; +} + +@media (max-width: 768px) { + .featureDescription { + font-size: var(--home-font-base); + } +} + +/* ── Reduced Motion ────────────────── */ +@media (prefers-reduced-motion: reduce) { + .ctaPrimary, + .ctaSecondary, + .featureCard { + transition: none; + } + + .ctaPrimary:hover { + transform: none; + } + + .featureCard:hover { + transform: none; + } +} diff --git a/frontend/src/pages/Home.tsx b/frontend/src/pages/Home.tsx index 85e30180..bf5ed0ea 100644 --- a/frontend/src/pages/Home.tsx +++ b/frontend/src/pages/Home.tsx @@ -1,34 +1,46 @@ import { Icon } from '@stellar/design-system'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; +import styles from './Home.module.css'; + +/** + * Home + * + * Landing page with responsive dashboard grid layout. + * Uses design tokens for consistent spacing, typography, and breakpoints. + * + * This component demonstrates: + * - Responsive grid layout that adapts from 1 to 3 columns + * - Consistent spacing using design tokens + * - Mobile-first approach with progressive enhancement + * - Accessibility considerations (reduced motion support) + */ export default function Home() { const navigate = useNavigate(); const { t } = useTranslation(); return ( -
-
+
+
-
+
-

+

{t('home.titleLine1Prefix')}{' '} - {t('home.titleLine1Highlight')} + {t('home.titleLine1Highlight')}
{t('home.titleLine2Prefix')}{' '} - {t('home.titleLine2Highlight')} + {t('home.titleLine2Highlight')} {t('home.titleLine2Suffix')}

-

- {t('home.tagline')} -

+

{t('home.tagline')}

-
+
-
-
-
+
+
+
-

{t('home.card1Title')}

-

{t('home.card1Body')}

+

{t('home.card1Title')}

+

{t('home.card1Body')}

-
-
+
+
-

{t('home.card2Title')}

-

{t('home.card2Body')}

+

{t('home.card2Title')}

+

{t('home.card2Body')}

-
-
+
+
-

{t('home.card3Title')}

-

{t('home.card3Body')}

+

{t('home.card3Title')}

+

{t('home.card3Body')}

diff --git a/frontend/src/utils/exportChart.ts b/frontend/src/utils/exportChart.ts new file mode 100644 index 00000000..ec4e362c --- /dev/null +++ b/frontend/src/utils/exportChart.ts @@ -0,0 +1,535 @@ +/** + * exportChart.ts + * + * Utility functions for exporting charts and pages to print/PDF format. + * Provides consistent print styling using design tokens and respects + * the application's theme system. + * + * This module demonstrates: + * - Print stylesheet integration using design tokens + * - Chart export functionality for recharts-based components + * - Theme-aware print output (light mode for print) + * - Responsive print layouts + * + * Usage: + * ```typescript + * import { printPage, exportChartToImage } from '../utils/exportChart'; + * + * // Print current page + * printPage(); + * + * // Export a chart element to image + * const chartElement = document.getElementById('my-chart'); + * if (chartElement) { + * exportChartToImage(chartElement, 'payroll-chart.png'); + * } + * ``` + * + * Print Stylesheet Pattern: + * - Uses CSS @media print rules + * - Forces light theme for print output + * - Hides interactive elements (buttons, modals, etc.) + * - Optimizes typography for print readability + * - Maintains design token consistency + */ + +/** + * Design tokens for print/export styles + * These mirror the CSS custom properties but are used in JavaScript + * for dynamic styling during export operations. + */ +export const printTokens = { + /* Colors - forced light theme for print */ + colors: { + background: '#ffffff', + surface: '#f6f8fa', + surfaceHi: '#f0f2f5', + border: 'rgba(0, 0, 0, 0.08)', + borderHi: 'rgba(0, 0, 0, 0.15)', + text: '#1f2328', + muted: '#656d76', + accent: '#0d9668', + accent2: '#6c5ce7', + danger: '#d1242f', + success: '#1a7f37', + }, + + /* Typography */ + typography: { + fontFamily: { + head: "'Syne', sans-serif", + body: "'Inter', sans-serif", + mono: "'DM Mono', monospace", + }, + fontSize: { + xs: '10px', + sm: '12px', + base: '14px', + md: '16px', + lg: '18px', + xl: '22px', + '2xl': '28px', + '3xl': '36px', + }, + }, + + /* Spacing */ + spacing: { + xs: '4px', + sm: '8px', + md: '16px', + lg: '24px', + xl: '32px', + '2xl': '48px', + }, + + /* Border Radius */ + radius: { + sm: '6px', + md: '10px', + lg: '14px', + xl: '16px', + }, +} as const; + +/** + * Inject print stylesheet into the document + * This adds CSS rules for print media that: + * - Force light theme + * - Hide interactive elements + * - Optimize layout for print + */ +export function injectPrintStyles(): void { + const styleId = 'payd-print-styles'; + + // Don't inject if already present + if (document.getElementById(styleId)) return; + + const style = document.createElement('style'); + style.id = styleId; + style.textContent = ` + @media print { + /* Force light theme for print */ + :root, [data-theme="dark"], [data-theme="light"] { + --bg: ${printTokens.colors.background}; + --surface: ${printTokens.colors.surface}; + --surface-hi: ${printTokens.colors.surfaceHi}; + --border: ${printTokens.colors.border}; + --border-hi: ${printTokens.colors.borderHi}; + --text: ${printTokens.colors.text}; + --muted: ${printTokens.colors.muted}; + --accent: ${printTokens.colors.accent}; + --accent2: ${printTokens.colors.accent2}; + --danger: ${printTokens.colors.danger}; + --success: ${printTokens.colors.success}; + color-scheme: light; + } + + /* Page setup */ + @page { + margin: 1cm; + size: A4; + } + + /* Hide interactive elements */ + button, + a[href], + input, + select, + textarea, + [role="button"], + .no-print, + nav, + header:not(.print-header), + footer, + .modal, + [data-modal], + .toast, + [role="alert"], + .sidebar, + .topbar { + display: none !important; + } + + /* Show print-only elements */ + .print-only { + display: block !important; + } + + /* Typography adjustments for print */ + body { + font-size: 12pt; + line-height: 1.5; + color: ${printTokens.colors.text}; + background: ${printTokens.colors.background}; + } + + h1, h2, h3, h4, h5, h6 { + font-family: ${printTokens.typography.fontFamily.head}; + page-break-after: avoid; + margin-top: 1em; + margin-bottom: 0.5em; + } + + h1 { font-size: 24pt; } + h2 { font-size: 20pt; } + h3 { font-size: 16pt; } + h4 { font-size: 14pt; } + + /* Code blocks */ + code, pre { + font-family: ${printTokens.typography.fontFamily.mono}; + font-size: 10pt; + background: ${printTokens.colors.surface}; + border: 1px solid ${printTokens.colors.border}; + border-radius: 4px; + padding: 2px 4px; + } + + pre { + padding: 12px; + overflow: visible; + white-space: pre-wrap; + word-wrap: break-word; + } + + /* Tables */ + table { + border-collapse: collapse; + width: 100%; + margin: 12pt 0; + font-size: 10pt; + } + + th, td { + border: 1px solid ${printTokens.colors.border}; + padding: 6px 12px; + text-align: left; + } + + th { + background: ${printTokens.colors.surface}; + font-weight: 600; + } + + /* Links */ + a { + color: ${printTokens.colors.accent}; + text-decoration: underline; + } + + a[href^="http"]::after { + content: " (" attr(href) ")"; + font-size: 9pt; + color: ${printTokens.colors.muted}; + } + + /* Images and charts */ + img, svg { + max-width: 100%; + height: auto; + } + + /* Page breaks */ + .page-break { + page-break-before: always; + } + + .no-break { + page-break-inside: avoid; + } + + /* Card styling for print */ + .card, [class*="Card"] { + border: 1px solid ${printTokens.colors.border}; + border-radius: ${printTokens.radius.md}; + padding: 12px; + margin-bottom: 12px; + background: ${printTokens.colors.background}; + } + + /* Remove shadows and transforms */ + * { + box-shadow: none !important; + transform: none !important; + text-shadow: none !important; + } + + /* Ensure proper contrast */ + .text-accent { color: ${printTokens.colors.accent} !important; } + .text-accent2 { color: ${printTokens.colors.accent2} !important; } + .text-danger { color: ${printTokens.colors.danger} !important; } + .text-success { color: ${printTokens.colors.success} !important; } + .text-muted { color: ${printTokens.colors.muted} !important; } + + /* Background colors for print */ + .bg-accent-10, + .bg-accent-20 { + background: rgba(13, 150, 104, 0.1) !important; + } + + /* Status indicators */ + [class*="status"] { + border: 1px solid currentColor; + padding: 2px 6px; + border-radius: 4px; + font-size: 9pt; + text-transform: uppercase; + } + } + + /* Print-only header */ + .print-header { + display: none; + } + + @media print { + .print-header { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 12px; + margin-bottom: 24px; + border-bottom: 2px solid ${printTokens.colors.border}; + } + + .print-header h1 { + font-size: 18pt; + margin: 0; + } + + .print-header .print-date { + font-size: 10pt; + color: ${printTokens.colors.muted}; + } + } + `; + + document.head.appendChild(style); +} + +/** + * Remove print stylesheet from the document + */ +export function removePrintStyles(): void { + const style = document.getElementById('payd-print-styles'); + if (style) { + style.remove(); + } +} + +/** + * Print the current page + * Injects print styles, triggers print dialog, then removes styles + */ +export function printPage(): void { + injectPrintStyles(); + + // Small delay to ensure styles are applied + setTimeout(() => { + window.print(); + + // Remove styles after print dialog closes + setTimeout(() => { + removePrintStyles(); + }, 1000); + }, 100); +} + +/** + * Export a chart element to an image + * Uses html2canvas to capture the chart and download as PNG + * + * @param element - The DOM element containing the chart + * @param filename - The filename for the downloaded image + */ +export async function exportChartToImage( + element: HTMLElement, + filename: string = 'chart.png' +): Promise { + try { + // Dynamic import for html2canvas (optional dependency) + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const html2canvas = (await import('html2canvas')).default as ( + el: HTMLElement, + options?: Record + ) => Promise; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const canvas = await html2canvas(element, { + backgroundColor: printTokens.colors.background, + scale: 2, // High resolution for print + useCORS: true, + logging: false, + }); + + // Create download link + const link = document.createElement('a'); + link.download = filename; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + link.href = canvas.toDataURL('image/png'); + link.click(); + } catch (error) { + console.error('Failed to export chart:', error); + throw new Error('Chart export failed. Please try again.'); + } +} + +/** + * Export a chart element to PDF + * Uses html2canvas + jspdf to create a PDF document + * + * @param element - The DOM element containing the chart + * @param filename - The filename for the downloaded PDF + * @param options - Optional configuration for PDF export + */ +export async function exportChartToPDF( + element: HTMLElement, + filename: string = 'chart.pdf', + options: { + title?: string; + orientation?: 'portrait' | 'landscape'; + format?: 'a4' | 'letter'; + } = {} +): Promise { + try { + // Dynamic imports for optional dependencies + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + const html2canvasModule = await import('html2canvas').then((mod) => mod.default); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const html2canvas = html2canvasModule as ( + el: HTMLElement, + options?: Record + ) => Promise; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access + const { jsPDF } = await import('jspdf').then((mod) => mod.default as { jsPDF: new (options: Record) => jsPDFInstance }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const canvas = await html2canvas(element, { + backgroundColor: printTokens.colors.background, + scale: 2, + useCORS: true, + logging: false, + }); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call + const imgData = canvas.toDataURL('image/png'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const imgWidth = canvas.width as number; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access + const imgHeight = canvas.height as number; + + // Calculate PDF dimensions + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call + const pdf = new jsPDF({ + orientation: options.orientation || 'landscape', + unit: 'px', + format: options.format || [imgWidth, imgHeight], + }); + + // Add title if provided + if (options.title) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + pdf.setFontSize(16); + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + pdf.text(options.title, 20, 30); + } + + // Add image + const yOffset = options.title ? 50 : 0; + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + pdf.addImage(imgData, 'PNG', 0, yOffset, imgWidth, imgHeight); + + // Save PDF + // eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access + pdf.save(filename); + } catch (error) { + console.error('Failed to export PDF:', error); + throw new Error('PDF export failed. Please try again.'); + } +} + +interface jsPDFInstance { + setFontSize: (size: number) => void; + text: (text: string, x: number, y: number) => void; + addImage: (imageData: string, format: string, x: number, y: number, width: number, height: number) => void; + save: (filename: string) => void; +} + +/** + * Generate a print-friendly HTML string from content + * Useful for server-side generation or email templates + * + * @param content - The HTML content to format + * @param options - Optional configuration + * @returns Formatted HTML string ready for print + */ +export function generatePrintHTML( + content: string, + options: { + title?: string; + includeStyles?: boolean; + customStyles?: string; + } = {} +): string { + const styles = options.includeStyles !== false ? ` + + ${options.customStyles || ''} + ` : ''; + + return ` + + + + + + ${options.title || 'PayD Export'} + ${styles} + + + ${options.title ? `

${options.title}

` : ''} + ${content} + + + `; +}