Skip to content
Draft
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
8 changes: 8 additions & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
24 changes: 24 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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._
19 changes: 11 additions & 8 deletions web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -50,14 +51,16 @@ const App = () => {
return (
<ThemeProvider>
<ToastProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
<ConfirmProvider>
<AuthProvider>
<HashRouter>
<ErrorBoundary>
<AppRoutes />
</ErrorBoundary>
<ToastContainer />
</HashRouter>
</AuthProvider>
</ConfirmProvider>
</ToastProvider>
</ThemeProvider>
);
Expand Down
142 changes: 142 additions & 0 deletions web/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<ConfirmDialogProps> = ({
isOpen,
onConfirm,
onCancel,
title,
message,
confirmText = 'Confirm',
cancelText = 'Cancel',
variant = 'primary'
}) => {
const { style } = useTheme();
const cancelButtonRef = useRef<HTMLButtonElement>(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 <AlertTriangle className={style === THEMES.NEOBRUTALISM ? "text-black" : "text-red-500"} size={32} />;
return <HelpCircle className={style === THEMES.NEOBRUTALISM ? "text-black" : "text-blue-500"} size={32} />;
};

return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
<motion.div
variants={overlayVariants}
initial="hidden"
animate="visible"
exit="hidden"
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onCancel}
/>
<motion.div
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
aria-describedby="confirm-desc"
variants={modalVariants}
initial="hidden"
animate="visible"
exit="exit"
className={`relative w-full max-w-md overflow-hidden flex flex-col
${style === THEMES.NEOBRUTALISM
? 'bg-white border-2 border-black shadow-[8px_8px_0px_0px_rgba(0,0,0,1)] rounded-none'
: 'bg-gray-900/90 border border-white/20 rounded-3xl shadow-2xl text-white backdrop-blur-xl'}`}
>
<div className="p-6 flex flex-col items-center text-center gap-4">
<div className={`p-4 rounded-full flex items-center justify-center ${style === THEMES.NEOBRUTALISM ? 'bg-gray-100 border-2 border-black' : 'bg-white/10'}`}>
{getIcon()}
</div>

<div>
<h3 id="confirm-title" className={`text-xl font-bold mb-2 ${style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-white'}`}>{title}</h3>
<p id="confirm-desc" className={`leading-relaxed ${style === THEMES.NEOBRUTALISM ? 'text-black/80' : 'text-white/80'}`}>{message}</p>
</div>

<div className="flex gap-3 w-full mt-4">
<Button
ref={cancelButtonRef}
variant="ghost"
onClick={onCancel}
className={`flex-1 ${style === THEMES.NEOBRUTALISM ? 'text-black' : ''}`}
>
{cancelText}
</Button>
<Button
variant={variant}
onClick={onConfirm}
className="flex-1"
>
{confirmText}
</Button>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};
64 changes: 64 additions & 0 deletions web/contexts/ConfirmContext.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>;
}

const ConfirmContext = createContext<ConfirmContextType | undefined>(undefined);

export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
const [isOpen, setIsOpen] = useState(false);
const [options, setOptions] = useState<ConfirmOptions>({ title: '', message: '' });
const [resolveRef, setResolveRef] = useState<((value: boolean) => void) | null>(null);

const confirm = useCallback((options: ConfirmOptions) => {
setOptions(options);
setIsOpen(true);
return new Promise<boolean>((resolve) => {
setResolveRef(() => resolve);
});
}, []);

const handleConfirm = useCallback(() => {
setIsOpen(false);
if (resolveRef) resolveRef(true);
}, [resolveRef]);

const handleCancel = useCallback(() => {
setIsOpen(false);
if (resolveRef) resolveRef(false);
}, [resolveRef]);

return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<ConfirmDialog
isOpen={isOpen}
onConfirm={handleConfirm}
onCancel={handleCancel}
title={options.title}
message={options.message}
confirmText={options.confirmText}
cancelText={options.cancelText}
variant={options.variant}
/>
</ConfirmContext.Provider>
);
};

export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (!context) {
throw new Error('useConfirm must be used within a ConfirmProvider');
}
return context;
};
33 changes: 29 additions & 4 deletions web/pages/GroupDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -41,6 +42,7 @@ export const GroupDetails = () => {
const { user } = useAuth();
const { style } = useTheme();
const { addToast } = useToast();
const { confirm } = useConfirm();

const [group, setGroup] = useState<Group | null>(null);
const [expenses, setExpenses] = useState<Expense[]>([]);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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
Expand Down
Loading