diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 1c78aeb02..bbd15e908 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,6 +1,7 @@
import React, { Suspense } from 'react';
import { Routes, Route } from 'react-router-dom';
import { AuthGuard } from './components/auth/AuthGuard';
+import { ToastContainer } from './components/ui/Toast';
// Lazy load pages
const HomePage = React.lazy(() => import('./pages/HomePage').then((m) => ({ default: m.HomePage })));
@@ -23,7 +24,9 @@ function PageLoader() {
export default function App() {
return (
- }>
+ <>
+
+ }>
} />
} />
@@ -49,6 +52,7 @@ export default function App() {
} />
} />
-
+
+ >
);
}
diff --git a/frontend/src/__tests__/toast.test.tsx b/frontend/src/__tests__/toast.test.tsx
new file mode 100644
index 000000000..fc2433fb8
--- /dev/null
+++ b/frontend/src/__tests__/toast.test.tsx
@@ -0,0 +1,190 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, act, fireEvent } from '@testing-library/react';
+import React from 'react';
+import { ToastProvider, useToastContext } from '../contexts/ToastContext';
+import { ToastContainer } from '../components/ui/Toast';
+
+// Mock framer-motion to avoid animation issues in tests
+vi.mock('framer-motion', () => ({
+ motion: {
+ div: ({ children, ...props }: any) => {
+ // Remove animation-related props
+ const { layout, initial, animate, exit, transition, ...domProps } = props;
+ return
{children}
;
+ },
+ },
+ AnimatePresence: ({ children }: any) => <>{children}>,
+}));
+
+function ToastDemo() {
+ const { success, error, warning, info } = useToastContext();
+
+ return (
+
+
+
+
+
+
+ );
+}
+
+function Wrapper({ children }: { children: React.ReactNode }) {
+ return (
+
+ {children}
+
+
+ );
+}
+
+describe('Toast Notification System', () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it('renders toast container with no toasts initially', () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByLabelText('Notifications')).toBeInTheDocument();
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('displays a success toast with correct message', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('success'));
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert).toHaveTextContent('Success message');
+ });
+
+ it('displays an error toast with correct message', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('error'));
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toBeInTheDocument();
+ expect(alert).toHaveTextContent('Error message');
+ });
+
+ it('displays a warning toast', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('warning'));
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Warning message');
+ });
+
+ it('displays an info toast', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('info'));
+
+ expect(screen.getByRole('alert')).toHaveTextContent('Info message');
+ });
+
+ it('dismisses toast when close button is clicked', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('success'));
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+
+ fireEvent.click(screen.getByLabelText('Dismiss notification'));
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('auto-dismisses toast after 5 seconds', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('success'));
+ expect(screen.getByRole('alert')).toBeInTheDocument();
+
+ act(() => {
+ vi.advanceTimersByTime(5000);
+ });
+
+ expect(screen.queryByRole('alert')).not.toBeInTheDocument();
+ });
+
+ it('stacks multiple toasts', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('success'));
+ fireEvent.click(screen.getByTestId('error'));
+ fireEvent.click(screen.getByTestId('warning'));
+
+ const alerts = screen.getAllByRole('alert');
+ expect(alerts).toHaveLength(3);
+ expect(alerts[0]).toHaveTextContent('Success message');
+ expect(alerts[1]).toHaveTextContent('Error message');
+ expect(alerts[2]).toHaveTextContent('Warning message');
+ });
+
+ it('has accessible role="alert" on each toast', () => {
+ render(
+
+
+
+ );
+
+ fireEvent.click(screen.getByTestId('success'));
+
+ const alert = screen.getByRole('alert');
+ expect(alert).toHaveAttribute('aria-live', 'assertive');
+ });
+
+ it('throws error when useToastContext is used outside provider', () => {
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
+
+ function BadComponent() {
+ useToastContext();
+ return null;
+ }
+
+ expect(() => render()).toThrow(
+ 'useToastContext must be used within a ToastProvider'
+ );
+
+ spy.mockRestore();
+ });
+});
diff --git a/frontend/src/components/ui/Toast.tsx b/frontend/src/components/ui/Toast.tsx
new file mode 100644
index 000000000..45beb953a
--- /dev/null
+++ b/frontend/src/components/ui/Toast.tsx
@@ -0,0 +1,82 @@
+import React from 'react';
+import { motion, AnimatePresence } from 'framer-motion';
+import { X, CheckCircle, AlertCircle, AlertTriangle, Info } from 'lucide-react';
+import { useToastContext } from '../../contexts/ToastContext';
+import type { Toast, ToastVariant } from '../../contexts/ToastContext';
+
+const variantStyles: Record = {
+ success: {
+ bg: 'bg-emerald-bg',
+ border: 'border-emerald-border',
+ icon: CheckCircle,
+ iconColor: 'text-emerald',
+ },
+ error: {
+ bg: 'bg-red-950/30',
+ border: 'border-red-500/30',
+ icon: AlertCircle,
+ iconColor: 'text-red-400',
+ },
+ warning: {
+ bg: 'bg-amber-950/30',
+ border: 'border-amber-500/30',
+ icon: AlertTriangle,
+ iconColor: 'text-amber-400',
+ },
+ info: {
+ bg: 'bg-purple-bg',
+ border: 'border-purple-border',
+ icon: Info,
+ iconColor: 'text-purple-light',
+ },
+};
+
+function ToastItem({ id, message, variant }: { id: string; message: string; variant: ToastVariant }) {
+ const { removeToast } = useToastContext();
+ const style = variantStyles[variant];
+ const Icon = style.icon;
+
+ return (
+
+
+ {message}
+
+
+ );
+}
+
+export function ToastContainer() {
+ const { toasts } = useToastContext();
+
+ return (
+
+
+ {toasts.map((toast: Toast) => (
+
+ ))}
+
+
+ );
+}
diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx
new file mode 100644
index 000000000..f420cad88
--- /dev/null
+++ b/frontend/src/contexts/ToastContext.tsx
@@ -0,0 +1,85 @@
+import React, { createContext, useContext, useState, useCallback, useRef } from 'react';
+
+export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
+
+export interface Toast {
+ id: string;
+ message: string;
+ variant: ToastVariant;
+}
+
+interface ToastContextValue {
+ toasts: Toast[];
+ addToast: (message: string, variant?: ToastVariant) => void;
+ removeToast: (id: string) => void;
+ success: (message: string) => void;
+ error: (message: string) => void;
+ warning: (message: string) => void;
+ info: (message: string) => void;
+}
+
+const ToastContext = createContext(null);
+
+let toastCounter = 0;
+
+export function ToastProvider({ children }: { children: React.ReactNode }) {
+ const [toasts, setToasts] = useState([]);
+ const timersRef = useRef