import React, { createContext, useContext, useState, useEffect, useRef, ReactNode } from 'react'; import { createPortal } from 'react-dom'; import { CheckCircle2, AlertCircle, Info, X, Copy, Check, ChevronDown, ChevronUp, } from 'lucide-react'; import { parseFriendlyError, ErrorCategory } from '../../utils/apiErrorParser'; export type ToastType = 'success' | 'error' | 'info'; export interface ToastItem { id: string; type: ToastType; title: string; message?: string; duration: number; // in milliseconds createdAt: number; statusCode?: number; category?: ErrorCategory; categoryLabel?: string; technicalDetails?: string; fieldErrors?: Array<{ field: string; message: string }>; errorsList?: string[]; } export interface ToastOptions { duration?: number; statusCode?: number; category?: ErrorCategory; categoryLabel?: string; technicalDetails?: string; fieldErrors?: Array<{ field: string; message: string }>; errorsList?: string[]; } interface ToastContextType { toast: (type: ToastType, title: string, message?: string, options?: ToastOptions) => void; success: (title: string, message?: string, options?: ToastOptions) => void; error: (titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions) => void; info: (title: string, message?: string, options?: ToastOptions) => void; } const ToastContext = createContext(undefined); // Default durations (in ms): // Errors linger significantly longer (14s) so users have sufficient time to read and note the failure. const DEFAULT_DURATIONS: Record = { success: 4500, info: 6000, error: 14000, }; const ToastCard: React.FC<{ toast: ToastItem; onRemove: (id: string) => void; }> = ({ toast, onRemove }) => { const [isPaused, setIsPaused] = useState(false); const [remainingTime, setRemainingTime] = useState(toast.duration); const [copied, setCopied] = useState(false); const [showTechnical, setShowTechnical] = useState(false); const intervalRef = useRef | null>(null); useEffect(() => { if (isPaused) { if (intervalRef.current) clearInterval(intervalRef.current); return; } const step = 100; intervalRef.current = setInterval(() => { setRemainingTime((prev) => { if (prev <= step) { if (intervalRef.current) clearInterval(intervalRef.current); onRemove(toast.id); return 0; } return prev - step; }); }, step); return () => { if (intervalRef.current) clearInterval(intervalRef.current); }; }, [isPaused, toast.id, onRemove]); const handleCopy = () => { const textToCopy = [ `PFM Error Notice: ${toast.title}`, toast.message ? `Details: ${toast.message}` : '', toast.statusCode ? `Status Code: ${toast.statusCode}` : '', toast.technicalDetails ? `Technical Info: ${toast.technicalDetails}` : '', `Timestamp: ${new Date(toast.createdAt).toISOString()}`, ] .filter(Boolean) .join('\n'); navigator.clipboard.writeText(textToCopy).then(() => { setCopied(true); setTimeout(() => setCopied(false), 2000); }); }; const progressPercent = Math.max(0, Math.min(100, (remainingTime / toast.duration) * 100)); return (
setIsPaused(true)} onMouseLeave={() => setIsPaused(false)} className={`pointer-events-auto relative flex flex-col rounded-2xl border shadow-2xl transition-all duration-200 overflow-hidden bg-[#0d1322] backdrop-blur-none ${ toast.type === 'success' ? 'border-emerald-500/60 text-gray-100 shadow-emerald-950/40 ring-1 ring-emerald-500/20' : toast.type === 'error' ? 'border-rose-500/70 text-gray-100 shadow-rose-950/60 ring-1 ring-rose-500/30' : 'border-blue-500/60 text-gray-100 shadow-blue-950/40 ring-1 ring-blue-500/20' }`} >
{toast.type === 'success' && } {toast.type === 'error' && } {toast.type === 'info' && }

{toast.title}

{toast.categoryLabel && ( {toast.categoryLabel} )} {toast.statusCode && ( HTTP {toast.statusCode} )}
{/* Display comma-separated error items from detail property one under another */} {toast.errorsList && toast.errorsList.length > 0 ? (
    {toast.errorsList.map((err, idx) => (
  • • {err}
  • ))}
) : toast.message ? (
{toast.message.includes('\n') ? (
    {toast.message .split('\n') .map((l) => l.trim()) .filter(Boolean) .map((line, idx) => (
  • • {line.replace(/^[•\-*]\s*/, '')}
  • ))}
) : (

{toast.message}

)}
) : null} {/* Optional collapsible technical reference */} {toast.technicalDetails && (
{showTechnical && (
{toast.technicalDetails}
)}
)} {/* Action bar for error toasts (Copy info & linger helper) */} {toast.type === 'error' && (
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
)}
{/* Visual countdown progress bar */}
); }; export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const [toasts, setToasts] = useState([]); const removeToast = (id: string) => { setToasts((prev) => prev.filter((t) => t.id !== id)); }; const addToast = ( type: ToastType, title: string, message?: string, options?: ToastOptions ) => { const id = Math.random().toString(36).substring(2, 9); const duration = options?.duration || DEFAULT_DURATIONS[type]; setToasts((prev) => [ ...prev, { id, type, title, message, duration, createdAt: Date.now(), statusCode: options?.statusCode, category: options?.category, categoryLabel: options?.categoryLabel, technicalDetails: options?.technicalDetails, fieldErrors: options?.fieldErrors, errorsList: options?.errorsList, }, ]); }; const success = (title: string, message?: string, options?: ToastOptions) => addToast('success', title, message, options); const info = (title: string, message?: string, options?: ToastOptions) => addToast('info', title, message, options); /** * Intelligently formats errors using ProblemDetails and non-technical language. * Differentiates BadRequest errors for input validation, creation failures, and update failures. * Accepts flexible argument patterns: * - error("Action Failed", err) * - error("Action Failed", "Custom message") * - error(err, "Action Failed") * - error(err) */ const error = ( titleOrError: unknown, messageOrError?: unknown, options?: ToastOptions ) => { let rawError: unknown = null; let actionTitle = 'Action Failed'; if (typeof titleOrError === 'string') { actionTitle = titleOrError; rawError = messageOrError; } else { rawError = titleOrError; if (typeof messageOrError === 'string') { actionTitle = messageOrError; } } // Parse friendly non-technical error with ProblemDetails awareness const parsed = parseFriendlyError(rawError, actionTitle); let finalMessage = parsed.message; if ( typeof messageOrError === 'string' && messageOrError.trim() && !messageOrError.includes('Request failed with status code') && (!parsed.hasReturnObject || !parsed.errorsList?.length) ) { finalMessage = messageOrError.trim(); } let finalErrorsList = parsed.errorsList || options?.errorsList; if (!finalErrorsList && typeof finalMessage === 'string' && finalMessage.includes(',')) { const parts = finalMessage.split(',').map((p) => p.trim()).filter(Boolean); if (parts.length > 1) { finalErrorsList = parts; } } addToast('error', parsed.title, finalMessage, { duration: options?.duration || DEFAULT_DURATIONS.error, statusCode: parsed.statusCode || options?.statusCode, category: options?.category || parsed.category, categoryLabel: options?.categoryLabel || parsed.categoryLabel, technicalDetails: parsed.technicalDetails || options?.technicalDetails, fieldErrors: parsed.fieldErrors || options?.fieldErrors, errorsList: finalErrorsList, }); }; const toastRegion = (
{toasts.map((t) => ( ))}
); return ( {children} {typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null} ); }; export const useToast = () => { const context = useContext(ToastContext); if (!context) { throw new Error('useToast must be used within a ToastProvider'); } return context; };