392 lines
14 KiB
TypeScript
392 lines
14 KiB
TypeScript
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<ToastContextType | undefined>(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<ToastType, number> = {
|
|
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<ReturnType<typeof setInterval> | 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 (
|
|
<div
|
|
onMouseEnter={() => 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'
|
|
}`}
|
|
>
|
|
<div className="flex items-start gap-3 p-4">
|
|
{toast.type === 'success' && <CheckCircle2 className="w-5 h-5 text-emerald-400 shrink-0 mt-0.5" />}
|
|
{toast.type === 'error' && <AlertCircle className="w-5 h-5 text-rose-400 shrink-0 mt-0.5" />}
|
|
{toast.type === 'info' && <Info className="w-5 h-5 text-blue-400 shrink-0 mt-0.5" />}
|
|
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-1.5 flex-wrap">
|
|
<h4 className="text-sm font-semibold text-white tracking-tight leading-snug">{toast.title}</h4>
|
|
{toast.categoryLabel && (
|
|
<span
|
|
className={`text-[10px] font-medium px-1.5 py-0.5 rounded border ${
|
|
toast.category === 'validation'
|
|
? 'bg-amber-950/60 text-amber-300 border-amber-800/60'
|
|
: toast.category === 'creation'
|
|
? 'bg-indigo-950/60 text-indigo-300 border-indigo-800/60'
|
|
: toast.category === 'update'
|
|
? 'bg-sky-950/60 text-sky-300 border-sky-800/60'
|
|
: toast.category === 'security'
|
|
? 'bg-rose-950/60 text-rose-300 border-rose-800/60'
|
|
: toast.category === 'notFound'
|
|
? 'bg-slate-800 text-slate-300 border-slate-700'
|
|
: toast.category === 'conflict'
|
|
? 'bg-purple-950/60 text-purple-300 border-purple-800/60'
|
|
: 'bg-slate-800 text-slate-300 border-slate-700'
|
|
}`}
|
|
>
|
|
{toast.categoryLabel}
|
|
</span>
|
|
)}
|
|
{toast.statusCode && (
|
|
<span className="text-[10px] font-mono font-medium px-1.5 py-0.5 rounded bg-slate-800 text-slate-300 border border-slate-700">
|
|
HTTP {toast.statusCode}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Display comma-separated error items from detail property one under another */}
|
|
{toast.errorsList && toast.errorsList.length > 0 ? (
|
|
<div className="mt-2 space-y-1.5">
|
|
<ul className="space-y-1.5">
|
|
{toast.errorsList.map((err, idx) => (
|
|
<li
|
|
key={idx}
|
|
className="text-xs text-rose-200/95 flex items-start gap-2 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
|
|
>
|
|
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
|
|
<span className="leading-snug break-words font-medium">{err}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
) : toast.message ? (
|
|
<div className="text-xs mt-1.5 text-slate-300 leading-relaxed whitespace-pre-line break-words">
|
|
{toast.message.includes('\n') ? (
|
|
<ul className="space-y-1.5 mt-1">
|
|
{toast.message
|
|
.split('\n')
|
|
.map((l) => l.trim())
|
|
.filter(Boolean)
|
|
.map((line, idx) => (
|
|
<li
|
|
key={idx}
|
|
className="flex items-start gap-2 text-rose-200/95 bg-rose-950/40 px-2.5 py-1.5 rounded-lg border border-rose-900/40"
|
|
>
|
|
<span className="text-rose-400 font-bold mt-0.5 text-sm leading-none">•</span>
|
|
<span className="leading-snug break-words font-medium">{line.replace(/^[•\-*]\s*/, '')}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
) : (
|
|
<p>{toast.message}</p>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{/* Optional collapsible technical reference */}
|
|
{toast.technicalDetails && (
|
|
<div className="mt-2.5 pt-2 border-t border-slate-800/80">
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowTechnical(!showTechnical)}
|
|
className="inline-flex items-center gap-1 text-[11px] text-slate-400 hover:text-slate-200 transition-colors cursor-pointer"
|
|
>
|
|
<span>{showTechnical ? 'Hide Technical Reference' : 'Show Technical Reference'}</span>
|
|
{showTechnical ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
|
|
</button>
|
|
|
|
{showTechnical && (
|
|
<div className="mt-1.5 p-2 rounded bg-[#090d16] border border-slate-800 font-mono text-[10px] text-slate-400 break-all select-all">
|
|
{toast.technicalDetails}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Action bar for error toasts (Copy info & linger helper) */}
|
|
{toast.type === 'error' && (
|
|
<div className="mt-3 flex items-center justify-between gap-2 pt-2 border-t border-slate-800/60 text-[11px] text-slate-400">
|
|
<span className="text-[10px] text-slate-500">
|
|
{isPaused ? 'Timer paused (hovering)' : 'Notification lingers for review'}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={handleCopy}
|
|
className="inline-flex items-center gap-1 px-2 py-1 rounded bg-slate-800/80 hover:bg-slate-700 text-slate-300 hover:text-white transition-colors cursor-pointer"
|
|
title="Copy details to clipboard for support or administrator"
|
|
>
|
|
{copied ? <Check className="w-3 h-3 text-emerald-400" /> : <Copy className="w-3 h-3" />}
|
|
<span>{copied ? 'Copied' : 'Copy Details'}</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => onRemove(toast.id)}
|
|
className="text-slate-400 hover:text-white p-1 rounded-lg hover:bg-slate-800/60 transition-colors shrink-0 cursor-pointer"
|
|
title="Dismiss notification"
|
|
aria-label="Dismiss"
|
|
>
|
|
<X className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Visual countdown progress bar */}
|
|
<div className="w-full bg-slate-800/40 h-1 overflow-hidden">
|
|
<div
|
|
className={`h-full transition-all duration-100 ease-linear ${
|
|
toast.type === 'success'
|
|
? 'bg-emerald-500'
|
|
: toast.type === 'error'
|
|
? 'bg-rose-500'
|
|
: 'bg-blue-500'
|
|
}`}
|
|
style={{ width: `${progressPercent}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
|
const [toasts, setToasts] = useState<ToastItem[]>([]);
|
|
|
|
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 = (
|
|
<div
|
|
role="region"
|
|
aria-label="System Notifications"
|
|
className="fixed bottom-5 right-5 z-[999999] flex flex-col gap-3 max-w-md sm:max-w-lg w-full pointer-events-none p-2"
|
|
style={{ zIndex: 999999 }}
|
|
>
|
|
{toasts.map((t) => (
|
|
<ToastCard key={t.id} toast={t} onRemove={removeToast} />
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<ToastContext.Provider value={{ toast: addToast, success, error, info }}>
|
|
{children}
|
|
{typeof document !== 'undefined' ? createPortal(toastRegion, document.body) : null}
|
|
</ToastContext.Provider>
|
|
);
|
|
};
|
|
|
|
export const useToast = () => {
|
|
const context = useContext(ToastContext);
|
|
if (!context) {
|
|
throw new Error('useToast must be used within a ToastProvider');
|
|
}
|
|
return context;
|
|
};
|