"use client";
import React, {
useState,
useEffect,
useCallback,
createContext,
useContext,
} from "react";
import { motion, AnimatePresence } from "motion/react";
import { cn } from "@/lib/utils";
import {
X,
CheckCircle2,
AlertCircle,
Info,
AlertTriangle,
Loader2,
} from "lucide-react";
// --- Types ---
export type ToastVariant =
| "default"
| "success"
| "error"
| "warning"
| "info"
| "loading";
export type ToastPosition =
| "top-left"
| "top-right"
| "bottom-left"
| "bottom-right";
export interface ToastProps {
id: string;
title?: string;
description?: string;
variant?: ToastVariant;
duration?: number;
position?: ToastPosition;
onClose: (id: string) => void;
}
// --- Icons Mapping ---
const VARIANT_ICONS: Record<ToastVariant, React.ReactNode> = {
default: null,
success: <CheckCircle2 className="w-4 h-4 mt-0.5 text-emerald-500" />,
error: <AlertCircle className="w-4 h-4 mt-0.5 text-red-500" />,
warning: <AlertTriangle className="w-4 h-4 mt-0.5 text-amber-500" />,
info: <Info className="w-4 h-4 mt-0.5 text-blue-500" />,
loading: (
<Loader2 className="w-4 h-4 mt-0.5 text-foreground/50 animate-spin" />
),
};
// --- Toast Component ---
export const Toast = React.memo(
React.forwardRef<HTMLDivElement, ToastProps>(
(
{
id,
title,
description,
variant = "default",
duration = 5000,
position = "bottom-right",
onClose,
},
ref,
) => {
useEffect(() => {
if (duration === Infinity) return;
const timer = setTimeout(() => onClose(id), duration);
return () => clearTimeout(timer);
}, [id, duration, onClose]);
const isLeft = position.includes("left");
const isTop = position.includes("top");
return (
<motion.div
ref={ref}
layout
initial={{
opacity: 0,
y: isTop ? -20 : 20,
x: isLeft ? -20 : 20,
scale: 0.9,
filter: "blur(10px)",
}}
animate={{ opacity: 1, y: 0, x: 0, scale: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
x: isLeft ? -40 : 40,
scale: 0.5,
filter: "blur(5px)",
transition: { duration: 0.2 },
}}
whileHover={{ scale: 1.02 }}
className={cn(
"group relative flex w-full max-w-sm gap-2 overflow-hidden rounded-md border p-3 shadow-sm transition-all",
"bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl",
"border-foreground/10 hover:border-foreground/20",
variant === "success" &&
"border-foreground/10 shadow-emerald-500/5",
variant === "error" && "border-foreground/10 shadow-red-500/5",
variant === "warning" && "border-foreground/10 shadow-amber-500/5",
variant === "info" && "border-foreground/10 shadow-blue-500/5",
)}
>
{/* Progress Bar (Subtle) */}
{duration !== Infinity && (
<motion.div
initial={{ scaleX: 1 }}
animate={{ scaleX: 0 }}
transition={{ duration: duration / 1000, ease: "linear" }}
className={cn(
"absolute bottom-0 left-0 h-0.5 w-full origin-left bg-foreground/10",
variant === "success" && "bg-emerald-500/70",
variant === "error" && "bg-red-500/70",
variant === "warning" && "bg-amber-500/70",
variant === "info" && "bg-blue-500/70",
variant === "loading" && "bg-foreground/30",
)}
/>
)}
{/* Icon */}
{VARIANT_ICONS[variant] && (
<div className="flex shrink-0 items-start pt-0.5">
{VARIANT_ICONS[variant]}
</div>
)}
{/* Content */}
<div className="flex flex-1 flex-col gap-1 pr-6">
{title && (
<h3 className="text-md font-semibold text-foreground/90">
{title}
</h3>
)}
{description && (
<p className="text-sm text-foreground/50 font-medium">
{description}
</p>
)}
</div>
{/* Close Button */}
<button
onClick={() => onClose(id)}
className="absolute right-3 top-3 rounded-lg p-1 text-foreground/20 opacity-0 transition-all hover:bg-foreground/5 hover:text-foreground/80 group-hover:opacity-100"
>
<X className="w-4 h-4" />
</button>
</motion.div>
);
},
),
);
Toast.displayName = "Toast";
type ToastAction =
| { type: "ADD_TOAST"; toast: Omit<ToastProps, "onClose"> }
| { type: "REMOVE_TOAST"; id: string };
interface ToastState {
toasts: Omit<ToastProps, "onClose">[];
}
const toastReducer = (state: ToastState, action: ToastAction): ToastState => {
switch (action.type) {
case "ADD_TOAST":
return { toasts: [action.toast, ...state.toasts].slice(0, 5) };
case "REMOVE_TOAST":
return { toasts: state.toasts.filter((t) => t.id !== action.id) };
default:
return state;
}
};
let memoryState: ToastState = { toasts: [] };
let listeners: React.Dispatch<React.SetStateAction<ToastState>>[] = [];
function dispatch(action: ToastAction) {
memoryState = toastReducer(memoryState, action);
listeners.forEach((listener) => listener(memoryState));
}
type ToastOptions = Omit<ToastProps, "id" | "onClose">;
function toastFunction(props: ToastOptions) {
const id = Math.random().toString(36).substring(2, 9);
dispatch({ type: "ADD_TOAST", toast: { ...props, id } });
return id;
}
toastFunction.dismiss = (id: string) => dispatch({ type: "REMOVE_TOAST", id });
toastFunction.success = (title: string, description?: string) =>
toastFunction({ title, description, variant: "success" });
toastFunction.error = (title: string, description?: string) =>
toastFunction({ title, description, variant: "error" });
toastFunction.warning = (title: string, description?: string) =>
toastFunction({ title, description, variant: "warning" });
toastFunction.info = (title: string, description?: string) =>
toastFunction({ title, description, variant: "info" });
toastFunction.loading = (title: string, description?: string) =>
toastFunction({ title, description, variant: "loading", duration: Infinity });
export const toast = toastFunction;
type ToastContextType = {
toast: typeof toast;
dismiss: typeof toast.dismiss;
};
const ToastContext = createContext<ToastContextType | null>(null);
export function ToastProvider({
children,
position = "bottom-right",
}: {
children: React.ReactNode;
position?: ToastPosition;
}) {
return (
<ToastContext.Provider value={{ toast, dismiss: toast.dismiss }}>
{children}
<Toaster position={position} />
</ToastContext.Provider>
);
}
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error("useToast must be used within a ToastProvider");
}
return context;
};
// --- Toaster Component ---
export function Toaster({
position = "bottom-right",
}: {
position?: ToastPosition;
}) {
const [state, setState] = useState<ToastState>(memoryState);
useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) listeners.splice(index, 1);
};
}, []);
const positionClasses = {
"top-left": "top-0 left-0",
"top-right": "top-0 right-0",
"bottom-left": "bottom-0 left-0",
"bottom-right": "bottom-0 right-0",
};
const isLeft = position.includes("left");
return (
<div
className={cn(
"fixed z-100 flex flex-col gap-3 p-4 sm:p-6 sm:max-w-[420px] pointer-events-none",
positionClasses[position],
isLeft ? "items-start" : "items-end",
)}
>
<AnimatePresence mode="popLayout">
{state.toasts.map((t) => (
<div key={t.id} className="pointer-events-auto w-full">
<Toast {...t} position={position} onClose={toast.dismiss} />
</div>
))}
</AnimatePresence>
</div>
);
}