/** * Toast Notification Component * Displays temporary notifications at the top-right of the screen */ import React, { createContext, useContext, useState, useCallback, useEffect, } from "react"; import { X, CheckCircle2, AlertCircle, Info, AlertTriangle, } from "lucide-react"; import { __ } from "../../lib/i18n"; export type ToastType = "success" | "error" | "warning" | "info"; export interface Toast { id: string; message: string; type: ToastType; duration?: number; } interface ToastContextType { showToast: (message: string, type?: ToastType, duration?: number) => void; removeToast: (id: string) => void; } const ToastContext = createContext(undefined); export const useToast = () => { const context = useContext(ToastContext); if (!context) { throw new Error("useToast must be used within ToastProvider"); } return context; }; export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children, }) => { const [toasts, setToasts] = useState([]); const showToast = useCallback( (message: string, type: ToastType = "success", duration: number = 4000) => { const id = `toast-${Date.now()}-${Math.random()}`; const newToast: Toast = { id, message, type, duration }; setToasts((prev) => [...prev, newToast]); if (duration > 0) { setTimeout(() => { removeToast(id); }, duration); } }, // eslint-disable-next-line react-hooks/exhaustive-deps [], ); const removeToast = useCallback((id: string) => { setToasts((prev) => prev.filter((toast) => toast.id !== id)); }, []); return ( {children} ); }; interface ToastContainerProps { toasts: Toast[]; onRemove: (id: string) => void; } const ToastContainer: React.FC = ({ toasts, onRemove, }) => { // Sit below the WordPress admin bar (and above it in stacking order) so // toasts aren't hidden behind it on the front-end account page. The admin // bar is 46px on small screens (<=782px) and 32px otherwise; +16px gap. const topOffset = React.useMemo(() => { if (typeof document === "undefined") return 16; if (!document.body.classList.contains("admin-bar")) return 16; const small = typeof window !== "undefined" && window.innerWidth <= 782; return (small ? 46 : 32) + 16; }, []); if (toasts.length === 0) return null; return (
{toasts.map((toast) => ( ))}
); }; interface ToastItemProps { toast: Toast; onRemove: (id: string) => void; } const ToastItem: React.FC = ({ toast, onRemove }) => { const [isVisible, setIsVisible] = useState(false); useEffect(() => { // Trigger animation setTimeout(() => setIsVisible(true), 10); }, []); const handleRemove = () => { setIsVisible(false); setTimeout(() => onRemove(toast.id), 300); }; const getIcon = () => { switch (toast.type) { case "success": return ; case "error": return ; case "warning": return ; case "info": return ; default: return ; } }; const getStyles = () => { switch (toast.type) { case "success": return "bg-green-50 border-green-200 text-green-800 dark:bg-green-900/20 dark:border-green-800 dark:text-green-400"; case "error": return "bg-red-50 border-red-200 text-red-800 dark:bg-red-900/20 dark:border-red-800 dark:text-red-400"; case "warning": return "bg-yellow-50 border-yellow-200 text-yellow-800 dark:bg-yellow-900/20 dark:border-yellow-800 dark:text-yellow-400"; case "info": return "bg-blue-50 border-blue-200 text-blue-800 dark:bg-blue-900/20 dark:border-blue-800 dark:text-blue-400"; default: return "bg-gray-50 border-gray-200 text-gray-800 dark:bg-gray-900/20 dark:border-gray-800 dark:text-gray-400"; } }; return (
{getIcon()}
{toast.message}
); };