'use client' import React, { useEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { twMerge } from 'tailwind-merge' import { useAppDispatch, useAppSelector } from '../redux/hooks' import { removeToast, Toast as ToastType, ToastPosition } from '../redux/reducers/toast' import { Icon } from './icon' type TypeStyles = { container?: string icon?: string } export interface ToastClasses { root?: string item?: string icon?: string message?: string action?: string dismiss?: string animateIn?: string animateOut?: string types?: Partial> positions?: Partial> } export interface ToastContainerProps { classes?: ToastClasses icons?: Partial> customRender?: (props: { toast: ToastType onDismiss: () => void }) => React.ReactNode onAction?: (actionId: string, toast: ToastType) => void } const builtInTypeStyles: Record = { success: { container: 'border-l-4 border-l-success bg-white', icon: 'text-success' }, error: { container: 'border-l-4 border-l-error bg-white', icon: 'text-error' }, warning: { container: 'border-l-4 border-l-[#e89a0c] bg-white', icon: 'text-[#e89a0c]' }, info: { container: 'border-l-4 border-l-primary bg-white', icon: 'text-primary' } } const builtInIcons: Record = { success: 'check', error: 'close', warning: 'info', info: 'info' } const builtInPositions: Record = { 'top-right': 'fixed top-4 right-4', 'top-left': 'fixed top-4 left-4', 'top-center': 'fixed top-4 left-1/2 -translate-x-1/2', 'bottom-right': 'fixed bottom-4 right-4', 'bottom-left': 'fixed bottom-4 left-4', 'bottom-center': 'fixed bottom-4 left-1/2 -translate-x-1/2' } const ToastItem = React.memo(function ToastItem({ toast, classes, icons, customRender, onAction }: { toast: ToastType classes: ToastClasses icons: Record customRender?: ToastContainerProps['customRender'] onAction?: ToastContainerProps['onAction'] }) { const dispatch = useAppDispatch() const [exiting, setExiting] = useState(false) const timerRef = useRef>(null) useEffect(() => { if (toast.duration > 0) { timerRef.current = setTimeout(() => { setExiting(true) }, toast.duration) } return () => { if (timerRef.current) clearTimeout(timerRef.current) } }, [toast.duration]) const handleAnimationEnd = () => { if (exiting) { dispatch(removeToast(toast.id)) } } const handleDismiss = () => { if (timerRef.current) clearTimeout(timerRef.current) setExiting(true) } const animIn = classes.animateIn ?? 'animate-toast-in' const animOut = classes.animateOut ?? 'animate-toast-out' if (customRender) { return (
{customRender({ toast, onDismiss: handleDismiss })}
) } const typeStyle = classes.types?.[toast.type] ?? builtInTypeStyles[toast.type] const iconName = toast.icon ?? icons[toast.type] return (

{toast.message}

{toast.action && onAction && ( )} {toast.dismissible !== false && ( )}
) }) const EMPTY_CLASSES: ToastClasses = {} export function ToastContainer({ classes = EMPTY_CLASSES, icons, customRender, onAction }: ToastContainerProps = {}) { const toasts = useAppSelector((state) => state.toast.toasts) const [mounted, setMounted] = useState(false) const mergedIcons = useMemo( () => ({ ...builtInIcons, ...icons }), [icons] ) const posStyles = useMemo( () => ({ ...builtInPositions, ...classes.positions }), [classes.positions] ) const grouped = useMemo( () => toasts.reduce>((acc, toast) => { const pos = toast.position ?? 'top-right' if (!acc[pos]) acc[pos] = [] acc[pos].push(toast) return acc }, {}), [toasts] ) useEffect(() => { setMounted(true) }, []) if (!mounted || toasts.length === 0) return null return createPortal( <> {Object.entries(grouped).map(([position, items]) => (
{items.map((toast) => ( ))}
))} , document.body ) }