almeira-web/src/components/publications/ToastProvider.tsx
Carlos (Almeira) dda9ae0f4a feat: implement robust publications module with real-time validation and state management
- Refactor dashboard entity pages to support dynamic publications management.
- Add Publications Module with Atomic Design components (PostCard, PublicationsList, FormSection).
- Implement Zod-based real-time validation for publication forms.
- Manage state using Zustand for optimistic UI and in-place relations.
- Add RBAC protection with PermissionGuard and server-side API validation.
- Fix: Resolve TypeScript type error in AdWrappers.tsx to ensure successful build.
- Add ToastProvider and ErrorBoundary for improved user experience.
- Update dependencies for publications logic.
2026-03-28 08:47:40 -05:00

190 lines
6.3 KiB
TypeScript

'use client';
/**
* @file ToastProvider.tsx
* @description Lightweight global Toast/Notification system.
* Uses React Context + Zustand-like imperative API via a singleton store.
*
* Usage:
* 1. Wrap your layout with <ToastProvider>
* 2. Call toast.success('Mensaje') anywhere in the app
*/
import React, { createContext, useCallback, useContext, useReducer, useRef } from 'react';
import { createPortal } from 'react-dom';
import { CheckCircle, AlertCircle, Info, X, AlertTriangle } from 'lucide-react';
// ─────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────
export type ToastVariant = 'success' | 'error' | 'warning' | 'info';
export interface Toast {
id: string;
message: string;
variant: ToastVariant;
duration?: number;
}
type ToastAction =
| { type: 'ADD'; toast: Toast }
| { type: 'REMOVE'; id: string };
interface ToastContextValue {
toasts: Toast[];
addToast: (message: string, variant?: ToastVariant, duration?: number) => void;
removeToast: (id: string) => void;
}
// ─────────────────────────────────────────────
// CONTEXT
// ─────────────────────────────────────────────
const ToastContext = createContext<ToastContextValue | null>(null);
function toastReducer(state: Toast[], action: ToastAction): Toast[] {
switch (action.type) {
case 'ADD': return [action.toast, ...state].slice(0, 5); // Max 5 toasts
case 'REMOVE': return state.filter((t) => t.id !== action.id);
default: return state;
}
}
// ─────────────────────────────────────────────
// PROVIDER
// ─────────────────────────────────────────────
export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, dispatch] = useReducer(toastReducer, []);
const counterRef = useRef(0);
const removeToast = useCallback((id: string) => {
dispatch({ type: 'REMOVE', id });
}, []);
const addToast = useCallback((
message: string,
variant: ToastVariant = 'info',
duration = 4000
) => {
const id = `toast-${Date.now()}-${++counterRef.current}`;
dispatch({ type: 'ADD', toast: { id, message, variant, duration } });
if (duration > 0) {
setTimeout(() => dispatch({ type: 'REMOVE', id }), duration);
}
}, []);
return (
<ToastContext.Provider value={{ toasts, addToast, removeToast }}>
{children}
<ToastContainer toasts={toasts} onRemove={removeToast} />
</ToastContext.Provider>
);
}
// ─────────────────────────────────────────────
// HOOK
// ─────────────────────────────────────────────
export function useToast() {
const ctx = useContext(ToastContext);
if (!ctx) throw new Error('useToast must be used inside <ToastProvider>');
return {
success: (msg: string, duration?: number) => ctx.addToast(msg, 'success', duration),
error: (msg: string, duration?: number) => ctx.addToast(msg, 'error', duration),
warning: (msg: string, duration?: number) => ctx.addToast(msg, 'warning', duration),
info: (msg: string, duration?: number) => ctx.addToast(msg, 'info', duration),
remove: ctx.removeToast,
};
}
// ─────────────────────────────────────────────
// TOAST ITEM
// ─────────────────────────────────────────────
const variantStyles: Record<ToastVariant, { bg: string; border: string; icon: React.ReactNode }> = {
success: {
bg: 'bg-emerald-950/90',
border: 'border-emerald-500/40',
icon: <CheckCircle size={16} className="text-emerald-400 shrink-0" />,
},
error: {
bg: 'bg-red-950/90',
border: 'border-red-500/40',
icon: <AlertCircle size={16} className="text-red-400 shrink-0" />,
},
warning: {
bg: 'bg-amber-950/90',
border: 'border-amber-500/40',
icon: <AlertTriangle size={16} className="text-amber-400 shrink-0" />,
},
info: {
bg: 'bg-slate-900/90',
border: 'border-cyan-500/30',
icon: <Info size={16} className="text-cyan-400 shrink-0" />,
},
};
const ToastItem = React.memo(function ToastItem({
toast,
onRemove,
}: {
toast: Toast;
onRemove: (id: string) => void;
}) {
const styles = variantStyles[toast.variant];
return (
<div
role="alert"
aria-live="assertive"
className={`
flex items-start gap-3 px-4 py-3 rounded-xl border shadow-2xl
backdrop-blur-xl min-w-[280px] max-w-[400px]
animate-in slide-in-from-right-4 fade-in duration-300
${styles.bg} ${styles.border}
`}
>
{styles.icon}
<p className="text-sm font-medium text-white flex-1 leading-snug">{toast.message}</p>
<button
type="button"
onClick={() => onRemove(toast.id)}
className="text-slate-500 hover:text-white transition-colors ml-1 shrink-0"
aria-label="Cerrar notificación"
>
<X size={14} />
</button>
</div>
);
});
// ─────────────────────────────────────────────
// TOAST CONTAINER (Portal)
// ─────────────────────────────────────────────
function ToastContainer({
toasts,
onRemove,
}: {
toasts: Toast[];
onRemove: (id: string) => void;
}) {
if (typeof document === 'undefined' || toasts.length === 0) return null;
return createPortal(
<div
className="fixed bottom-6 right-6 z-[99999] flex flex-col gap-3"
aria-label="Notificaciones"
>
{toasts.map((t) => (
<ToastItem key={t.id} toast={t} onRemove={onRemove} />
))}
</div>,
document.body
);
}