diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx index 0d94ec5..6e09b94 100644 --- a/src/components/ui/toaster.tsx +++ b/src/components/ui/toaster.tsx @@ -1,3 +1,87 @@ "use client"; -// TODO: Wire to @radix-ui/react-toast -export function Toaster() { return null; } + +import * as React from "react"; +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { CheckCircle, XCircle, Info, X } from "lucide-react"; +import clsx from "clsx"; +import { twMerge } from "tailwind-merge"; +import { useToast } from "@/hooks/use-toast"; + +const cn = (...inputs: Parameters) => twMerge(clsx(inputs)); + +const variantConfig = { + success: { + icon: CheckCircle, + className: "border-green-500/50 bg-green-50 text-green-900", + iconClassName: "text-green-600", + }, + error: { + icon: XCircle, + className: "border-red-500/50 bg-red-50 text-red-900", + iconClassName: "text-red-600", + }, + info: { + icon: Info, + className: "border-blue-500/50 bg-blue-50 text-blue-900", + iconClassName: "text-blue-600", + }, +}; + +export function Toaster() { + const { toasts, dismissToast } = useToast(); + + return ( + + {toasts.map((t) => { + const config = variantConfig[t.type]; + const Icon = config.icon; + + return ( + { + if (!open) dismissToast(t.id); + }} + className={cn( + "group pointer-events-auto relative flex w-full items-start gap-3 overflow-hidden rounded-lg border p-4 pr-8 shadow-lg", + "data-[state=open]:animate-in data-[state=open]:slide-in-from-right-full data-[state=open]:duration-300", + "data-[state=closed]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=closed]:duration-200", + config.className + )} + role={t.type === "error" ? "alert" : "status"} + > + + ); + })} + + + + ); +} + +export default Toaster; diff --git a/src/hooks/use-toast.ts b/src/hooks/use-toast.ts new file mode 100644 index 0000000..4c01d31 --- /dev/null +++ b/src/hooks/use-toast.ts @@ -0,0 +1,56 @@ +import { create } from 'zustand'; + +type ToastType = 'success' | 'error' | 'info'; + +interface Toast { + id: string; + type: ToastType; + message: string; + title?: string; +} + +interface ToastStore { + toasts: Toast[]; + addToast: (type: ToastType, message: string, title?: string) => void; + dismissToast: (id: string) => void; +} + +const genId = () => Math.random().toString(36).substring(2, 9); + +const useToastStore = create((set) => ({ + toasts: [], + addToast: (type, message, title) => { + const id = genId(); + set((state) => ({ + toasts: [...state.toasts, { id, type, message, title }], + })); + setTimeout(() => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })); + }, 5000); + }, + dismissToast: (id) => { + set((state) => ({ + toasts: state.toasts.filter((t) => t.id !== id), + })); + }, +})); + +export const useToast = () => { + const toasts = useToastStore((s) => s.toasts); + const addToast = useToastStore((s) => s.addToast); + const dismissToast = useToastStore((s) => s.dismissToast); + + return { + toasts, + toast: { + success: (msg: string, title?: string) => addToast('success', msg, title), + error: (msg: string, title?: string) => addToast('error', msg, title), + info: (msg: string, title?: string) => addToast('info', msg, title), + }, + dismissToast, + }; +}; + +export type { Toast, ToastType };