From 555ae03798753e62014d9b00f0611564014dac00 Mon Sep 17 00:00:00 2001 From: waterWang Date: Sun, 9 Aug 2026 03:22:36 +0800 Subject: [PATCH] feat: add toast notification system with success/error/warning/info variants (Closes #825) --- frontend/src/__tests__/toast.test.tsx | 115 +++++++++++++ .../components/bounty/BountyCreateWizard.tsx | 6 + frontend/src/contexts/ToastContext.tsx | 154 ++++++++++++++++++ frontend/src/main.tsx | 5 +- 4 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/toast.test.tsx create mode 100644 frontend/src/contexts/ToastContext.tsx diff --git a/frontend/src/__tests__/toast.test.tsx b/frontend/src/__tests__/toast.test.tsx new file mode 100644 index 000000000..36e568c32 --- /dev/null +++ b/frontend/src/__tests__/toast.test.tsx @@ -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>) => { + const { initial, animate, exit, transition, layout, ...rest } = props as Record; + return
{children}
; + }, + }, + AnimatePresence: ({ children }: React.PropsWithChildren) => <>{children}, +})); + +// Helper component that surfaces toast actions for testing +function ToastTrigger() { + const toast = useToast(); + return ( +
+ + + + +
+ ); +} + +function renderWithToast(ui: React.ReactElement) { + return render({ui}); +} + +describe('Toast notification system', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('renders a success toast on trigger', () => { + renderWithToast(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + fireEvent.click(screen.getByText('Trigger success')); + const alerts = screen.getAllByRole('alert'); + expect(alerts.length).toBeGreaterThanOrEqual(1); + }); +}); \ No newline at end of file diff --git a/frontend/src/components/bounty/BountyCreateWizard.tsx b/frontend/src/components/bounty/BountyCreateWizard.tsx index 0c4c0d76d..314485036 100644 --- a/frontend/src/components/bounty/BountyCreateWizard.tsx +++ b/frontend/src/components/bounty/BountyCreateWizard.tsx @@ -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; @@ -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(null); @@ -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); } @@ -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); } diff --git a/frontend/src/contexts/ToastContext.tsx b/frontend/src/contexts/ToastContext.tsx new file mode 100644 index 000000000..732e6097e --- /dev/null +++ b/frontend/src/contexts/ToastContext.tsx @@ -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(null); + +const VARIANT_CONFIG: Record = { + success: { + icon: