mirror of
https://github.com/lukevella/rallly.git
synced 2025-08-02 07:58:57 +02:00
♻️ Replace old toaster with sonner (#1801)
This commit is contained in:
parent
ac75e690a8
commit
961a493a29
11 changed files with 87 additions and 312 deletions
|
@ -12,7 +12,6 @@ import {
|
|||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@rallly/ui/form";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import Cookies from "js-cookie";
|
||||
import { InfoIcon } from "lucide-react";
|
||||
|
@ -24,6 +23,7 @@ import { z } from "zod";
|
|||
import { Trans } from "@/components/trans";
|
||||
import { useUser } from "@/components/user-provider";
|
||||
import { trpc } from "@/trpc/client";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
|
||||
const emailChangeFormData = z.object({
|
||||
email: z.string().email(),
|
||||
|
@ -41,7 +41,6 @@ export const ProfileEmailAddress = () => {
|
|||
resolver: zodResolver(emailChangeFormData),
|
||||
});
|
||||
const { t } = useTranslation("app");
|
||||
const { toast } = useToast();
|
||||
|
||||
const [didRequestEmailChange, setDidRequestEmailChange] =
|
||||
React.useState(false);
|
||||
|
@ -52,35 +51,38 @@ export const ProfileEmailAddress = () => {
|
|||
|
||||
if (success) {
|
||||
posthog.capture("email change completed");
|
||||
toast({
|
||||
title: t("emailChangeSuccess", {
|
||||
toast.message(
|
||||
t("emailChangeSuccess", {
|
||||
defaultValue: "Email changed successfully",
|
||||
}),
|
||||
description: t("emailChangeSuccessDescription", {
|
||||
defaultValue: "Your email has been updated",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("emailChangeSuccessDescription", {
|
||||
defaultValue: "Your email has been updated",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
posthog.capture("email change failed", { error });
|
||||
toast({
|
||||
variant: "destructive",
|
||||
title: t("emailChangeFailed", {
|
||||
toast.error(
|
||||
t("emailChangeFailed", {
|
||||
defaultValue: "Email change failed",
|
||||
}),
|
||||
description:
|
||||
error === "invalidToken"
|
||||
? t("emailChangeInvalidToken", {
|
||||
defaultValue:
|
||||
"The verification link is invalid or has expired. Please try again.",
|
||||
})
|
||||
: t("emailChangeError", {
|
||||
defaultValue: "An error occurred while changing your email",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description:
|
||||
error === "invalidToken"
|
||||
? t("emailChangeInvalidToken", {
|
||||
defaultValue:
|
||||
"The verification link is invalid or has expired. Please try again.",
|
||||
})
|
||||
: t("emailChangeError", {
|
||||
defaultValue: "An error occurred while changing your email",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [posthog, t, toast]);
|
||||
}, [posthog, t]);
|
||||
|
||||
const { handleSubmit, formState, reset } = form;
|
||||
return (
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
|
||||
import { usePostHog } from "@rallly/posthog/client";
|
||||
import { Button } from "@rallly/ui/button";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import React, { useState } from "react";
|
||||
import { z } from "zod";
|
||||
|
||||
|
@ -12,6 +11,7 @@ import { useUser } from "@/components/user-provider";
|
|||
import { useFeatureFlag } from "@/features/feature-flags/client";
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
import { trpc } from "@/trpc/client";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
|
||||
const allowedMimeTypes = z.enum(["image/jpeg", "image/png"]);
|
||||
|
||||
|
@ -19,7 +19,6 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
|
|||
const getPresignedUrl = trpc.user.getAvatarUploadUrl.useMutation();
|
||||
const updateAvatar = trpc.user.updateAvatar.useMutation();
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const handleFileChange = async (
|
||||
|
@ -32,28 +31,32 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
|
|||
const parsedFileType = allowedMimeTypes.safeParse(file.type);
|
||||
|
||||
if (!parsedFileType.success) {
|
||||
toast({
|
||||
title: t("invalidFileType", {
|
||||
toast.message(
|
||||
t("invalidFileType", {
|
||||
defaultValue: "Invalid file type",
|
||||
}),
|
||||
description: t("invalidFileTypeDescription", {
|
||||
defaultValue: "Please upload a JPG or PNG file.",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("invalidFileTypeDescription", {
|
||||
defaultValue: "Please upload a JPG or PNG file.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const fileType = parsedFileType.data;
|
||||
|
||||
if (file.size > 2 * 1024 * 1024) {
|
||||
toast({
|
||||
title: t("fileTooLarge", {
|
||||
toast.message(
|
||||
t("fileTooLarge", {
|
||||
defaultValue: "File too large",
|
||||
}),
|
||||
description: t("fileTooLargeDescription", {
|
||||
defaultValue: "Please upload a file smaller than 2MB.",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("fileTooLargeDescription", {
|
||||
defaultValue: "Please upload a file smaller than 2MB.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsUploading(true);
|
||||
|
@ -81,15 +84,17 @@ function ChangeAvatarButton({ onSuccess }: { onSuccess: () => void }) {
|
|||
|
||||
onSuccess();
|
||||
} catch {
|
||||
toast({
|
||||
title: t("errorUploadPicture", {
|
||||
toast.error(
|
||||
t("errorUploadPicture", {
|
||||
defaultValue: "Failed to upload",
|
||||
}),
|
||||
description: t("errorUploadPictureDescription", {
|
||||
defaultValue:
|
||||
"There was an issue uploading your picture. Please try again later.",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("errorUploadPictureDescription", {
|
||||
defaultValue:
|
||||
"There was an issue uploading your picture. Please try again later.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
|
|
|
@ -29,7 +29,7 @@ import {
|
|||
FormItem,
|
||||
FormLabel,
|
||||
} from "@rallly/ui/form";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import { Switch } from "@rallly/ui/switch";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
|
@ -45,8 +45,6 @@ export function InstanceSettingsForm({
|
|||
|
||||
const { t } = useTranslation();
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
|
@ -57,15 +55,17 @@ export function InstanceSettingsForm({
|
|||
form.reset(data);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast({
|
||||
title: t("unexpectedError", {
|
||||
toast.error(
|
||||
t("unexpectedError", {
|
||||
defaultValue: "Unexpected Error",
|
||||
}),
|
||||
description: t("unexpectedErrorDescription", {
|
||||
defaultValue:
|
||||
"There was an unexpected error. Please try again later.",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("unexpectedErrorDescription", {
|
||||
defaultValue:
|
||||
"There was an unexpected error. Please try again later.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
})}
|
||||
>
|
||||
|
|
|
@ -2,8 +2,7 @@ import "../../style.css";
|
|||
|
||||
import { supportedLngs } from "@rallly/languages";
|
||||
import { PostHogProvider, posthog } from "@rallly/posthog/client";
|
||||
import { Toaster as SonnerToast } from "@rallly/ui/sonner";
|
||||
import { Toaster } from "@rallly/ui/toaster";
|
||||
import { Toaster } from "@rallly/ui/sonner";
|
||||
import { TooltipProvider } from "@rallly/ui/tooltip";
|
||||
import { LazyMotion, domAnimation } from "motion/react";
|
||||
import type { Viewport } from "next";
|
||||
|
@ -72,7 +71,6 @@ export default async function Root({
|
|||
<body>
|
||||
<FeatureFlagsProvider value={{ storage: isStorageEnabled }}>
|
||||
<Toaster />
|
||||
<SonnerToast />
|
||||
<I18nProvider locale={locale}>
|
||||
<TRPCProvider>
|
||||
<LazyMotion features={domAnimation}>
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import Cookies from "js-cookie";
|
||||
import { useParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function UnsubscribeAlert() {
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
const urlId = useParams<{ urlId: string }>()?.urlId;
|
||||
|
@ -18,17 +17,19 @@ export function UnsubscribeAlert() {
|
|||
const unsubscribed = Cookies.get(cookieName);
|
||||
if (unsubscribed) {
|
||||
Cookies.remove(cookieName);
|
||||
toast({
|
||||
title: t("unsubscribeToastTitle", {
|
||||
toast.message(
|
||||
t("unsubscribeToastTitle", {
|
||||
defaultValue: "You have disabled notifications",
|
||||
}),
|
||||
description: t("unsubscribeToastDescription", {
|
||||
defaultValue:
|
||||
"You will no longer receive notifications for this poll",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("unsubscribeToastDescription", {
|
||||
defaultValue:
|
||||
"You will no longer receive notifications for this poll",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [t, toast, urlId]);
|
||||
}, [t, urlId]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -9,7 +9,6 @@ import {
|
|||
CardTitle,
|
||||
} from "@rallly/ui/card";
|
||||
import { Form } from "@rallly/ui/form";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { useRouter } from "next/navigation";
|
||||
import type React from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
@ -21,6 +20,7 @@ import { Trans } from "@/components/trans";
|
|||
import { useUser } from "@/components/user-provider";
|
||||
import { trpc } from "@/trpc/client";
|
||||
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import type { NewEventData } from "./forms";
|
||||
import { PollDetailsForm, PollOptionsForm } from "./forms";
|
||||
|
||||
|
@ -42,7 +42,6 @@ export interface CreatePollPageProps {
|
|||
export const CreatePoll: React.FunctionComponent = () => {
|
||||
const router = useRouter();
|
||||
const { user, createGuestIfNeeded } = useUser();
|
||||
const { toast } = useToast();
|
||||
const form = useForm<NewEventData>({
|
||||
defaultValues: {
|
||||
title: "",
|
||||
|
@ -69,10 +68,7 @@ export const CreatePoll: React.FunctionComponent = () => {
|
|||
networkMode: "always",
|
||||
onError: (error) => {
|
||||
if (error.data?.code === "BAD_REQUEST") {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
});
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -16,7 +16,6 @@ import {
|
|||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@rallly/ui/dropdown-menu";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import { Icon } from "@rallly/ui/icon";
|
||||
import { Input } from "@rallly/ui/input";
|
||||
import { Textarea } from "@rallly/ui/textarea";
|
||||
|
@ -38,6 +37,7 @@ import { useRole } from "@/contexts/role";
|
|||
import { useTranslation } from "@/i18n/client";
|
||||
import { trpc } from "@/trpc/client";
|
||||
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import { requiredString } from "../../utils/form-validation";
|
||||
import TruncatedLinkify from "../poll/truncated-linkify";
|
||||
import { useUser } from "../user-provider";
|
||||
|
@ -79,17 +79,13 @@ function NewCommentForm({
|
|||
content: "",
|
||||
},
|
||||
});
|
||||
const { toast } = useToast();
|
||||
|
||||
const addComment = trpc.polls.comments.add.useMutation({
|
||||
onSuccess: () => {
|
||||
posthog?.capture("created comment");
|
||||
},
|
||||
onError: (error) => {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
});
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
return (
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import { usePostHog } from "@rallly/posthog/client";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
|
||||
import { usePoll } from "@/components/poll-context";
|
||||
import { trpc } from "@/trpc/client";
|
||||
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import type { ParticipantForm } from "./types";
|
||||
|
||||
export const normalizeVotes = (
|
||||
|
@ -81,7 +81,6 @@ export const useDeleteParticipantMutation = () => {
|
|||
|
||||
export const useUpdatePollMutation = () => {
|
||||
const posthog = usePostHog();
|
||||
const { toast } = useToast();
|
||||
return trpc.polls.update.useMutation({
|
||||
onSuccess: (_data, { urlId }) => {
|
||||
posthog?.capture("updated poll", {
|
||||
|
@ -90,10 +89,7 @@ export const useUpdatePollMutation = () => {
|
|||
},
|
||||
onError: (error) => {
|
||||
if (error.data?.code === "BAD_REQUEST") {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: error.message,
|
||||
});
|
||||
toast.error(error.message);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
"use client";
|
||||
import { usePostHog } from "@rallly/posthog/client";
|
||||
import { useToast } from "@rallly/ui/hooks/use-toast";
|
||||
import {
|
||||
MutationCache,
|
||||
QueryClient,
|
||||
|
@ -13,11 +12,11 @@ import superjson from "superjson";
|
|||
|
||||
import { useTranslation } from "@/i18n/client";
|
||||
|
||||
import { toast } from "@rallly/ui/sonner";
|
||||
import { trpc } from "../client";
|
||||
|
||||
export function TRPCProvider(props: { children: React.ReactNode }) {
|
||||
const posthog = usePostHog();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation();
|
||||
const [queryClient] = useState(
|
||||
() =>
|
||||
|
@ -41,14 +40,16 @@ export function TRPCProvider(props: { children: React.ReactNode }) {
|
|||
window.location.href = "/login";
|
||||
break;
|
||||
case "TOO_MANY_REQUESTS":
|
||||
toast({
|
||||
title: t("tooManyRequests", {
|
||||
toast.error(
|
||||
t("tooManyRequests", {
|
||||
defaultValue: "Too many requests",
|
||||
}),
|
||||
description: t("tooManyRequestsDescription", {
|
||||
defaultValue: "Please try again later.",
|
||||
}),
|
||||
});
|
||||
{
|
||||
description: t("tooManyRequestsDescription", {
|
||||
defaultValue: "Please try again later.",
|
||||
}),
|
||||
},
|
||||
);
|
||||
break;
|
||||
case "FORBIDDEN":
|
||||
signOut({
|
||||
|
|
|
@ -1,190 +0,0 @@
|
|||
// Inspired by react-hot-toast library
|
||||
import * as React from "react";
|
||||
|
||||
import type { ToastActionElement, ToastProps } from "../toast";
|
||||
|
||||
const TOAST_LIMIT = 1;
|
||||
const TOAST_REMOVE_DELAY = 1000000;
|
||||
|
||||
type ToasterToast = ToastProps & {
|
||||
id: string;
|
||||
title?: React.ReactNode;
|
||||
description?: React.ReactNode;
|
||||
action?: ToastActionElement;
|
||||
};
|
||||
|
||||
let count = 0;
|
||||
|
||||
function genId() {
|
||||
count = (count + 1) % Number.MAX_SAFE_INTEGER;
|
||||
return count.toString();
|
||||
}
|
||||
|
||||
type ActionType = {
|
||||
ADD_TOAST: "ADD_TOAST";
|
||||
UPDATE_TOAST: "UPDATE_TOAST";
|
||||
DISMISS_TOAST: "DISMISS_TOAST";
|
||||
REMOVE_TOAST: "REMOVE_TOAST";
|
||||
};
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: ActionType["ADD_TOAST"];
|
||||
toast: ToasterToast;
|
||||
}
|
||||
| {
|
||||
type: ActionType["UPDATE_TOAST"];
|
||||
toast: Partial<ToasterToast>;
|
||||
}
|
||||
| {
|
||||
type: ActionType["DISMISS_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
}
|
||||
| {
|
||||
type: ActionType["REMOVE_TOAST"];
|
||||
toastId?: ToasterToast["id"];
|
||||
};
|
||||
|
||||
interface State {
|
||||
toasts: ToasterToast[];
|
||||
}
|
||||
|
||||
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
|
||||
const addToRemoveQueue = (toastId: string) => {
|
||||
if (toastTimeouts.has(toastId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
toastTimeouts.delete(toastId);
|
||||
dispatch({
|
||||
type: "REMOVE_TOAST",
|
||||
toastId: toastId,
|
||||
});
|
||||
}, TOAST_REMOVE_DELAY);
|
||||
|
||||
toastTimeouts.set(toastId, timeout);
|
||||
};
|
||||
|
||||
export const reducer = (state: State, action: Action): State => {
|
||||
switch (action.type) {
|
||||
case "ADD_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
|
||||
};
|
||||
|
||||
case "UPDATE_TOAST":
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === action.toast.id ? { ...t, ...action.toast } : t,
|
||||
),
|
||||
};
|
||||
|
||||
case "DISMISS_TOAST": {
|
||||
const { toastId } = action;
|
||||
|
||||
// ! Side effects ! - This could be extracted into a dismissToast() action,
|
||||
// but I'll keep it here for simplicity
|
||||
if (toastId) {
|
||||
addToRemoveQueue(toastId);
|
||||
} else {
|
||||
// biome-ignore lint/complexity/noForEach: Fix this later
|
||||
state.toasts.forEach((toast) => {
|
||||
addToRemoveQueue(toast.id);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.map((t) =>
|
||||
t.id === toastId || toastId === undefined
|
||||
? {
|
||||
...t,
|
||||
open: false,
|
||||
}
|
||||
: t,
|
||||
),
|
||||
};
|
||||
}
|
||||
case "REMOVE_TOAST":
|
||||
if (action.toastId === undefined) {
|
||||
return {
|
||||
...state,
|
||||
toasts: [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
toasts: state.toasts.filter((t) => t.id !== action.toastId),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const listeners: Array<(state: State) => void> = [];
|
||||
|
||||
let memoryState: State = { toasts: [] };
|
||||
|
||||
function dispatch(action: Action) {
|
||||
memoryState = reducer(memoryState, action);
|
||||
// biome-ignore lint/complexity/noForEach: Fix this later
|
||||
listeners.forEach((listener) => {
|
||||
listener(memoryState);
|
||||
});
|
||||
}
|
||||
|
||||
type Toast = Omit<ToasterToast, "id">;
|
||||
|
||||
function toast({ ...props }: Toast) {
|
||||
const id = genId();
|
||||
|
||||
const update = (props: ToasterToast) =>
|
||||
dispatch({
|
||||
type: "UPDATE_TOAST",
|
||||
toast: { ...props, id },
|
||||
});
|
||||
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id });
|
||||
|
||||
dispatch({
|
||||
type: "ADD_TOAST",
|
||||
toast: {
|
||||
...props,
|
||||
id,
|
||||
open: true,
|
||||
onOpenChange: (open) => {
|
||||
if (!open) dismiss();
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
id: id,
|
||||
dismiss,
|
||||
update,
|
||||
};
|
||||
}
|
||||
|
||||
function useToast() {
|
||||
const [state, setState] = React.useState<State>(memoryState);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: I think this needs to be here
|
||||
React.useEffect(() => {
|
||||
listeners.push(setState);
|
||||
return () => {
|
||||
const index = listeners.indexOf(setState);
|
||||
if (index > -1) {
|
||||
listeners.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}, [state]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
toast,
|
||||
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
|
||||
};
|
||||
}
|
||||
|
||||
export { toast, useToast };
|
|
@ -1,30 +0,0 @@
|
|||
"use client";
|
||||
import { useToast } from "./hooks/use-toast";
|
||||
import {
|
||||
Toast,
|
||||
ToastClose,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastTitle,
|
||||
ToastViewport,
|
||||
} from "./toast";
|
||||
|
||||
export function Toaster() {
|
||||
const { toasts } = useToast();
|
||||
|
||||
return (
|
||||
<ToastProvider duration={2000}>
|
||||
{toasts.map(({ id, title, description, action, ...props }) => (
|
||||
<Toast key={id} {...props}>
|
||||
<div className="grid gap-1">
|
||||
{title && <ToastTitle>{title}</ToastTitle>}
|
||||
{description && <ToastDescription>{description}</ToastDescription>}
|
||||
</div>
|
||||
{action}
|
||||
<ToastClose />
|
||||
</Toast>
|
||||
))}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue