1"use client";
2
3import { useEffect, useState } from "react";
4import { CheckCircle2, Info, TriangleAlert, X } from "lucide-react";
5import { cn } from "@/lib/utils";
6import { useUiStore, type Toast } from "@/stores/ui-store";
7
8const KIND_STYLES: Record<Toast["kind"], { icon: typeof Info; classes: string }> = {
9 success: { icon: CheckCircle2, classes: "border-green-600/30 text-foreground [&_svg]:text-green-600" },
10 error: { icon: TriangleAlert, classes: "border-red-600/40 text-foreground [&_svg]:text-red-600" },
11 info: { icon: Info, classes: "border-border text-foreground [&_svg]:text-muted-foreground" }
12};
13
14function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: (id: string) => void }) {
15 const [visible, setVisible] = useState(false);
16
17 useEffect(() => {
18 const raf = requestAnimationFrame(() => setVisible(true));
19 const timer = setTimeout(() => onDismiss(toast.id), toast.duration);
20 return () => {
21 cancelAnimationFrame(raf);
22 clearTimeout(timer);
23 };
24
25 }, [toast.id]);
26
27 const { icon: Icon, classes } = KIND_STYLES[toast.kind];
28
29 return (
30 <div
31 role="status"
32 className={cn(
33 "pointer-events-auto flex w-80 items-start gap-3 rounded-lg border bg-card p-3 shadow-lg transition-all duration-200",
34 "translate-y-2 opacity-0",
35 visible && "translate-y-0 opacity-100",
36 classes
37 )}
38 >
39 <Icon className="mt-0.5 size-4 shrink-0" aria-hidden />
40 <div className="min-w-0 flex-1">
41 <p className="text-sm font-medium leading-5">{toast.title}</p>
42 {toast.description ? (
43 <p className="mt-0.5 break-words text-xs text-muted-foreground">{toast.description}</p>
44 ) : null}
45 </div>
46 <button
47 type="button"
48 onClick={() => onDismiss(toast.id)}
49 className="rounded p-0.5 text-muted-foreground hover:bg-accent hover:text-foreground"
50 aria-label="Dismiss notification"
51 >
52 <X className="size-3.5" aria-hidden />
53 </button>
54 </div>
55 );
56}
57
58
62export function Toaster() {
63 const toasts = useUiStore((s) => s.toasts);
64 const removeToast = useUiStore((s) => s.removeToast);
65
66 return (
67 <div
68 aria-live="polite"
69 className="pointer-events-none fixed bottom-4 right-4 z-[100] flex flex-col gap-2"
70 >
71 {toasts.map((t) => (
72 <ToastItem key={t.id} toast={t} onDismiss={removeToast} />
73 ))}
74 </div>
75 );
76}
77