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
1 change: 1 addition & 0 deletions .Jules/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
23 changes: 23 additions & 0 deletions .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 6 additions & 6 deletions .Jules/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
19 changes: 11 additions & 8 deletions web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
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
44 changes: 44 additions & 0 deletions web/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<ConfirmDialogProps> = ({
isOpen,
onConfirm,
onCancel,
options
}) => {
const { title, message, confirmText = 'Confirm', cancelText = 'Cancel', variant = 'primary' } = options;

return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="ghost" onClick={onCancel}>
{cancelText}
</Button>
<Button
variant={variant}
onClick={onConfirm}
autoFocus
>
{confirmText}
</Button>
</>
}
>
<p className="opacity-80 text-lg leading-relaxed">{message}</p>
</Modal>
);
};
2 changes: 2 additions & 0 deletions web/components/ui/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ export const Modal: React.FC<ModalProps> = ({ isOpen, onClose, title, children,
onClick={onClose}
/>
<motion.div
role="dialog"
aria-modal="true"
variants={modalVariants}
initial="hidden"
animate="visible"
Expand Down
67 changes: 67 additions & 0 deletions web/contexts/ConfirmContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { ConfirmDialog } from '../components/ui/ConfirmDialog';

export interface ConfirmOptions {
title: string;
message: string;
confirmText?: string;
cancelText?: string;
variant?: 'danger' | 'primary';
}

interface ConfirmContextType {
confirm: (options: ConfirmOptions) => Promise<boolean>;
}

const ConfirmContext = createContext<ConfirmContextType | undefined>(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<ConfirmOptions>({ 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<boolean>((resolve) => {
setResolveRef(() => resolve);
});
}, []);

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

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

return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
{isOpen && (
<ConfirmDialog
isOpen={isOpen}
onConfirm={handleConfirm}
onCancel={handleCancel}
options={options}
/>
)}
</ConfirmContext.Provider>
);
};
30 changes: 26 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,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);
Expand Down Expand Up @@ -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');
Expand All @@ -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');
Expand All @@ -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
Expand Down
Loading