From f940162306cdb17b6a15802cb818fe8d88e82703 Mon Sep 17 00:00:00 2001 From: Matteo Date: Sun, 3 May 2026 13:45:29 +0200 Subject: [PATCH] Sprint 2 batch 5: unified toast system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @radix-ui/react-toast was listed as a dependency but never wired up. Pages were each rolling their own setMsg(string) state plus a fade-out timer, with no queueing and inconsistent styling. components/toast.tsx exposes a ToastProvider + useToast() hook backed by Radix Toast. Up to 4 toasts visible at once (newest evicts oldest), auto-dismiss after 5s, three tones (success / error / info), keyboard dismissal handled by Radix. Mounted globally inside Providers so any page can call useToast().show({...}). Migrated forgot-password as the first consumer; the rest of the dashboard will be migrated incrementally without breaking — useToast() returns a no-op when called outside the provider, so partially-migrated pages stay green. --- .../frontend/src/app/forgot-password/page.tsx | 11 +- packages/frontend/src/app/providers.tsx | 5 +- packages/frontend/src/components/toast.tsx | 122 ++++++++++++++++++ 3 files changed, 136 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/components/toast.tsx diff --git a/packages/frontend/src/app/forgot-password/page.tsx b/packages/frontend/src/app/forgot-password/page.tsx index 321976f..596b1b6 100644 --- a/packages/frontend/src/app/forgot-password/page.tsx +++ b/packages/frontend/src/app/forgot-password/page.tsx @@ -4,12 +4,14 @@ import { useState } from 'react'; import Link from 'next/link'; import { auth } from '@/lib/api'; import { LogoIcon } from '@/components/nav-bar'; +import { useToast } from '@/components/toast'; export default function ForgotPasswordPage() { const [email, setEmail] = useState(''); const [sent, setSent] = useState(false); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); + const toast = useToast(); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -19,8 +21,15 @@ export default function ForgotPasswordPage() { try { await auth.forgotPassword(email); setSent(true); + toast.show({ + tone: 'success', + title: 'Reset link requested', + description: 'If that email is registered, a reset link is on the way.', + }); } catch (err: any) { - setError(err.message || 'Something went wrong'); + const message = err.message || 'Something went wrong'; + setError(message); + toast.show({ tone: 'error', title: 'Request failed', description: message }); } finally { setLoading(false); } diff --git a/packages/frontend/src/app/providers.tsx b/packages/frontend/src/app/providers.tsx index 65fd103..799a54d 100644 --- a/packages/frontend/src/app/providers.tsx +++ b/packages/frontend/src/app/providers.tsx @@ -2,11 +2,14 @@ import { AuthProvider } from '@/lib/auth-context'; import { ThemeProvider } from '@/lib/theme-context'; +import { ToastProvider } from '@/components/toast'; export function Providers({ children }: { children: React.ReactNode }) { return ( - {children} + + {children} + ); } diff --git a/packages/frontend/src/components/toast.tsx b/packages/frontend/src/components/toast.tsx new file mode 100644 index 0000000..5422727 --- /dev/null +++ b/packages/frontend/src/components/toast.tsx @@ -0,0 +1,122 @@ +'use client'; + +/** + * Unified toast system built on @radix-ui/react-toast. + * + * Replaces the ad-hoc `setMsg(string)` pattern that pages were using to + * surface success/error feedback. Pages call `useToast().show({...})`; + * the queue is rendered by the global Toaster mounted in providers.tsx. + * + * - Auto-dismiss after 5s; the user can dismiss earlier by clicking the + * close button or by hovering and pressing Escape (Radix handles the + * keyboard interactions). + * - Tones: 'success' | 'error' | 'info' (default 'info'). + * - Up to 4 toasts are visible at once; new toasts evict the oldest. + */ + +import * as ToastPrimitive from '@radix-ui/react-toast'; +import { + createContext, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; + +export type ToastTone = 'success' | 'error' | 'info'; + +export interface ToastInput { + title?: string; + description?: string; + tone?: ToastTone; + durationMs?: number; +} + +interface ToastEntry extends ToastInput { + id: string; +} + +interface ToastContextValue { + show: (input: ToastInput) => void; +} + +const ToastContext = createContext(null); + +const MAX_VISIBLE = 4; + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) { + // Don't crash a page that's still wired to the old setMsg pattern; + // give a no-op so migration can be incremental. + return { show: () => {} }; + } + return ctx; +} + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toasts, setToasts] = useState([]); + + const show = useCallback((input: ToastInput) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + setToasts((prev) => { + const next = [...prev, { id, ...input }]; + return next.length > MAX_VISIBLE ? next.slice(next.length - MAX_VISIBLE) : next; + }); + }, []); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const value = useMemo(() => ({ show }), [show]); + + return ( + + + {children} + {toasts.map((t) => ( + { + if (!open) dismiss(t.id); + }} + className={[ + 'mb-2 grid grid-cols-[1fr_auto] gap-3 items-start', + 'rounded-lg px-4 py-3 shadow-lg border', + 'bg-[var(--card)] text-[var(--foreground)]', + t.tone === 'success' + ? 'border-[var(--success-border,_#22c55e)]' + : t.tone === 'error' + ? 'border-[var(--destructive-border,_#ef4444)]' + : 'border-[var(--border)]', + 'data-[state=open]:animate-in data-[state=open]:fade-in', + 'data-[state=closed]:animate-out data-[state=closed]:fade-out', + ].join(' ')} + > +
+ {t.title ? ( + + {t.title} + + ) : null} + {t.description ? ( + + {t.description} + + ) : null} +
+ + × + +
+ ))} + +
+
+ ); +}