diff --git a/.Jules/changelog.md b/.Jules/changelog.md index 16a7b0c..3e53c11 100644 --- a/.Jules/changelog.md +++ b/.Jules/changelog.md @@ -7,6 +7,14 @@ ## [Unreleased] ### Added +- **Confirmation Dialog System:** Replaced native `window.confirm` with a custom, accessible `ConfirmDialog`. + - **Features:** + - Dual-theme support matching the app's Neobrutalism and Glassmorphism styles. + - Asynchronous `useConfirm` hook pattern. + - Keyboard accessibility (Focus management, Escape to close). + - Integrated into `GroupDetails` for deleting expenses, groups, and removing members. + - **Technical:** Created `web/contexts/ConfirmContext.tsx` and `web/components/ui/ConfirmDialog.tsx`. + - **Error Boundary System:** Implemented a global React Error Boundary to catch render errors gracefully. - **Features:** - Dual-theme support (Glassmorphism & Neobrutalism) for the error fallback UI. diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md index ad48a44..d975c6d 100644 --- a/.Jules/knowledge.md +++ b/.Jules/knowledge.md @@ -205,6 +205,30 @@ addToast('Message', 'success|error|info'); - Auto-dismisses after 3 seconds - Stacks vertically in bottom-right +### Confirmation Dialog Pattern + +**Date:** 2026-01-14 +**Context:** Replacing window.confirm with useConfirm + +```tsx +const { confirm } = useConfirm(); + +const handleDelete = async () => { + if (await confirm({ + title: 'Are you sure?', + message: 'This cannot be undone.', + confirmText: 'Delete', + variant: 'danger' + })) { + // Proceed with deletion + } +}; +``` + +- **Asynchronous:** Returns a Promise resolving to `true` or `false`. +- **Themed:** Matches global theme (Glass/Neo). +- **Accessible:** Manages focus and ARIA roles. + ### Form Validation Pattern **Date:** 2026-01-01 diff --git a/.Jules/todo.md b/.Jules/todo.md index 4539a8a..d66592f 100644 --- a/.Jules/todo.md +++ b/.Jules/todo.md @@ -77,11 +77,11 @@ - Size: ~35 lines - Added: 2026-01-01 -- [ ] **[ux]** Confirmation dialog for destructive actions - - Files: Create `web/components/ui/ConfirmDialog.tsx`, integrate +- [x] **[ux]** Confirmation dialog for destructive actions + - Files: Created `web/components/ui/ConfirmDialog.tsx`, integrated in `GroupDetails.tsx` - Context: Confirm before deleting groups/expenses - Impact: Prevents accidental data loss - - Size: ~70 lines + - Size: ~150 lines - Added: 2026-01-01 ### Mobile @@ -159,4 +159,9 @@ - Files modified: `web/components/ErrorBoundary.tsx`, `web/App.tsx` - Impact: App doesn't crash, users can recover +- [x] **[ux]** Confirmation dialog for destructive actions + - Completed: 2026-01-14 + - Files modified: `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/pages/GroupDetails.tsx` + - Impact: Prevents accidental data loss via accessible, themed dialogs + _No tasks completed yet. Move tasks here after completion._ diff --git a/web/App.tsx b/web/App.tsx index 0a6d4c6..dfc2041 100644 --- a/web/App.tsx +++ b/web/App.tsx @@ -5,6 +5,7 @@ import { ThemeWrapper } from './components/layout/ThemeWrapper'; import { AuthProvider, useAuth } from './contexts/AuthContext'; import { ThemeProvider } from './contexts/ThemeContext'; import { ToastProvider } from './contexts/ToastContext'; +import { ConfirmProvider } from './contexts/ConfirmContext'; import { ToastContainer } from './components/ui/Toast'; import { ErrorBoundary } from './components/ErrorBoundary'; import { Auth } from './pages/Auth'; @@ -50,14 +51,16 @@ const App = () => { return ( - - - - - - - - + + + + + + + + + + ); diff --git a/web/components/ui/ConfirmDialog.tsx b/web/components/ui/ConfirmDialog.tsx new file mode 100644 index 0000000..084db87 --- /dev/null +++ b/web/components/ui/ConfirmDialog.tsx @@ -0,0 +1,142 @@ +import { AnimatePresence, motion, Variants } from 'framer-motion'; +import { AlertTriangle, HelpCircle } from 'lucide-react'; +import React, { useEffect, useRef } from 'react'; +import { THEMES } from '../../constants'; +import { useTheme } from '../../contexts/ThemeContext'; +import { Button } from './Button'; + +interface ConfirmDialogProps { + isOpen: boolean; + onConfirm: () => void; + onCancel: () => void; + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'primary' | 'secondary'; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + onConfirm, + onCancel, + title, + message, + confirmText = 'Confirm', + cancelText = 'Cancel', + variant = 'primary' +}) => { + const { style } = useTheme(); + const cancelButtonRef = useRef(null); + + // Focus management + useEffect(() => { + if (isOpen) { + // Small delay to ensure render + setTimeout(() => { + cancelButtonRef.current?.focus(); + }, 50); + } + }, [isOpen]); + + // Close on Escape + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && isOpen) { + onCancel(); + } + }; + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isOpen, onCancel]); + + const overlayVariants: Variants = { + hidden: { opacity: 0 }, + visible: { opacity: 1 }, + }; + + const modalVariants: Variants = style === THEMES.NEOBRUTALISM ? { + hidden: { y: '100%', scale: 0.9, opacity: 0 }, + visible: { + y: 0, + scale: 1, + opacity: 1, + transition: { type: 'spring', damping: 20, stiffness: 300 } + }, + exit: { y: '100%', scale: 0.9, opacity: 0 } + } : { + hidden: { scale: 0.9, opacity: 0, backdropFilter: 'blur(0px)' }, + visible: { + scale: 1, + opacity: 1, + backdropFilter: 'blur(4px)', + transition: { type: 'spring', damping: 25, stiffness: 300 } + }, + exit: { scale: 0.9, opacity: 0 } + }; + + const getIcon = () => { + if (variant === 'danger') return ; + return ; + }; + + return ( + + {isOpen && ( + + + + + + {getIcon()} + + + + {title} + {message} + + + + + {cancelText} + + + {confirmText} + + + + + + )} + + ); +}; diff --git a/web/contexts/ConfirmContext.tsx b/web/contexts/ConfirmContext.tsx new file mode 100644 index 0000000..a87b541 --- /dev/null +++ b/web/contexts/ConfirmContext.tsx @@ -0,0 +1,64 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import { ConfirmDialog } from '../components/ui/ConfirmDialog'; + +interface ConfirmOptions { + title: string; + message: string; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'primary' | 'secondary'; +} + +interface ConfirmContextType { + confirm: (options: ConfirmOptions) => Promise; +} + +const ConfirmContext = createContext(undefined); + +export const ConfirmProvider = ({ children }: { children: ReactNode }) => { + const [isOpen, setIsOpen] = useState(false); + const [options, setOptions] = useState({ title: '', message: '' }); + const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null); + + const confirm = useCallback((options: ConfirmOptions) => { + setOptions(options); + setIsOpen(true); + return new Promise((resolve) => { + setResolveRef(() => resolve); + }); + }, []); + + const handleConfirm = useCallback(() => { + setIsOpen(false); + if (resolveRef) resolveRef(true); + }, [resolveRef]); + + const handleCancel = useCallback(() => { + setIsOpen(false); + if (resolveRef) resolveRef(false); + }, [resolveRef]); + + return ( + + {children} + + + ); +}; + +export const useConfirm = () => { + const context = useContext(ConfirmContext); + if (!context) { + throw new Error('useConfirm must be used within a ConfirmProvider'); + } + return context; +}; diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx index d47ebc0..d085d3a 100644 --- a/web/pages/GroupDetails.tsx +++ b/web/pages/GroupDetails.tsx @@ -8,6 +8,7 @@ import { Modal } from '../components/ui/Modal'; import { Skeleton } from '../components/ui/Skeleton'; import { THEMES } from '../constants'; import { useAuth } from '../contexts/AuthContext'; +import { useConfirm } from '../contexts/ConfirmContext'; import { useTheme } from '../contexts/ThemeContext'; import { useToast } from '../contexts/ToastContext'; import { @@ -41,6 +42,7 @@ export const GroupDetails = () => { const { user } = useAuth(); const { style } = useTheme(); const { addToast } = useToast(); + const { confirm } = useConfirm(); const [group, setGroup] = useState(null); const [expenses, setExpenses] = useState([]); @@ -257,7 +259,13 @@ export const GroupDetails = () => { const handleDeleteExpense = async () => { if (!editingExpenseId || !id) return; - if (window.confirm("Are you sure you want to delete this expense?")) { + + if (await confirm({ + title: 'Delete Expense', + message: 'Are you sure you want to delete this expense?', + confirmText: 'Delete', + variant: 'danger' + })) { try { await deleteExpense(id, editingExpenseId); setIsExpenseModalOpen(false); @@ -313,7 +321,13 @@ export const GroupDetails = () => { const handleDeleteGroup = async () => { if (!id) return; - if (window.confirm("Are you sure? This cannot be undone.")) { + + if (await confirm({ + title: 'Delete Group', + message: 'Are you sure? This cannot be undone and all expenses will be lost.', + confirmText: 'Delete Group', + variant: 'danger' + })) { try { await deleteGroup(id); navigate('/groups'); @@ -326,7 +340,13 @@ export const GroupDetails = () => { const handleLeaveGroup = async () => { if (!id) return; - if (window.confirm("You can only leave when your balances are settled. Continue?")) { + + if (await confirm({ + title: 'Leave Group', + message: 'You can only leave when your balances are settled. Continue?', + confirmText: 'Leave', + variant: 'danger' + })) { try { await leaveGroup(id); addToast('You have left the group', 'success'); @@ -341,7 +361,12 @@ export const GroupDetails = () => { if (!id || !isAdmin) return; if (memberId === user?._id) return; - if (window.confirm(`Are you sure you want to remove ${memberName} from the group?`)) { + if (await confirm({ + title: 'Remove Member', + message: `Are you sure you want to remove ${memberName} from the group?`, + confirmText: 'Remove', + variant: 'danger' + })) { try { const hasUnsettled = settlements.some( s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0
{message}