Skip to content

Commit e8432ff

Browse files
google-labs-jules[bot]Devasycoderabbitai[bot]
authored
Add Toast Notification System (#227)
* [jules] enhance: Add global toast notification system - Added `ToastContext` and `ToastProvider` for managing global toast state. - Added `Toast` component supporting `success`, `error`, and `info` variants. - Implemented dual-theme support (Glassmorphism & Neobrutalism) for toasts. - Integrated toast notifications into `Auth.tsx` for login/signup feedback. - Updated `App.tsx` to include `ToastProvider`. * Update web/contexts/ToastContext.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * [jules] enhance: Add toasts to C,U,D operations - Added toast notifications to Group creation/joining in `Groups.tsx`. - Added toast notifications to Expense C/U/D, Payment recording, and Group management in `GroupDetails.tsx`. - Added toast notifications to Profile updates in `Profile.tsx`. - Ensured toasts are triggered on both success and error states. * [jules] style: Enhance Toast dark mode support - Updated `Toast.tsx` to use `mode` from `ThemeContext`. - Adjusted border colors for Glassmorphism toasts in dark mode. * [jules] fix: Toast cleanup and button type - Added `type="button"` to Toast close button to prevent form submission. - Moved auto-dismiss logic to `ToastItem` using `useEffect` for proper cleanup. - Removed `setTimeout` from `ToastContext` to prevent memory leaks. --------- Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com> Co-authored-by: Devasy Patel <[email protected]> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
1 parent 2c5cf9a commit e8432ff

File tree

10 files changed

+187
-21
lines changed

10 files changed

+187
-21
lines changed

.Jules/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
### Added
1010
- Dashboard skeleton loading state (`DashboardSkeleton`) to improve perceived performance during data fetch.
1111
- Comprehensive `EmptyState` component for Groups and Friends pages to better guide new users.
12+
- Toast notification system (`ToastContext`, `Toast` component) for providing non-blocking user feedback.
1213

1314
### Planned
1415
- See `todo.md` for queued tasks

.Jules/knowledge.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,21 @@ colors: {
134134
</Modal>
135135
```
136136

137+
### Toast Notification Pattern
138+
139+
**Date:** 2026-01-01
140+
**Context:** ToastContext.tsx and Toast.tsx
141+
142+
```tsx
143+
const { addToast } = useToast();
144+
addToast('Message', 'success|error|info');
145+
```
146+
147+
- Supports `success`, `error`, `info` types
148+
- Automatically adapts to current theme (Glassmorphism/Neobrutalism)
149+
- Auto-dismisses after 3 seconds
150+
- Stacks vertically in bottom-right
151+
137152
---
138153

139154
## Mobile Patterns

.Jules/todo.md

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,10 @@
1515
- Files modified: `web/pages/Dashboard.tsx`, `web/components/skeletons/DashboardSkeleton.tsx`
1616
- Impact: Professional loading experience that mimics actual content layout
1717

18-
- [ ] **[ux]** Toast notification system for user actions
19-
- Files: Create `web/components/ui/Toast.tsx`, integrate into actions
20-
- Context: Show success/error toasts for create/delete actions (not alerts)
21-
- Impact: Modern feedback without blocking user
22-
- Size: ~80 lines
23-
- Added: 2026-01-01
18+
- [x] **[ux]** Toast notification system for user actions
19+
- Completed: 2026-01-01
20+
- Files modified: `web/contexts/ToastContext.tsx`, `web/components/ui/Toast.tsx`, `web/App.tsx`, `web/pages/Auth.tsx`
21+
- Impact: Modern feedback system that supports both themes
2422

2523
- [ ] **[a11y]** Complete keyboard navigation for Groups page
2624
- File: `web/pages/Groups.tsx`

web/App.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import { Layout } from './components/layout/Layout';
44
import { ThemeWrapper } from './components/layout/ThemeWrapper';
55
import { AuthProvider, useAuth } from './contexts/AuthContext';
66
import { ThemeProvider } from './contexts/ThemeContext';
7+
import { ToastProvider } from './contexts/ToastContext';
8+
import { ToastContainer } from './components/ui/Toast';
79
import { Auth } from './pages/Auth';
810
import { Dashboard } from './pages/Dashboard';
911
import { Friends } from './pages/Friends';
@@ -46,11 +48,14 @@ const AppRoutes = () => {
4648
const App = () => {
4749
return (
4850
<ThemeProvider>
49-
<AuthProvider>
50-
<HashRouter>
51-
<AppRoutes />
52-
</HashRouter>
53-
</AuthProvider>
51+
<ToastProvider>
52+
<AuthProvider>
53+
<HashRouter>
54+
<AppRoutes />
55+
<ToastContainer />
56+
</HashRouter>
57+
</AuthProvider>
58+
</ToastProvider>
5459
</ThemeProvider>
5560
);
5661
};

web/components/ui/Toast.tsx

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import React from 'react';
2+
import { motion, AnimatePresence } from 'framer-motion';
3+
import { X, CheckCircle, AlertCircle, Info } from 'lucide-react';
4+
import { useToast, Toast } from '../../contexts/ToastContext';
5+
import { useTheme } from '../../contexts/ThemeContext';
6+
import { THEMES } from '../../constants';
7+
8+
import { useEffect } from 'react';
9+
10+
const ToastItem: React.FC<{ toast: Toast }> = ({ toast }) => {
11+
const { removeToast } = useToast();
12+
const { style, mode } = useTheme();
13+
14+
const isNeo = style === THEMES.NEOBRUTALISM;
15+
const isDark = mode === 'dark';
16+
17+
useEffect(() => {
18+
if (toast.duration && toast.duration > 0) {
19+
const timer = setTimeout(() => {
20+
removeToast(toast.id);
21+
}, toast.duration);
22+
return () => clearTimeout(timer);
23+
}
24+
}, [toast.id, toast.duration, removeToast]);
25+
26+
const icons = {
27+
success: <CheckCircle className="w-5 h-5" />,
28+
error: <AlertCircle className="w-5 h-5" />,
29+
info: <Info className="w-5 h-5" />,
30+
};
31+
32+
const colors = {
33+
success: isNeo
34+
? 'bg-[#00cc88] text-black border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
35+
: `bg-green-500/90 text-white backdrop-blur-md shadow-lg rounded-lg border ${isDark ? 'border-green-500/30' : 'border-white/20'}`,
36+
error: isNeo
37+
? 'bg-[#ff5555] text-black border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
38+
: `bg-red-500/90 text-white backdrop-blur-md shadow-lg rounded-lg border ${isDark ? 'border-red-500/30' : 'border-white/20'}`,
39+
info: isNeo
40+
? 'bg-[#8855ff] text-white border-2 border-black shadow-[4px_4px_0px_0px_rgba(0,0,0,1)] rounded-none'
41+
: `bg-blue-500/90 text-white backdrop-blur-md shadow-lg rounded-lg border ${isDark ? 'border-blue-500/30' : 'border-white/20'}`,
42+
};
43+
44+
return (
45+
<motion.div
46+
layout
47+
initial={{ opacity: 0, y: 50, scale: 0.3 }}
48+
animate={{ opacity: 1, y: 0, scale: 1 }}
49+
exit={{ opacity: 0, scale: 0.5, transition: { duration: 0.2 } }}
50+
className={`
51+
flex items-center gap-3 px-4 py-3 mb-3 min-w-[300px] max-w-md pointer-events-auto
52+
${colors[toast.type]}
53+
`}
54+
>
55+
<span className="shrink-0">{icons[toast.type]}</span>
56+
<p className="flex-1 text-sm font-medium">{toast.message}</p>
57+
<button
58+
type="button"
59+
onClick={() => removeToast(toast.id)}
60+
className="shrink-0 hover:opacity-70 transition-opacity"
61+
aria-label="Close notification"
62+
>
63+
<X className="w-4 h-4" />
64+
</button>
65+
</motion.div>
66+
);
67+
};
68+
69+
export const ToastContainer: React.FC = () => {
70+
const { toasts } = useToast();
71+
72+
return (
73+
<div className="fixed bottom-4 right-4 z-50 flex flex-col items-end pointer-events-none p-4">
74+
<AnimatePresence mode="popLayout">
75+
{toasts.map((toast) => (
76+
<ToastItem key={toast.id} toast={toast} />
77+
))}
78+
</AnimatePresence>
79+
</div>
80+
);
81+
};

web/contexts/ToastContext.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
2+
3+
export type ToastType = 'success' | 'error' | 'info';
4+
5+
export interface Toast {
6+
id: string;
7+
type: ToastType;
8+
message: string;
9+
duration?: number;
10+
}
11+
12+
interface ToastContextType {
13+
toasts: Toast[];
14+
addToast: (message: string, type?: ToastType, duration?: number) => void;
15+
removeToast: (id: string) => void;
16+
}
17+
18+
const ToastContext = createContext<ToastContextType | undefined>(undefined);
19+
20+
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
21+
const [toasts, setToasts] = useState<Toast[]>([]);
22+
23+
const removeToast = useCallback((id: string) => {
24+
setToasts((prev) => prev.filter((toast) => toast.id !== id));
25+
}, []);
26+
27+
const addToast = useCallback((message: string, type: ToastType = 'info', duration: number = 3000) => {
28+
const id = Math.random().toString(36).substr(2, 9);
29+
setToasts((prev) => [...prev, { id, message, type, duration }]);
30+
}, []);
31+
32+
return (
33+
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
34+
{children}
35+
</ToastContext.Provider>
36+
);
37+
};
38+
39+
export const useToast = (): ToastContextType => {
40+
const context = useContext(ToastContext);
41+
if (!context) {
42+
throw new Error('useToast must be used within a ToastProvider');
43+
}
44+
return context;
45+
};

web/pages/Auth.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Spinner } from '../components/ui/Spinner';
88
import { THEMES } from '../constants';
99
import { useAuth } from '../contexts/AuthContext';
1010
import { useTheme } from '../contexts/ThemeContext';
11+
import { useToast } from '../contexts/ToastContext';
1112
import {
1213
login as apiLogin,
1314
signup as apiSignup,
@@ -26,6 +27,7 @@ export const Auth = () => {
2627

2728
const { login } = useAuth();
2829
const { style, toggleStyle } = useTheme();
30+
const { addToast } = useToast();
2931
const navigate = useNavigate();
3032

3133
const handleGoogleSignIn = async () => {
@@ -40,6 +42,7 @@ export const Auth = () => {
4042
throw new Error('Invalid response from server');
4143
}
4244
login(access_token, user);
45+
addToast('Welcome back!', 'success');
4346
navigate('/dashboard');
4447
} catch (err: any) {
4548
console.error('Google login error:', err);
@@ -75,6 +78,7 @@ export const Auth = () => {
7578

7679
const { access_token, user } = res.data;
7780
login(access_token, user);
81+
addToast(isLogin ? 'Welcome back!' : 'Account created successfully!', 'success');
7882
navigate('/dashboard');
7983
} catch (err: any) {
8084
if (err.response) {

web/pages/GroupDetails.tsx

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Skeleton } from '../components/ui/Skeleton';
99
import { THEMES } from '../constants';
1010
import { useAuth } from '../contexts/AuthContext';
1111
import { useTheme } from '../contexts/ThemeContext';
12+
import { useToast } from '../contexts/ToastContext';
1213
import {
1314
createExpense,
1415
createSettlement,
@@ -39,6 +40,7 @@ export const GroupDetails = () => {
3940
const navigate = useNavigate();
4041
const { user } = useAuth();
4142
const { style } = useTheme();
43+
const { addToast } = useToast();
4244

4345
const [group, setGroup] = useState<Group | null>(null);
4446
const [expenses, setExpenses] = useState<Expense[]>([]);
@@ -240,14 +242,16 @@ export const GroupDetails = () => {
240242
try {
241243
if (editingExpenseId) {
242244
await updateExpense(id, editingExpenseId, payload);
245+
addToast('Expense updated successfully!', 'success');
243246
} else {
244247
await createExpense(id, payload);
248+
addToast('Expense created successfully!', 'success');
245249
}
246250
setIsExpenseModalOpen(false);
247251
fetchData();
248252
} catch (err) {
249253
console.error(err);
250-
alert('Error saving expense');
254+
addToast('Error saving expense', 'error');
251255
}
252256
};
253257

@@ -258,8 +262,9 @@ export const GroupDetails = () => {
258262
await deleteExpense(id, editingExpenseId);
259263
setIsExpenseModalOpen(false);
260264
fetchData();
265+
addToast('Expense deleted successfully', 'success');
261266
} catch (err) {
262-
alert("Failed to delete expense");
267+
addToast("Failed to delete expense", 'error');
263268
}
264269
}
265270
};
@@ -287,8 +292,9 @@ export const GroupDetails = () => {
287292
setIsPaymentModalOpen(false);
288293
setPaymentAmount('');
289294
fetchData();
295+
addToast('Payment recorded successfully!', 'success');
290296
} catch (err) {
291-
alert("Failed to record payment");
297+
addToast("Failed to record payment", 'error');
292298
}
293299
};
294300

@@ -299,8 +305,9 @@ export const GroupDetails = () => {
299305
await updateGroup(id, { name: editGroupName });
300306
setIsSettingsModalOpen(false);
301307
fetchData();
308+
addToast('Group updated successfully!', 'success');
302309
} catch (err) {
303-
alert("Failed to update group");
310+
addToast("Failed to update group", 'error');
304311
}
305312
};
306313

@@ -310,8 +317,9 @@ export const GroupDetails = () => {
310317
try {
311318
await deleteGroup(id);
312319
navigate('/groups');
320+
addToast('Group deleted successfully', 'success');
313321
} catch (err) {
314-
alert("Failed to delete group");
322+
addToast("Failed to delete group", 'error');
315323
}
316324
}
317325
};
@@ -321,10 +329,10 @@ export const GroupDetails = () => {
321329
if (window.confirm("You can only leave when your balances are settled. Continue?")) {
322330
try {
323331
await leaveGroup(id);
324-
alert('You have left the group');
332+
addToast('You have left the group', 'success');
325333
navigate('/groups');
326334
} catch (err: any) {
327-
alert(err.response?.data?.detail || "Cannot leave - please settle balances first");
335+
addToast(err.response?.data?.detail || "Cannot leave - please settle balances first", 'error');
328336
}
329337
}
330338
};
@@ -344,8 +352,9 @@ export const GroupDetails = () => {
344352
}
345353
await removeMember(id, memberId);
346354
fetchData();
355+
addToast(`Removed ${memberName} from group`, 'success');
347356
} catch (err: any) {
348-
alert(err.response?.data?.detail || "Failed to remove member");
357+
addToast(err.response?.data?.detail || "Failed to remove member", 'error');
349358
}
350359
}
351360
};

web/pages/Groups.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { Modal } from '../components/ui/Modal';
99
import { Skeleton } from '../components/ui/Skeleton';
1010
import { THEMES } from '../constants';
1111
import { useTheme } from '../contexts/ThemeContext';
12+
import { useToast } from '../contexts/ToastContext';
1213
import { createGroup, getBalanceSummary, getGroups, joinGroup } from '../services/api';
1314
import { BalanceSummary, Group, GroupBalanceSummary } from '../types';
1415

@@ -24,6 +25,7 @@ export const Groups = () => {
2425

2526
const navigate = useNavigate();
2627
const { style, mode } = useTheme();
28+
const { addToast } = useToast();
2729
const isNeo = style === THEMES.NEOBRUTALISM;
2830

2931
useEffect(() => {
@@ -57,8 +59,9 @@ export const Groups = () => {
5759
setNewGroupName('');
5860
setIsCreateModalOpen(false);
5961
loadData();
62+
addToast('Group created successfully!', 'success');
6063
} catch (err) {
61-
alert('Failed to create group');
64+
addToast('Failed to create group', 'error');
6265
}
6366
};
6467

@@ -69,8 +72,9 @@ export const Groups = () => {
6972
setJoinCode('');
7073
setIsJoinModalOpen(false);
7174
loadData();
75+
addToast('Joined group successfully!', 'success');
7276
} catch (err) {
73-
alert('Failed to join group (Invalid code or already joined)');
77+
addToast('Failed to join group (Invalid code or already joined)', 'error');
7478
}
7579
};
7680

0 commit comments

Comments
 (0)