import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'; export type ToastVariant = 'success' | 'error' | 'info'; export interface ToastState { id: string; message: string; variant: ToastVariant; title?: string; actionLabel?: string; onAction?: () => void; visual?: ReactNode; } export interface ShowToastOptions { /** 同じ ID の通知は更新し、重複して積まない。 */ id?: string; title?: string; actionLabel?: string; onAction?: () => void; visual?: ReactNode; } let nextToastId = 0; /** * 画面内通知のキュー。既存の `toast` は後方互換のため最新1件を返す。 * 同じ ID は置き換えるため、ポーリング由来の重複通知を表示しない。 */ export function useToast(durationMs = 3500) { const [toasts, setToasts] = useState([]); const timers = useRef(new Map>()); const dismissToast = useCallback((id: string) => { const timer = timers.current.get(id); if (timer) clearTimeout(timer); timers.current.delete(id); setToasts(current => current.filter(toast => toast.id !== id)); }, []); const showToast = useCallback((message: string, variant: ToastVariant = 'success', options: ShowToastOptions = {}) => { const id = options.id ?? `toast-${++nextToastId}`; const timer = timers.current.get(id); if (timer) clearTimeout(timer); const toast: ToastState = { id, message, variant, title: options.title, actionLabel: options.actionLabel, onAction: options.onAction, visual: options.visual }; setToasts(current => [...current.filter(item => item.id !== id), toast]); timers.current.set(id, setTimeout(() => dismissToast(id), durationMs)); }, [dismissToast, durationMs]); useEffect(() => () => { timers.current.forEach(clearTimeout); timers.current.clear(); }, []); const latest = toasts.at(-1); // Legacy consumers only expect these two properties. const toast = latest ? { message: latest.message, variant: latest.variant } : null; return { toast, toasts, showToast, dismissToast }; }