// Toast.tsx 'use client' import React, { useEffect, useState, useRef, useCallback } from 'react' import { PColor } from '../../../assets/colors' import { SwipeableCard } from '../SwipeableCard' import { Icon, Row, Text } from '../../atoms' import { getGlobalStyle } from '../../../utils' import styles from './styles.module.css' export enum ToastPosition { 'top-left' = 'top-left', 'top-right' = 'top-right', 'bottom-left' = 'bottom-left', 'bottom-right' = 'bottom-right' } export interface ToastItem { position?: ToastPosition id: number title: string description: string backgroundColor?: 'success' | 'warning' | 'error' } export interface ToastProps { toastList: ToastItem[] position?: ToastPosition autoDelete?: boolean autoDeleteTime?: number deleteToast: (id: string) => void } const STACK_THRESHOLD = 0 const VISIBLE_STACK = 5 // number of visible cards when stacked const OFFSET_Y = 20 const SCALE_STEP = 0.02 const ROTATION_DEG = 0 export const Toast: React.FC = (props) => { const { toastList, position = ToastPosition['top-right'], autoDelete, autoDeleteTime = 5000, deleteToast } = props const [list, setList] = useState([...toastList]) const timeoutRefs = useRef>>({}) useEffect(() => { setList([...toastList]) }, [toastList]) useEffect(() => { // Clear all timeouts on unmount or when list changes return () => { Object.values(timeoutRefs.current).forEach((timeoutId) => { globalThis.clearTimeout(timeoutId) }) timeoutRefs.current = {} } }, []) useEffect(() => { if (autoDelete) { // Set a timeout for each toast that doesn't already have one list.forEach((toast) => { if (!timeoutRefs.current[toast.id]) { timeoutRefs.current[toast.id] = globalThis.setTimeout(() => { deleteToastById(toast.id) }, autoDeleteTime) } }) } // Clean up timeouts for removed toasts Object.keys(timeoutRefs.current).forEach((id) => { if (!list.find((t) => t.id === Number(id))) { globalThis.clearTimeout(timeoutRefs.current[Number(id)]) delete timeoutRefs.current[Number(id)] } }) // Clean up on unmount return () => { Object.values(timeoutRefs.current).forEach((timeoutId) => { globalThis.clearTimeout(timeoutId) }) timeoutRefs.current = {} } // eslint-disable-next-line react-hooks/exhaustive-deps }, [autoDelete, autoDeleteTime, list]) /** * Deletes toast by id: clears its timeout and updates state + parent. */ const deleteToastById = useCallback((id: number) => { try { if (timeoutRefs.current[id]) { globalThis.clearTimeout(timeoutRefs.current[id]) delete timeoutRefs.current[id] } setList((prev) => prev.filter((t) => t.id !== id)) deleteToast(String(id)) } catch (err) { // defensive: avoid throwing inside timer // eslint-disable-next-line no-console console.error('Error deleting toast', err) } }, [deleteToast]) const getBackgroundColor = (color?: 'success' | 'warning' | 'error') => { switch (color) { case 'warning': return '#ebbc26' case 'error': return `${PColor}69` default: return '#50a773' } } const getIcon = (color?: 'success' | 'warning' | 'error') => { switch (color) { case 'warning': return 'IconWarning' case 'error': return 'IconError' case 'success': return 'IconSuccess' default: return 'IconInfo' } } // Decide if we should use per-item positions or the component-level position const hasItemPositions = list.some((t) => t.position !== undefined && t.position !== null) // Helper to render a group (for a given corner position) const renderGroup = (groupPos: ToastPosition, groupList: ToastItem[]) => { if (groupList.length === 0) return null const isBottom = groupPos === ToastPosition['bottom-left'] || groupPos === ToastPosition['bottom-right'] const isLeft = groupPos === ToastPosition['top-left'] || groupPos === ToastPosition['bottom-left'] const isStacked = groupList.length >= STACK_THRESHOLD const stackVisible = isStacked ? groupList.slice(-VISIBLE_STACK) : [] const containerClass = [ styles['notification-container'], styles[groupPos] ?? '', isStacked ? styles['stacked'] : '', isBottom ? styles['stack-bottom'] : styles['stack-top'], isLeft ? styles['stack-left'] : styles['stack-right'] ].join(' ').trim() return (
{!isStacked && groupList.map((toast, i) => { return ( deleteToastById(toast.id)} delay={1500} style={{ top: 22, right: 22, }} rightActions={groupPos === ToastPosition['bottom-left'] ? null :
deleteToastById(toast.id)} role='button' aria-label={`Eliminar notificación ${toast.title}`} style={{ height: 75, display: 'flex', alignItems: 'center', justifyContent: 'center' }} >
} >

{toast.title}

{toast.description}

) })} {isStacked && (
{stackVisible.map((toast, idx) => { const depth = stackVisible.length - 1 - idx const offset = depth * OFFSET_Y const translateY = isBottom ? -offset : offset const scale = 1 - depth * SCALE_STEP const rotate = -depth * ROTATION_DEG const opacity = 1 - depth * 0.08 const zIndex = 2000 + (stackVisible.length - depth) const transform = `translateY(${translateY}px) translateZ(0) scale(${scale}) rotate(${rotate}deg)` return (
deleteToastById(toast.id)} style={{ top: 15, right: 15 }} rightActions={groupPos === ToastPosition['bottom-left'] ? null :
deleteToastById(toast.id)} role='button' aria-label={`Eliminar notificación ${toast.title}`} style={{ height: 70, display: 'flex', alignItems: 'center', justifyContent: 'center' }} >
} >
{String(toast.title)} <>

{String(toast.description)}

) })} {groupList.length > VISIBLE_STACK && (
+{groupList.length - VISIBLE_STACK}
)}
)}
) } // If any item has its own position, render groups per position using item.position. if (hasItemPositions) { const posValues = [ ToastPosition['top-right'], ToastPosition['top-left'], ToastPosition['bottom-right'], ToastPosition['bottom-left'] ] as ToastPosition[] return ( <> {posValues.map((pos) => { const groupList = list.filter((t) => (t.position ?? position) === pos) return renderGroup(pos, groupList) })} ) } // Fallback: no per-item positions — render single container using component-level position return renderGroup(position, list) }