From c7268c837fb280198f9852ade6609e871b8ee313 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Sat, 24 Jan 2026 20:00:33 +0000
Subject: [PATCH] [jules] enhance: Add confirmation dialog system for
destructive actions
- Created `web/contexts/ConfirmContext.tsx` with Promise-based API
- Created `web/components/ui/ConfirmDialog.tsx` with dual-theme support
- Integrated `ConfirmProvider` into `web/App.tsx`
- Refactored `web/pages/GroupDetails.tsx` to replace `window.confirm` with `useConfirm`
- Improved `Modal` accessibility by adding `role="dialog"` and `aria-modal="true"`
- Verified with Playwright tests
---
.Jules/changelog.md | 1 +
.Jules/knowledge.md | 23 ++++++++++
.Jules/todo.md | 12 +++---
web/App.tsx | 19 ++++----
web/components/ui/ConfirmDialog.tsx | 44 +++++++++++++++++++
web/components/ui/Modal.tsx | 2 +
web/contexts/ConfirmContext.tsx | 67 +++++++++++++++++++++++++++++
web/pages/GroupDetails.tsx | 30 +++++++++++--
8 files changed, 180 insertions(+), 18 deletions(-)
create mode 100644 web/components/ui/ConfirmDialog.tsx
create mode 100644 web/contexts/ConfirmContext.tsx
diff --git a/.Jules/changelog.md b/.Jules/changelog.md
index 16a7b0c..5f26e67 100644
--- a/.Jules/changelog.md
+++ b/.Jules/changelog.md
@@ -20,6 +20,7 @@
- Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users.
- Toast notification system (`ToastContext`, `Toast` component) for providing non-blocking user feedback.
- Keyboard navigation support for Groups page, enabling accessibility for power users.
+- Confirmation Dialog System (`ConfirmContext`, `ConfirmDialog`) replacing native browser alerts for destructive actions in `GroupDetails`.
### Changed
- Updated JULES_PROMPT.md based on review of successful PRs:
diff --git a/.Jules/knowledge.md b/.Jules/knowledge.md
index ad48a44..090b071 100644
--- a/.Jules/knowledge.md
+++ b/.Jules/knowledge.md
@@ -205,6 +205,29 @@ 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 custom modal
+
+```tsx
+const { confirm } = useConfirm();
+
+// In async handler
+if (await confirm({
+ title: 'Delete Item',
+ message: 'Are you sure?',
+ variant: 'danger',
+ confirmText: 'Delete'
+})) {
+ // Proceed with deletion
+}
+```
+
+- Promise-based API avoids callback hell
+- Supports `danger` and `primary` variants
+- Fully accessible with `role="dialog"`
+
### Form Validation Pattern
**Date:** 2026-01-01
diff --git a/.Jules/todo.md b/.Jules/todo.md
index 4539a8a..2fdac3f 100644
--- a/.Jules/todo.md
+++ b/.Jules/todo.md
@@ -77,12 +77,12 @@
- Size: ~35 lines
- Added: 2026-01-01
-- [ ] **[ux]** Confirmation dialog for destructive actions
- - Files: Create `web/components/ui/ConfirmDialog.tsx`, integrate
- - Context: Confirm before deleting groups/expenses
- - Impact: Prevents accidental data loss
- - Size: ~70 lines
- - Added: 2026-01-01
+- [x] **[ux]** Confirmation dialog for destructive actions
+ - Completed: 2026-01-14
+ - Files: `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/pages/GroupDetails.tsx`
+ - Context: Replaced native `window.confirm` with custom dual-theme modal
+ - Impact: Prevents accidental data loss with consistent UI
+ - Size: ~100 lines
### Mobile
diff --git a/web/App.tsx b/web/App.tsx
index 0a6d4c6..964388b 100644
--- a/web/App.tsx
+++ b/web/App.tsx
@@ -3,6 +3,7 @@ import { HashRouter, Navigate, Route, Routes } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { ThemeWrapper } from './components/layout/ThemeWrapper';
import { AuthProvider, useAuth } from './contexts/AuthContext';
+import { ConfirmProvider } from './contexts/ConfirmContext';
import { ThemeProvider } from './contexts/ThemeContext';
import { ToastProvider } from './contexts/ToastContext';
import { ToastContainer } from './components/ui/Toast';
@@ -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..1c916f4
--- /dev/null
+++ b/web/components/ui/ConfirmDialog.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import { ConfirmOptions } from '../../contexts/ConfirmContext';
+import { Button } from './Button';
+import { Modal } from './Modal';
+
+interface ConfirmDialogProps {
+ isOpen: boolean;
+ onConfirm: () => void;
+ onCancel: () => void;
+ options: ConfirmOptions;
+}
+
+export const ConfirmDialog: React.FC = ({
+ isOpen,
+ onConfirm,
+ onCancel,
+ options
+}) => {
+ const { title, message, confirmText = 'Confirm', cancelText = 'Cancel', variant = 'primary' } = options;
+
+ return (
+
+
+
+ >
+ }
+ >
+ {message}
+
+ );
+};
diff --git a/web/components/ui/Modal.tsx b/web/components/ui/Modal.tsx
index a70621c..072c8e6 100644
--- a/web/components/ui/Modal.tsx
+++ b/web/components/ui/Modal.tsx
@@ -53,6 +53,8 @@ export const Modal: React.FC = ({ isOpen, onClose, title, children,
onClick={onClose}
/>
Promise;
+}
+
+const ConfirmContext = createContext(undefined);
+
+export const useConfirm = () => {
+ const context = useContext(ConfirmContext);
+ if (!context) {
+ throw new Error('useConfirm must be used within a ConfirmProvider');
+ }
+ return context;
+};
+
+export const ConfirmProvider = ({ children }: { children: ReactNode }) => {
+ const [isOpen, setIsOpen] = useState(false);
+ const [options, setOptions] = useState({ title: '', message: '' });
+ const [resolveRef, setResolveRef] = useState<(value: boolean) => void>(() => {});
+
+ const confirm = useCallback((opts: ConfirmOptions) => {
+ setOptions({
+ confirmText: 'Confirm',
+ cancelText: 'Cancel',
+ variant: 'primary',
+ ...opts
+ });
+ setIsOpen(true);
+ return new Promise((resolve) => {
+ setResolveRef(() => resolve);
+ });
+ }, []);
+
+ const handleConfirm = useCallback(() => {
+ setIsOpen(false);
+ resolveRef(true);
+ }, [resolveRef]);
+
+ const handleCancel = useCallback(() => {
+ setIsOpen(false);
+ resolveRef(false);
+ }, [resolveRef]);
+
+ return (
+
+ {children}
+ {isOpen && (
+
+ )}
+
+ );
+};
diff --git a/web/pages/GroupDetails.tsx b/web/pages/GroupDetails.tsx
index d47ebc0..9235cbc 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,12 @@ 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?',
+ variant: 'danger',
+ confirmText: 'Delete'
+ })) {
try {
await deleteExpense(id, editingExpenseId);
setIsExpenseModalOpen(false);
@@ -313,7 +320,12 @@ 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.',
+ variant: 'danger',
+ confirmText: 'Delete Group'
+ })) {
try {
await deleteGroup(id);
navigate('/groups');
@@ -326,7 +338,12 @@ 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?',
+ variant: 'danger',
+ confirmText: 'Leave'
+ })) {
try {
await leaveGroup(id);
addToast('You have left the group', 'success');
@@ -341,7 +358,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?`,
+ variant: 'danger',
+ confirmText: 'Remove'
+ })) {
try {
const hasUnsettled = settlements.some(
s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0