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
115 changes: 115 additions & 0 deletions frontend/src/__tests__/toast.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, act, fireEvent } from '@testing-library/react';
import React from 'react';
import { ToastProvider, useToast } from '../contexts/ToastContext';

// Mock framer-motion to avoid animation issues in test environment
vi.mock('framer-motion', () => ({
motion: {
div: ({ children, ...props }: React.PropsWithChildren<Record<string, unknown>>) => {
const { initial, animate, exit, transition, layout, ...rest } = props as Record<string, unknown>;
return <div {...rest}>{children}</div>;
},
},
AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}</>,
}));

// Helper component that surfaces toast actions for testing
function ToastTrigger() {
const toast = useToast();
return (
<div>
<button onClick={() => toast.success('Success title', 'Success description')}>
Trigger success
</button>
<button onClick={() => toast.error('Error title', 'Error description')}>
Trigger error
</button>
<button onClick={() => toast.warning('Warning title', 'Warning description')}>
Trigger warning
</button>
<button onClick={() => toast.info('Info title', 'Info description')}>
Trigger info
</button>
</div>
);
}

function renderWithToast(ui: React.ReactElement) {
return render(<ToastProvider>{ui}</ToastProvider>);
}

describe('Toast notification system', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.useRealTimers();
});

it('renders a success toast on trigger', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger success'));
expect(screen.getByText('Success title')).toBeInTheDocument();
expect(screen.getByText('Success description')).toBeInTheDocument();
});

it('renders an error toast on trigger', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger error'));
expect(screen.getByText('Error title')).toBeInTheDocument();
expect(screen.getByText('Error description')).toBeInTheDocument();
});

it('renders a warning toast on trigger', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger warning'));
expect(screen.getByText('Warning title')).toBeInTheDocument();
expect(screen.getByText('Warning description')).toBeInTheDocument();
});

it('renders an info toast on trigger', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger info'));
expect(screen.getByText('Info title')).toBeInTheDocument();
expect(screen.getByText('Info description')).toBeInTheDocument();
});

it('auto-dismisses toast after 5 seconds', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger success'));
expect(screen.getByText('Success title')).toBeInTheDocument();

act(() => vi.advanceTimersByTime(5001));
expect(screen.queryByText('Success title')).not.toBeInTheDocument();
});

it('dismisses toast on close button click', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger info'));
expect(screen.getByText('Info title')).toBeInTheDocument();

const dismissBtn = screen.getByLabelText('Dismiss notification');
fireEvent.click(dismissBtn);
expect(screen.queryByText('Info title')).not.toBeInTheDocument();
});

it('stacks multiple toasts', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger success'));
fireEvent.click(screen.getByText('Trigger warning'));
fireEvent.click(screen.getByText('Trigger error'));

expect(screen.getByText('Success title')).toBeInTheDocument();
expect(screen.getByText('Warning title')).toBeInTheDocument();
expect(screen.getByText('Error title')).toBeInTheDocument();
});

it('uses role="alert" for accessibility', () => {
renderWithToast(<ToastTrigger />);
fireEvent.click(screen.getByText('Trigger success'));
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThanOrEqual(1);
});
});
6 changes: 6 additions & 0 deletions frontend/src/components/bounty/BountyCreateWizard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Check, ChevronRight, Loader2, Copy } from 'lucide-react';
import type { BountyCreatePayload } from '../../types/bounty';
import { createBounty, getTreasuryDepositInfo, verifyEscrowDeposit } from '../../api/bounties';
import { pageTransition } from '../../lib/animations';
import { useToast } from '../../contexts/ToastContext';

const PRESET_AMOUNTS = [10, 20, 50, 100, 200];
const PLATFORM_FEE_PCT = 0.05;
Expand Down Expand Up @@ -380,6 +381,7 @@ function Step3({

export function BountyCreateWizard() {
const navigate = useNavigate();
const toast = useToast();
const [step, setStep] = useState(0);
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
Expand Down Expand Up @@ -422,8 +424,10 @@ export function BountyCreateWizard() {
onChange('treasury_address', depositInfo.treasury_address);
onChange('total_to_fund', depositInfo.total_to_fund);
setStep(2);
toast.success('Bounty draft created', 'Fund the escrow to publish it.');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to create bounty. Try again.');
toast.error('Could not create bounty', e instanceof Error ? e.message : 'Something went wrong.');
} finally {
setCreating(false);
}
Expand All @@ -436,8 +440,10 @@ export function BountyCreateWizard() {
try {
await verifyEscrowDeposit({ bounty_id: state.bounty_id, tx_signature: state.tx_signature });
setSuccess(true);
toast.success('Bounty published', 'Your bounty is now live on the marketplace.');
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to publish bounty. Try again.');
toast.error('Failed to publish bounty', e instanceof Error ? e.message : 'Something went wrong.');
} finally {
setCreating(false);
}
Expand Down
154 changes: 154 additions & 0 deletions frontend/src/contexts/ToastContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
/**
* ToastContext — global toast notification system.
*
* Provides a `useToast()` hook that surfaces success, error, warning, and info
* toasts. Toasts auto-dismiss after 5 seconds, support manual close, stack
* vertically in the top-right corner, and announce themselves to screen
* readers via `role="alert"`.
*
* Usage:
* ```
* const toast = useToast();
* toast.success('Bounty created');
* toast.error('Something went wrong');
* toast.warning('Deadline approaching');
* toast.info('Good to know');
* ```
*/
import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { CheckCircle2, AlertTriangle, XCircle, Info, X } from 'lucide-react';

export type ToastVariant = 'success' | 'error' | 'warning' | 'info';

export interface ToastItem {
id: number;
variant: ToastVariant;
title: string;
description?: string;
}

interface ToastContextValue {
/** Show a success toast. */
success: (title: string, description?: string) => void;
/** Show an error toast. */
error: (title: string, description?: string) => void;
/** Show a warning toast. */
warning: (title: string, description?: string) => void;
/** Show an info toast. */
info: (title: string, description?: string) => void;
/** Dismiss a toast by id. */
dismiss: (id: number) => void;
}

const AUTO_DISMISS_MS = 5000;

const ToastContext = createContext<ToastContextValue | null>(null);

const VARIANT_CONFIG: Record<ToastVariant, { icon: React.ReactNode; ring: string; iconColor: string }> = {
success: {
icon: <CheckCircle2 className="h-5 w-5" aria-hidden="true" />,
ring: 'border-status-success/40',
iconColor: 'text-status-success',
},
error: {
icon: <XCircle className="h-5 w-5" aria-hidden="true" />,
ring: 'border-status-error/40',
iconColor: 'text-status-error',
},
warning: {
icon: <AlertTriangle className="h-5 w-5" aria-hidden="true" />,
ring: 'border-status-warning/40',
iconColor: 'text-status-warning',
},
info: {
icon: <Info className="h-5 w-5" aria-hidden="true" />,
ring: 'border-status-info/40',
iconColor: 'text-status-info',
},
};

export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const idRef = useRef(0);

const dismiss = useCallback((id: number) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);

const push = useCallback(
(variant: ToastVariant, title: string, description?: string) => {
const id = ++idRef.current;
setToasts((prev) => [...prev, { id, variant, title, description }]);
if (AUTO_DISMISS_MS > 0) {
window.setTimeout(() => dismiss(id), AUTO_DISMISS_MS);
}
},
[dismiss],
);

const api = useMemo<ToastContextValue>(
() => ({
success: (title: string, description?: string) => push('success', title, description),
error: (title: string, description?: string) => push('error', title, description),
warning: (title: string, description?: string) => push('warning', title, description),
info: (title: string, description?: string) => push('info', title, description),
dismiss,
}),
[push, dismiss],
);

return (
<ToastContext.Provider value={api}>
{children}
{/* Top-right toast stack */}
<div
className="pointer-events-none fixed top-4 right-4 z-[100] flex w-[min(20rem,calc(100vw-2rem))] flex-col gap-3"
aria-live="polite"
aria-atomic="false"
>
<AnimatePresence>
{toasts.map((toast) => {
const cfg = VARIANT_CONFIG[toast.variant];
return (
<motion.div
key={toast.id}
layout
initial={{ opacity: 0, x: 40, scale: 0.96 }}
animate={{ opacity: 1, x: 0, scale: 1 }}
exit={{ opacity: 0, x: 40, scale: 0.96 }}
transition={{ type: 'spring', stiffness: 320, damping: 28 }}
role="alert"
className={`pointer-events-auto flex items-start gap-3 rounded-xl border ${cfg.ring} bg-forge-800 p-4 shadow-lg shadow-black/40`}
>
<span className={`mt-0.5 flex-shrink-0 ${cfg.iconColor}`}>{cfg.icon}</span>
<div className="min-w-0 flex-1">
<p className="text-sm font-semibold text-text-primary">{toast.title}</p>
{toast.description && (
<p className="mt-0.5 text-xs leading-relaxed text-text-secondary">
{toast.description}
</p>
)}
</div>
<button
type="button"
onClick={() => dismiss(toast.id)}
className="flex-shrink-0 rounded-md p-1 text-text-muted transition-colors hover:text-text-primary hover:bg-white/5"
aria-label="Dismiss notification"
>
<X className="h-4 w-4" aria-hidden="true" />
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}

export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside ToastProvider');
return ctx;
}
5 changes: 4 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './contexts/AuthContext';
import { ToastProvider } from './contexts/ToastContext';
import { queryClient } from './services/queryClient';
import App from './App';
import './index.css';
Expand All @@ -15,7 +16,9 @@ createRoot(root).render(
<BrowserRouter>
<QueryClientProvider client={queryClient}>
<AuthProvider>
<App />
<ToastProvider>
<App />
</ToastProvider>
</AuthProvider>
</QueryClientProvider>
</BrowserRouter>
Expand Down
Loading