diff --git a/frontend/components/ToastProvider.tsx b/frontend/components/ToastProvider.tsx new file mode 100644 index 0000000..15c982d --- /dev/null +++ b/frontend/components/ToastProvider.tsx @@ -0,0 +1,138 @@ +"use client"; + +/** + * Global imperative toast notification system (#133). + * + * Distinct from NotificationTray (#24), which is a push-based UI for + * server-driven trade status events specifically. This is a general + * "call it from anywhere" feedback surface for ad hoc action feedback — + * trade submitted, withdrawal failed, OTP sent, etc. NotificationTray is + * untouched by this change. + * + * Renders using the existing presentational component + * (components/ui/Toast.tsx) rather than re-implementing its styling — that + * component already covers the success/error/info/warning treatment this + * issue asks for. + */ + +import { + createContext, + useCallback, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { Toast, type ToastType } from "./ui/Toast"; + +const DEFAULT_DURATION_MS = 4_000; +const EXIT_TRANSITION_MS = 200; + +interface ActiveToast { + id: string; + type: ToastType; + message: string; + duration: number; +} + +export interface ToastContextValue { + success: (message: string, duration?: number) => void; + error: (message: string, duration?: number) => void; + info: (message: string, duration?: number) => void; + warning: (message: string, duration?: number) => void; +} + +export const ToastContext = createContext(null); + +/** One toast's enter/exit lifecycle — plain Tailwind transition classes, + * no animation library. Flips from a "hidden" state to a "visible" state on + * the frame after mount (so the transition actually fires instead of + * applying both states in the same render), and reverses on close before + * telling the parent to actually remove it from the list. */ +function ToastItem({ + toast, + onDismiss, +}: { + toast: ActiveToast; + onDismiss: (id: string) => void; +}) { + const [visible, setVisible] = useState(false); + + useEffect(() => { + const raf = requestAnimationFrame(() => setVisible(true)); + return () => cancelAnimationFrame(raf); + }, []); + + const requestClose = useCallback(() => { + setVisible(false); + setTimeout(() => onDismiss(toast.id), EXIT_TRANSITION_MS); + }, [onDismiss, toast.id]); + + useEffect(() => { + if (toast.duration <= 0) return; // duration=0 means "stay until dismissed" + const timer = setTimeout(requestClose, toast.duration); + return () => clearTimeout(timer); + }, [toast.duration, requestClose]); + + return ( +
+ +
+ ); +} + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]); + const [mounted, setMounted] = useState(false); + const nextId = useRef(0); + + // createPortal needs `document`, which doesn't exist during SSR — mirrors + // the mounted-guard pattern already used in ThemeToggle.tsx. + useEffect(() => setMounted(true), []); + + const dismiss = useCallback((id: string) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const push = useCallback( + (type: ToastType, message: string, duration = DEFAULT_DURATION_MS) => { + const id = `toast-${nextId.current++}`; + setToasts((prev) => [...prev, { id, type, message, duration }]); + }, + [], + ); + + const value = useMemo( + () => ({ + success: (message, duration) => push("success", message, duration), + error: (message, duration) => push("error", message, duration), + info: (message, duration) => push("info", message, duration), + warning: (message, duration) => push("warning", message, duration), + }), + [push], + ); + + return ( + + {children} + {mounted && + createPortal( +
+ {toasts.map((t) => ( + + ))} +
, + document.body, + )} +
+ ); +} \ No newline at end of file