diff --git a/app/(marketing)/features.tsx b/app/(marketing)/features.tsx index 13eff04..eed9b2a 100644 --- a/app/(marketing)/features.tsx +++ b/app/(marketing)/features.tsx @@ -3,14 +3,12 @@ import { CheckIcon } from "lucide-react" import Link from "next/link" import { useMemo } from "react" +import { toast } from "sonner" import { Button } from "@/components/ui/button" -import { useToast } from "@/components/ui/use-toast" import { cls } from "@/lib/utils" export function Features() { - const { toast } = useToast() - const FEATURES = useMemo( () => [ { title: "Next 14", href: "https://nextjs.org" }, @@ -41,14 +39,13 @@ export function Features() { { title: "Toasts", onClick: () => - toast({ - title: "Wait. Toasts, too?", + toast("Wait. Toasts, too?", { description: "Yep! You can use them anywhere in your app." }) }, { title: "and much more..." } ], - [toast] + [] ) return ( diff --git a/app/layout.tsx b/app/layout.tsx index 4b43f00..6241bca 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,7 +6,7 @@ import { type ReactNode } from "react" import { TailwindIndicator } from "@/components/debug/tailwind-indicator" import { Analytics } from "@/components/layout/analytics" -import { Toaster } from "@/components/ui/toaster" +import { Toaster } from "@/components/ui/sonner" import { siteConfig } from "@/config" import { cls, fullURL } from "@/lib/utils" diff --git a/bun.lockb b/bun.lockb index 0f241c8..e8afb67 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/components/auth/user-auth-form.tsx b/components/auth/user-auth-form.tsx index 1cc3648..f23f8a3 100644 --- a/components/auth/user-auth-form.tsx +++ b/components/auth/user-auth-form.tsx @@ -8,13 +8,13 @@ import { type SignInResponse } from "next-auth/react" import { useCallback, useEffect, useMemo, useState } from "react" import { type HTMLAttributes } from "react" import { useForm } from "react-hook-form" +import { toast } from "sonner" import { type z } from "zod" import { Spinner } from "@/components/spinner" import { Button } from "@/components/ui/button" import { Form, FormControl, FormField, FormItem } from "@/components/ui/form" import { Input } from "@/components/ui/input" -import { useToast } from "@/components/ui/use-toast" import { cls } from "@/lib/utils" import { userAuthSchema } from "@/lib/validations" @@ -25,30 +25,26 @@ type FormData = z.infer /** * https://github.com/nextauthjs/next-auth/blob/a79774f6e890b492ae30201f24b3f7024d0d7c9d/docs/docs/guides/basics/pages.md?plain=1#L42 */ -function parseErrorMessage(error?: string | null) { +function handleError(error?: string | null) { switch (error) { case "OAuthAccountNotLinked": - return { - title: "You already have an account", + return toast("You already have an account", { description: "Please sign in with the other service you used to sign up." - } + }) case "EmailSignin": - return { - title: "Unable to send login e-mail", + return toast("Unable to send login e-mail", { description: "Sending your login e-mail failed. Please try again." - } + }) case "CredentialsSignin": - return { - title: "Invalid username or password", + return toast("Invalid username or password", { description: "The username and password you entered did not match our records. Please double-check and try again." - } + }) case "SessionRequired": - return { - title: "Login required", + return toast("Login required", { description: "You must be logged in to view this page" - } + }) case "OAuthCallback": case "OAuthCreateAccount": case "OAuthSignin": @@ -56,10 +52,9 @@ function parseErrorMessage(error?: string | null) { case "Callback": case "Default": default: - return { - title: "Something went wrong.", + return toast("Something went wrong.", { description: "Your sign in request failed. Please try again." - } + }) } } @@ -67,7 +62,6 @@ type Props = HTMLAttributes export function UserAuthForm({ className, ...props }: Props) { const searchParams = useSearchParams() - const { toast } = useToast() const form = useForm>({ resolver: zodResolver(userAuthSchema), defaultValues: { @@ -88,12 +82,9 @@ export function UserAuthForm({ className, ...props }: Props) { */ useEffect(() => { if (searchParams.get("error")) { - toast({ - ...parseErrorMessage(searchParams.get("error")), - variant: "destructive" - }) + handleError(searchParams.get("error")) } - }, [searchParams, toast]) + }, [searchParams]) /** * Handle the form submission. @@ -118,18 +109,14 @@ export function UserAuthForm({ className, ...props }: Props) { } if (!signInResult?.ok || signInResult.error) { - return toast({ - ...parseErrorMessage(signInResult?.error), - variant: "destructive" - }) + return handleError(signInResult?.error) } - return toast({ - title: "Check your email", + return toast("Check your email", { description: "We sent you a login link. Be sure to check your spam too." }) }, - [isLoading, searchParams, toast] + [isLoading, searchParams] ) return ( diff --git a/components/ui/sonner.tsx b/components/ui/sonner.tsx new file mode 100644 index 0000000..fcd7e59 --- /dev/null +++ b/components/ui/sonner.tsx @@ -0,0 +1,32 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +function Toaster({ ...props }: ToasterProps) { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx deleted file mode 100644 index ee57d0d..0000000 --- a/components/ui/toast.tsx +++ /dev/null @@ -1,128 +0,0 @@ -import * as ToastPrimitives from "@radix-ui/react-toast" -import { type VariantProps, cva } from "class-variance-authority" -import { X } from "lucide-react" -import * as React from "react" - -import { cn } from "@/lib/utils" - -const ToastProvider = ToastPrimitives.Provider - -const ToastViewport = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastViewport.displayName = ToastPrimitives.Viewport.displayName - -// eslint-disable-next-line tailwindcss/no-custom-classname -- allow 'destructive' -const toastVariants = cva( - "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", - { - variants: { - variant: { - default: "border bg-background text-foreground", - destructive: - "destructive group border-destructive bg-destructive text-destructive-foreground" - } - }, - defaultVariants: { - variant: "default" - } - } -) - -const Toast = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef & - VariantProps ->(({ className, variant, ...props }, ref) => { - return ( - - ) -}) -Toast.displayName = ToastPrimitives.Root.displayName - -const ToastAction = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastAction.displayName = ToastPrimitives.Action.displayName - -const ToastClose = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - - - -)) -ToastClose.displayName = ToastPrimitives.Close.displayName - -const ToastTitle = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastTitle.displayName = ToastPrimitives.Title.displayName - -const ToastDescription = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => ( - -)) -ToastDescription.displayName = ToastPrimitives.Description.displayName - -type ToastProps = React.ComponentPropsWithoutRef - -type ToastActionElement = React.ReactElement - -export { - type ToastProps, - type ToastActionElement, - ToastProvider, - ToastViewport, - Toast, - ToastTitle, - ToastDescription, - ToastClose, - ToastAction -} diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx deleted file mode 100644 index 9f58683..0000000 --- a/components/ui/toaster.tsx +++ /dev/null @@ -1,35 +0,0 @@ -"use client" - -import { - Toast, - ToastClose, - ToastDescription, - ToastProvider, - ToastTitle, - ToastViewport -} from "@/components/ui/toast" -import { useToast } from "@/components/ui/use-toast" - -export function Toaster() { - const { toasts } = useToast() - - return ( - - {toasts.map(({ id, title, description, action, ...props }) => { - return ( - -
- {title ? {title} : null} - {description ? ( - {description} - ) : null} -
- {action} - -
- ) - })} - -
- ) -} diff --git a/components/ui/use-toast.ts b/components/ui/use-toast.ts deleted file mode 100644 index f995b9a..0000000 --- a/components/ui/use-toast.ts +++ /dev/null @@ -1,194 +0,0 @@ -// Inspired by react-hot-toast library -import * as React from "react" - -import type { ToastActionElement, ToastProps } from "@/components/ui/toast" - -const TOAST_LIMIT = 1 -const TOAST_REMOVE_DELAY = 1000000 - -type ToasterToast = ToastProps & { - id: string - title?: React.ReactNode - description?: React.ReactNode - action?: ToastActionElement -} - -const actionTypes = { - ADD_TOAST: "ADD_TOAST", - UPDATE_TOAST: "UPDATE_TOAST", - DISMISS_TOAST: "DISMISS_TOAST", - REMOVE_TOAST: "REMOVE_TOAST" -} as const - -let count = 0 - -function genId() { - count = (count + 1) % Number.MAX_SAFE_INTEGER - return count.toString() -} - -type ActionType = typeof actionTypes - -type Action = - | { - type: ActionType["ADD_TOAST"] - toast: ToasterToast - } - | { - type: ActionType["UPDATE_TOAST"] - toast: Partial - } - | { - type: ActionType["DISMISS_TOAST"] - toastId?: ToasterToast["id"] - } - | { - type: ActionType["REMOVE_TOAST"] - toastId?: ToasterToast["id"] - } - -type State = { - toasts: ToasterToast[] -} - -const toastTimeouts = new Map>() - -const addToRemoveQueue = (toastId: string) => { - if (toastTimeouts.has(toastId)) { - return - } - - const timeout = setTimeout(() => { - toastTimeouts.delete(toastId) - dispatch({ - type: "REMOVE_TOAST", - 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 { - state.toasts.forEach((t) => { - addToRemoveQueue(t.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: ((state: State) => void)[] = [] - -let memoryState: State = { toasts: [] } - -function dispatch(action: Action) { - memoryState = reducer(memoryState, action) - listeners.forEach((listener) => { - listener(memoryState) - }) -} - -type Toast = Omit - -function toast({ ...props }: Toast) { - const id = genId() - - const update = (updateProps: ToasterToast) => { - dispatch({ - type: "UPDATE_TOAST", - toast: { ...updateProps, 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, - dismiss, - update - } -} - -function useToast() { - const [state, setState] = React.useState(memoryState) - - 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 { useToast, toast } diff --git a/package.json b/package.json index 83b8443..8318da1 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "react-dom": "^18.2.0", "react-hook-form": "^7.49.2", "resend": "^2.1.0", + "sonner": "^1.3.1", "tailwind-merge": "^2.2.0", "zod": "^3.22.4" },