'use client' import React, { createContext, useCallback, useContext, useEffect, useState, type ReactNode } from 'react' import { createRoot } from 'react-dom/client' import { isPageTransitioning, onPageTransitionEnd } from '../animation/pageTransitionEffect' type ToastVariant = 'success' | 'error' | 'info' | 'warning' export interface ToastOptions { id?: string title?: string message: string variant?: ToastVariant duration?: number // ms, default 4000 } // add optional closing flag for exit animation interface ToastInternal extends ToastOptions { id: string closing?: boolean } interface ToastContextValue { showToast: (options: ToastOptions) => void } // increase fade duration const TOAST_ANIMATION_MS = 400 // fade-out duration in ms // --- global toast store so toasts survive route changes --- let globalToasts: ToastInternal[] = [] type ToastListener = (toasts: ToastInternal[]) => void const toastListeners = new Set() const toastTimeouts = new Map>() // NEW: portal state (single global React root) let toastPortalContainer: HTMLDivElement | null = null let toastPortalRoot: ReturnType | null = null let toastPortalMounted = false function notifyToastListeners() { for (const listener of toastListeners) { listener(globalToasts) } } function removeToastInternal(id: string) { // clear auto-dismiss timer const timeout = toastTimeouts.get(id) if (timeout) { clearTimeout(timeout) toastTimeouts.delete(id) } const existing = globalToasts.find(t => t.id === id) if (!existing) return if (existing.closing) { // already closing: hard-remove immediately globalToasts = globalToasts.filter(t => t.id !== id) notifyToastListeners() return } // mark as closing to trigger fade-out globalToasts = globalToasts.map(t => t.id === id ? { ...t, closing: true } : t ) notifyToastListeners() // remove after animation finishes setTimeout(() => { globalToasts = globalToasts.filter(t => t.id !== id) notifyToastListeners() }, TOAST_ANIMATION_MS) } function addToast(options: ToastOptions) { // Defer toast until page transition overlay has fully exited if (typeof window !== 'undefined' && isPageTransitioning) { onPageTransitionEnd(() => addToast(options)) return } const id = options.id ?? `${Date.now()}-${Math.random().toString(36).slice(2)}` const toast: ToastInternal = { id, variant: options.variant ?? 'info', duration: options.duration ?? 4000, ...options } globalToasts = [...globalToasts, toast] notifyToastListeners() if (toast.duration && toast.duration > 0) { const timeout = setTimeout(() => { removeToastInternal(id) }, toast.duration) toastTimeouts.set(id, timeout) } } // NEW: mount a global portal once per browser session function ensureToastPortalMounted() { if (toastPortalMounted) return if (typeof document === 'undefined') return toastPortalMounted = true toastPortalContainer = document.createElement('div') document.body.appendChild(toastPortalContainer) toastPortalRoot = createRoot(toastPortalContainer) // Defer actual render to avoid triggering nested updates during React render setTimeout(() => { if (!toastPortalRoot) return toastPortalRoot.render() }, 0) } // --- context & provider --- const ToastContext = createContext(undefined) export function useToast(): ToastContextValue { const ctx = useContext(ToastContext) if (ctx) { // Normal path: inside return ctx } // Fallback path: no provider mounted, still use global store/portal if (typeof window !== 'undefined') { ensureToastPortalMounted() } return { showToast: (options: ToastOptions) => { addToast(options) } } } interface ToastProviderProps { children: ReactNode } export function ToastProvider({ children }: ToastProviderProps) { // ensure the global portal is mounted when any provider appears useEffect(() => { ensureToastPortalMounted() }, []) const showToast = useCallback((options: ToastOptions) => { addToast(options) }, []) return ( {children} ) } // NEW: global viewport rendered via portal, independent of pages/providers function ToastViewport() { const [toasts, setToasts] = useState(globalToasts) useEffect(() => { const listener: ToastListener = (next) => setToasts(next) toastListeners.add(listener) setToasts(globalToasts) return () => { toastListeners.delete(listener) } }, []) const handleClose = useCallback((id: string) => { removeToastInternal(id) }, []) if (!toasts.length) return null return ( <>
{toasts.map(t => ( handleClose(t.id)} /> ))}
) } interface ToastItemProps { toast: ToastInternal onClose: () => void } const TOAST_ICONS: Record = { success: ( ), error: ( ), info: ( ), warning: ( ), } const TOAST_VARIANT_STYLES: Record = { success: { card: 'border border-emerald-200 border-l-4 border-l-emerald-400 bg-emerald-50/90 shadow-[0_8px_32px_-8px_rgba(5,150,105,0.18)]', iconWrap: 'bg-emerald-100 text-emerald-600', title: 'text-emerald-800', message: 'text-emerald-900/80', close: 'text-emerald-400 hover:bg-emerald-100 hover:text-emerald-700', }, error: { card: 'border border-red-200 border-l-4 border-l-red-400 bg-red-50/90 shadow-[0_8px_32px_-8px_rgba(220,38,38,0.18)]', iconWrap: 'bg-red-100 text-red-600', title: 'text-red-800', message: 'text-red-900/80', close: 'text-red-300 hover:bg-red-100 hover:text-red-700', }, info: { card: 'border border-sky-200 border-l-4 border-l-sky-400 bg-sky-50/90 shadow-[0_8px_32px_-8px_rgba(14,165,233,0.18)]', iconWrap: 'bg-sky-100 text-sky-600', title: 'text-sky-800', message: 'text-sky-900/80', close: 'text-sky-300 hover:bg-sky-100 hover:text-sky-700', }, warning: { card: 'border border-amber-200 border-l-4 border-l-amber-400 bg-amber-50/90 shadow-[0_8px_32px_-8px_rgba(217,119,6,0.18)]', iconWrap: 'bg-amber-100 text-amber-600', title: 'text-amber-800', message: 'text-amber-900/80', close: 'text-amber-300 hover:bg-amber-100 hover:text-amber-700', }, } function ToastItem({ toast, onClose }: ToastItemProps) { const { title, message, variant } = toast const v = variant ?? 'info' const styles = TOAST_VARIANT_STYLES[v] // local visible state for entry animation const [visible, setVisible] = useState(false) useEffect(() => { const frame = requestAnimationFrame(() => setVisible(true)) return () => cancelAnimationFrame(frame) }, []) const isClosing = !!toast.closing const motionClasses = isClosing ? 'opacity-0 translate-y-2 scale-95' : visible ? 'opacity-100 translate-y-0 scale-100' : 'opacity-0 translate-y-2 scale-95' return (
{TOAST_ICONS[v]}
{title && (
{title}
)}
{message}
) }