Skip to content
Open
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` alerts with a custom, dual-theme accessible `ConfirmDialog`.
- **Features:**
- `ConfirmProvider` and `useConfirm` hook for easy integration.
- Promise-based API (`await confirm(...)`) allowing linear code flow.
- Dual-theme support (Glassmorphism & Neobrutalism).
- Accessibility improvements: Added `role="dialog"` to Modal component.
- **Technical:** Created `web/contexts/ConfirmContext.tsx` and `web/components/ui/ConfirmDialog.tsx`. Integrated into `web/App.tsx` and `web/pages/GroupDetails.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
36 changes: 35 additions & 1 deletion .Jules/knowledge.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,38 @@ colors: {

## Component Patterns

### Confirmation Dialog Pattern

**Date:** 2026-01-23
**Context:** replacing `window.confirm` with custom UI

Use `ConfirmProvider` to allow promise-based confirmation without managing local state.

```tsx
// 1. Wrap App
<ConfirmProvider>
<App />
</ConfirmProvider>

// 2. Use Hook
const { confirm } = useConfirm();

const handleDelete = async () => {
if (await confirm({
title: 'Delete?',
message: 'Sure?',
variant: 'danger'
})) {
// delete logic
}
};
```

**Benefits:**
- Cleaner code (no `isModalOpen` state).
- Consistent UI across app.
- Non-blocking (unlike `alert`), but easy to await.

### Error Boundary Pattern

**Date:** 2026-01-14
Expand Down Expand Up @@ -176,7 +208,7 @@ When making a div clickable (like a card), you must ensure it's accessible:

### Modal Component Pattern

**Date:** 2026-01-01
**Date:** 2026-01-01 (Updated 2026-01-23)
**Context:** Modal.tsx structure

```tsx
Expand All @@ -190,6 +222,8 @@ When making a div clickable (like a card), you must ensure it's accessible:
</Modal>
```

**Accessibility Update:** Added `role="dialog"` and `aria-modal="true"` to the inner content wrapper to ensure screen readers announce it correctly.

### Toast Notification 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-23
- Files: `web/components/ui/ConfirmDialog.tsx`, `web/contexts/ConfirmContext.tsx`, `web/pages/GroupDetails.tsx`
- Context: Replaced `window.confirm` with dual-theme accessible modal
- Impact: Prevents accidental data loss with native-feeling UI
- Size: ~100 lines

### Mobile

Expand Down
4 changes: 4 additions & 0 deletions test-results/.last-run.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}
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
69 changes: 69 additions & 0 deletions web/components/ui/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React from 'react';
import { Modal } from './Modal';
import { Button } from './Button';
import { AlertTriangle, Info } from 'lucide-react';
import { THEMES } from '../../constants';
import { useTheme } from '../../contexts/ThemeContext';

interface ConfirmDialogProps {
isOpen: boolean;
onConfirm: () => void;
onCancel: () => void;
title: string;
message: string;
variant?: 'danger' | 'info';
confirmText?: string;
cancelText?: string;
}

export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
isOpen,
onConfirm,
onCancel,
title,
message,
variant = 'info',
confirmText = 'Confirm',
cancelText = 'Cancel'
}) => {
const { style } = useTheme();

return (
<Modal
isOpen={isOpen}
onClose={onCancel}
title={title}
footer={
<>
<Button variant="ghost" onClick={onCancel}>
{cancelText}
</Button>
<Button
variant={variant === 'danger' ? 'danger' : 'primary'}
onClick={onConfirm}
autoFocus
>
{confirmText}
</Button>
</>
}
>
<div className="flex flex-col gap-4">
<div className="flex items-start gap-4">
<div className={`p-3 rounded-full flex-shrink-0 ${
variant === 'danger'
? (style === THEMES.NEOBRUTALISM ? 'bg-red-200 border-2 border-black text-black' : 'bg-red-500/20 text-red-500')
: (style === THEMES.NEOBRUTALISM ? 'bg-blue-200 border-2 border-black text-black' : 'bg-blue-500/20 text-blue-500')
}`}>
{variant === 'danger' ? <AlertTriangle size={24} /> : <Info size={24} />}
</div>
<div className="mt-1">
<p className={`text-lg leading-relaxed ${style === THEMES.NEOBRUTALISM ? 'text-black' : 'text-gray-200'}`}>
{message}
</p>
</div>
</div>
</div>
</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
58 changes: 58 additions & 0 deletions web/contexts/ConfirmContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
import { ConfirmDialog } from '../components/ui/ConfirmDialog';

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

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

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

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

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

const handleConfirm = () => {
if (resolveRef) resolveRef(true);
setIsOpen(false);
};

const handleCancel = () => {
if (resolveRef) resolveRef(false);
setIsOpen(false);
};

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

export const useConfirm = () => {
const context = useContext(ConfirmContext);
if (!context) throw new Error('useConfirm must be used within 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?',
variant: 'danger',
confirmText: 'Delete'
})) {
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.',
variant: 'danger',
confirmText: 'Delete Group'
})) {
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?',
variant: 'danger',
confirmText: 'Leave Group'
})) {
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?`,
variant: 'danger',
confirmText: 'Remove'
})) {
try {
const hasUnsettled = settlements.some(
s => (s.fromUserId === memberId || s.toUserId === memberId) && (s.amount || 0) > 0
Expand Down
Loading