Spaces:
Sleeping
Sleeping
| 'use client'; | |
| import { | |
| createContext, | |
| useCallback, | |
| useContext, | |
| useEffect, | |
| useMemo, | |
| useState, | |
| } from 'react'; | |
| import { useTranslations } from 'next-intl'; | |
| import { CheckCircle2, AlertTriangle, Info, X } from 'lucide-react'; | |
| type ToastKind = 'success' | 'error' | 'info'; | |
| interface Toast { | |
| id: number; | |
| kind: ToastKind; | |
| message: string; | |
| } | |
| interface ToastContextValue { | |
| toast: (message: string, kind?: ToastKind) => void; | |
| success: (message: string) => void; | |
| error: (message: string) => void; | |
| info: (message: string) => void; | |
| } | |
| const ToastContext = createContext<ToastContextValue | undefined>(undefined); | |
| const KIND_CLASSES: Record<ToastKind, string> = { | |
| success: 'bg-emerald-50 text-emerald-900 ring-emerald-200', | |
| error: 'bg-red-50 text-red-900 ring-red-200', | |
| info: 'bg-slate-50 text-slate-900 ring-slate-200', | |
| }; | |
| const KIND_ICON = { | |
| success: CheckCircle2, | |
| error: AlertTriangle, | |
| info: Info, | |
| } as const; | |
| export function ToastProvider({ children }: { children: React.ReactNode }) { | |
| const tCommon = useTranslations('common'); | |
| const [toasts, setToasts] = useState<Toast[]>([]); | |
| const dismiss = useCallback((id: number) => { | |
| setToasts((prev) => prev.filter((t) => t.id !== id)); | |
| }, []); | |
| const toast = useCallback( | |
| (message: string, kind: ToastKind = 'info') => { | |
| const id = Date.now() + Math.random(); | |
| setToasts((prev) => [...prev, { id, kind, message }]); | |
| setTimeout(() => dismiss(id), 4500); | |
| }, | |
| [dismiss], | |
| ); | |
| const value = useMemo<ToastContextValue>( | |
| () => ({ | |
| toast, | |
| success: (m) => toast(m, 'success'), | |
| error: (m) => toast(m, 'error'), | |
| info: (m) => toast(m, 'info'), | |
| }), | |
| [toast], | |
| ); | |
| return ( | |
| <ToastContext.Provider value={value}> | |
| {children} | |
| <div | |
| aria-live="polite" | |
| aria-atomic="true" | |
| className="pointer-events-none fixed bottom-4 right-4 z-50 flex w-full max-w-sm flex-col gap-2" | |
| > | |
| {toasts.map((t) => { | |
| const Icon = KIND_ICON[t.kind]; | |
| return ( | |
| <div | |
| key={t.id} | |
| role={t.kind === 'error' ? 'alert' : 'status'} | |
| className={`pointer-events-auto flex items-start gap-2 rounded-xl p-3 text-sm shadow-lg ring-1 ${KIND_CLASSES[t.kind]}`} | |
| > | |
| <Icon className="mt-0.5 h-4 w-4 flex-none" aria-hidden /> | |
| <span className="flex-1">{t.message}</span> | |
| <button | |
| type="button" | |
| onClick={() => dismiss(t.id)} | |
| className="flex h-5 w-5 items-center justify-center rounded hover:bg-black/5" | |
| aria-label={tCommon('close')} | |
| > | |
| <X className="h-3.5 w-3.5" aria-hidden /> | |
| </button> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| </ToastContext.Provider> | |
| ); | |
| } | |
| export function useToast(): ToastContextValue { | |
| const ctx = useContext(ToastContext); | |
| if (!ctx) { | |
| // Graceful fallback in case a consumer renders outside the provider | |
| return { | |
| toast: () => undefined, | |
| success: () => undefined, | |
| error: () => undefined, | |
| info: () => undefined, | |
| }; | |
| } | |
| return ctx; | |
| } | |
| // Re-export for callers that want a hook-less ergonomic check. | |
| export function useToastMount() { | |
| const [mounted, setMounted] = useState(false); | |
| useEffect(() => setMounted(true), []); | |
| return mounted; | |
| } | |