- 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.
190 lines
6.3 KiB
TypeScript
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
|
|
);
|
|
}
|