Преглед изворни кода

added signup and verify email forms

Milo Schwartz пре 9 месеци
родитељ
комит
f3eb76fd5e

+ 2 - 0
package.json

@@ -21,6 +21,7 @@
         "@radix-ui/react-icons": "1.3.0",
         "@radix-ui/react-label": "2.1.0",
         "@radix-ui/react-slot": "1.1.0",
+        "@radix-ui/react-toast": "1.2.2",
         "@react-email/components": "0.0.25",
         "@react-email/tailwind": "0.1.0",
         "axios": "1.7.7",
@@ -37,6 +38,7 @@
         "glob": "11.0.0",
         "helmet": "7.1.0",
         "http-errors": "2.0.0",
+        "input-otp": "1.2.4",
         "js-yaml": "4.1.0",
         "lucia": "3.2.0",
         "lucide-react": "0.447.0",

+ 4 - 4
server/auth/passwordSchema.ts

@@ -6,8 +6,8 @@ export const passwordSchema = z
     .max(64, { message: "Password must be at most 64 characters long" })
     .regex(/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-]).*$/, {
         message: `Your password must meet the following conditions:
-- At least one uppercase English letter.
-- At least one lowercase English letter.
-- At least one digit.
-- At least one special character.`,
+at least one uppercase English letter,
+at least one lowercase English letter,
+at least one digit,
+at least one special character.`
     });

+ 8 - 1
server/routers/auth/verifyEmail.ts

@@ -59,12 +59,19 @@ export async function verifyEmail(
                     emailVerified: true,
                 })
                 .where(eq(users.id, user.id));
+        } else {
+            return next(
+                createHttpError(
+                    HttpCode.BAD_REQUEST,
+                    "Invalid verification code",
+                ),
+            );
         }
 
         return response<VerifyEmailResponse>(res, {
             success: true,
             error: false,
-            message: valid ? "Code is valid" : "Code is invalid",
+            message: "Email verified",
             status: HttpCode.OK,
             data: {
                 valid,

+ 1 - 1
server/routers/external.ts

@@ -93,7 +93,7 @@ authenticated.delete(
 
 authenticated.get("/users", user.listUsers);
 // authenticated.get("/org/:orgId/users", user.???); // TODO: Implement this
-authenticated.get("/user", user.getUser);
+unauthenticated.get("/user", verifySessionMiddleware, user.getUser);
 // authenticated.get("/user/:userId", user.getUser);
 authenticated.delete("/user/:userId", user.deleteUser);
 

+ 17 - 0
src/app/auth/signup/page.tsx

@@ -0,0 +1,17 @@
+import SignupForm from "@app/components/SignupForm";
+import { verifySession } from "@app/lib/verifySession";
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+    const user = await verifySession();
+
+    if (user) {
+        redirect("/");
+    }
+
+    return (
+        <>
+            <SignupForm />
+        </>
+    );
+}

+ 22 - 0
src/app/auth/verify-email/page.tsx

@@ -0,0 +1,22 @@
+import VerifyEmailForm from "@app/components/VerifyEmailForm";
+import { verifySession } from "@app/lib/verifySession";
+import { redirect } from "next/navigation";
+
+export default async function Page() {
+    const user = await verifySession();
+    console.log(user)
+
+    if (!user) {
+        redirect("/");
+    }
+
+    if (user.emailVerified) {
+        redirect("/");
+    }
+
+    return (
+        <>
+            <VerifyEmailForm email={user.email}/>
+        </>
+    );
+}

+ 2 - 0
src/app/layout.tsx

@@ -1,6 +1,7 @@
 import type { Metadata } from "next";
 import "./globals.css";
 import { Roboto } from "next/font/google";
+import { Toaster } from "@/components/ui/toaster"
 
 export const metadata: Metadata = {
     title: process.env.NEXT_PUBLIC_APP_NAME,
@@ -18,6 +19,7 @@ export default async function RootLayout({
         <html>
             <body className={`${font.className}`}>
                 <main>{children}</main>
+                <Toaster />
             </body>
         </html>
     );

+ 12 - 5
src/components/LoginForm.tsx

@@ -22,10 +22,10 @@ import {
     CardTitle,
 } from "@/components/ui/card";
 import { Alert, AlertDescription } from "@/components/ui/alert";
-import { ExclamationTriangleIcon } from "@radix-ui/react-icons";
 import { LoginResponse } from "@server/routers/auth";
 import { api } from "@app/api";
 import { useRouter } from "next/navigation";
+import { AxiosResponse } from "axios";
 
 type LoginFormProps = {
     redirect?: string;
@@ -54,7 +54,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
     async function onSubmit(values: z.infer<typeof formSchema>) {
         const { email, password } = values;
         const res = await api
-            .post<LoginResponse>("/auth/login", {
+            .post<AxiosResponse<LoginResponse>>("/auth/login", {
                 email,
                 password,
             })
@@ -68,6 +68,14 @@ export default function LoginForm({ redirect }: LoginFormProps) {
 
         if (res && res.status === 200) {
             setError(null);
+
+            console.log(res)
+
+            if (res.data?.data?.emailVerificationRequired) {
+                router.push("/auth/verify-email");
+                return;
+            }
+
             if (redirect && typeof redirect === "string") {
                 window.location.href = redirect;
             } else {
@@ -79,7 +87,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
     return (
         <Card className="w-full max-w-md mx-auto">
             <CardHeader>
-                <CardTitle>Secure Login</CardTitle>
+                <CardTitle>Login</CardTitle>
                 <CardDescription>
                     Enter your credentials to access your dashboard
                 </CardDescription>
@@ -124,8 +132,7 @@ export default function LoginForm({ redirect }: LoginFormProps) {
                             )}
                         />
                         {error && (
-                            <Alert variant="destructive">
-                                <ExclamationTriangleIcon className="h-4 w-4" />
+                            <Alert>
                                 <AlertDescription>{error}</AlertDescription>
                             </Alert>
                         )}

+ 167 - 0
src/components/SignupForm.tsx

@@ -0,0 +1,167 @@
+"use client";
+
+import { useState } from "react";
+import { useForm } from "react-hook-form";
+import { zodResolver } from "@hookform/resolvers/zod";
+import * as z from "zod";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+    Form,
+    FormControl,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@/components/ui/form";
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle,
+} from "@/components/ui/card";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { SignUpResponse } from "@server/routers/auth";
+import { api } from "@app/api";
+import { useRouter } from "next/navigation";
+import { passwordSchema } from "@server/auth/passwordSchema";
+import { AxiosResponse } from "axios";
+
+type SignupFormProps = {
+    redirect?: string;
+};
+
+const formSchema = z
+    .object({
+        email: z.string().email({ message: "Invalid email address" }),
+        password: passwordSchema,
+        confirmPassword: passwordSchema,
+    })
+    .refine((data) => data.password === data.confirmPassword, {
+        path: ["confirmPassword"],
+        message: "Passwords do not match",
+    });
+
+export default function SignupForm({ redirect }: SignupFormProps) {
+    const router = useRouter();
+
+    const [error, setError] = useState<string | null>(null);
+
+    const form = useForm<z.infer<typeof formSchema>>({
+        resolver: zodResolver(formSchema),
+        defaultValues: {
+            email: "",
+            password: "",
+            confirmPassword: "",
+        },
+    });
+
+    async function onSubmit(values: z.infer<typeof formSchema>) {
+        const { email, password } = values;
+        const res = await api
+            .put<AxiosResponse<SignUpResponse>>("/auth/signup", {
+                email,
+                password,
+            })
+            .catch((e) => {
+                console.error(e);
+                setError(
+                    e.response?.data?.message ||
+                        "An error occurred while signing up",
+                );
+            });
+
+        if (res && res.status === 200) {
+            setError(null);
+
+            if (res.data.data.emailVerificationRequired) {
+                router.push("/auth/verify-email");
+                return;
+            }
+
+            if (redirect && typeof redirect === "string") {
+                window.location.href = redirect;
+            }
+
+            router.push("/");
+        }
+    }
+
+    return (
+        <Card className="w-full max-w-md mx-auto">
+            <CardHeader>
+                <CardTitle>Create Account</CardTitle>
+                <CardDescription>
+                    Enter your details to create an account
+                </CardDescription>
+            </CardHeader>
+            <CardContent>
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-4"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="email"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Email</FormLabel>
+                                    <FormControl>
+                                        <Input placeholder="Email" {...field} />
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <FormField
+                            control={form.control}
+                            name="password"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Password</FormLabel>
+                                    <FormControl>
+                                        <Input
+                                            type="password"
+                                            placeholder="Password"
+                                            {...field}
+                                        />
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+                        <FormField
+                            control={form.control}
+                            name="confirmPassword"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Confirm Password</FormLabel>
+                                    <FormControl>
+                                        <Input
+                                            type="password"
+                                            placeholder="Confirm Password"
+                                            {...field}
+                                        />
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+
+                        {error && (
+                            <Alert>
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+
+                        <Button type="submit" className="w-full">
+                            Create Account
+                        </Button>
+                    </form>
+                </Form>
+            </CardContent>
+        </Card>
+    );
+}

+ 218 - 0
src/components/VerifyEmailForm.tsx

@@ -0,0 +1,218 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import { z } from "zod";
+import { useState } from "react";
+import { Button } from "@/components/ui/button";
+import {
+    Card,
+    CardContent,
+    CardDescription,
+    CardHeader,
+    CardTitle,
+} from "@/components/ui/card";
+import {
+    Form,
+    FormControl,
+    FormDescription,
+    FormField,
+    FormItem,
+    FormLabel,
+    FormMessage,
+} from "@/components/ui/form";
+import { Input } from "@/components/ui/input";
+import {
+    InputOTP,
+    InputOTPGroup,
+    InputOTPSlot,
+} from "@/components/ui/input-otp";
+import api from "@app/api";
+import { AxiosResponse } from "axios";
+import { VerifyEmailResponse } from "@server/routers/auth";
+import { Loader2 } from "lucide-react";
+import { Alert, AlertDescription } from "./ui/alert";
+import { useToast } from "@app/hooks/use-toast";
+import { useRouter } from "next/navigation";
+
+const FormSchema = z.object({
+    email: z.string().email({ message: "Invalid email address" }),
+    pin: z.string().min(8, {
+        message: "Your verification code must be 8 characters.",
+    }),
+});
+
+export type VerifyEmailFormProps = {
+    email: string;
+};
+
+export default function VerifyEmailForm({ email }: VerifyEmailFormProps) {
+    const router = useRouter();
+
+    const [error, setError] = useState<string | null>(null);
+    const [successMessage, setSuccessMessage] = useState<string | null>(null);
+    const [isResending, setIsResending] = useState(false);
+    const [isSubmitting, setIsSubmitting] = useState(false);
+
+    const { toast } = useToast();
+
+    const form = useForm<z.infer<typeof FormSchema>>({
+        resolver: zodResolver(FormSchema),
+        defaultValues: {
+            email: email,
+            pin: "",
+        },
+    });
+
+    async function onSubmit(data: z.infer<typeof FormSchema>) {
+        setIsSubmitting(true);
+
+        const res = await api
+            .post<AxiosResponse<VerifyEmailResponse>>("/auth/verify-email", {
+                code: data.pin,
+            })
+            .catch((e) => {
+                setError(e.response?.data?.message || "An error occurred");
+                console.error("Failed to verify email:", e);
+            });
+
+        if (res && res.data?.data?.valid) {
+            setError(null);
+            setSuccessMessage(
+                "Email successfully verified! Redirecting you...",
+            );
+            setTimeout(() => {
+                router.push("/");
+                setIsSubmitting(false);
+            }, 3000);
+        }
+    }
+
+    async function handleResendCode() {
+        setIsResending(true);
+
+        const res = await api.post("/auth/verify-email/request").catch((e) => {
+            setError(e.response?.data?.message || "An error occurred");
+            console.error("Failed to resend verification code:", e);
+        });
+
+        if (res) {
+            setError(null);
+            toast({
+                variant: "default",
+                title: "Verification code resent",
+                description:
+                    "We've resent a verification code to your email address. Please check your inbox.",
+            });
+        }
+
+        setIsResending(false);
+    }
+
+    return (
+        <Card className="w-full max-w-md mx-auto">
+            <CardHeader>
+                <CardTitle>Verify Your Email</CardTitle>
+                <CardDescription>
+                    Enter the verification code sent to your email address.
+                </CardDescription>
+            </CardHeader>
+            <CardContent>
+                <Form {...form}>
+                    <form
+                        onSubmit={form.handleSubmit(onSubmit)}
+                        className="space-y-4"
+                    >
+                        <FormField
+                            control={form.control}
+                            name="email"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Email</FormLabel>
+                                    <FormControl>
+                                        <Input
+                                            placeholder="Email"
+                                            {...field}
+                                            disabled
+                                        />
+                                    </FormControl>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+
+                        <FormField
+                            control={form.control}
+                            name="pin"
+                            render={({ field }) => (
+                                <FormItem>
+                                    <FormLabel>Verification Code</FormLabel>
+                                    <FormControl>
+                                        <div className="flex justify-center">
+                                            <InputOTP maxLength={8} {...field}>
+                                                <InputOTPGroup className="flex">
+                                                    <InputOTPSlot index={0} />
+                                                    <InputOTPSlot index={1} />
+                                                    <InputOTPSlot index={2} />
+                                                    <InputOTPSlot index={3} />
+                                                    <InputOTPSlot index={4} />
+                                                    <InputOTPSlot index={5} />
+                                                    <InputOTPSlot index={6} />
+                                                    <InputOTPSlot index={7} />
+                                                </InputOTPGroup>
+                                            </InputOTP>
+                                        </div>
+                                    </FormControl>
+                                    <FormDescription>
+                                        We sent a verification code to your
+                                        email address. Please enter the code to
+                                        verify your email address.
+                                    </FormDescription>
+                                    <FormMessage />
+                                </FormItem>
+                            )}
+                        />
+
+                        {error && (
+                            <Alert>
+                                <AlertDescription>{error}</AlertDescription>
+                            </Alert>
+                        )}
+
+                        {successMessage && (
+                            <Alert>
+                                <AlertDescription>
+                                    {successMessage}
+                                </AlertDescription>
+                            </Alert>
+                        )}
+
+                        <Button
+                            type="submit"
+                            className="w-full"
+                            disabled={isSubmitting}
+                        >
+                            {isSubmitting && (
+                                <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+                            )}
+                            Submit
+                        </Button>
+
+                        <div className="flex justify-center">
+                            <Button
+                                type="button"
+                                variant="link"
+                                onClick={handleResendCode}
+                                disabled={isResending}
+                            >
+                                {isResending
+                                    ? "Resending..."
+                                    : "Didn't receive a code? Click here to resend"}
+                            </Button>
+                        </div>
+                    </form>
+                </Form>
+            </CardContent>
+        </Card>
+    );
+}

+ 71 - 0
src/components/ui/input-otp.tsx

@@ -0,0 +1,71 @@
+"use client"
+
+import * as React from "react"
+import { OTPInput, OTPInputContext } from "input-otp"
+import { Dot } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const InputOTP = React.forwardRef<
+  React.ElementRef<typeof OTPInput>,
+  React.ComponentPropsWithoutRef<typeof OTPInput>
+>(({ className, containerClassName, ...props }, ref) => (
+  <OTPInput
+    ref={ref}
+    containerClassName={cn(
+      "flex items-center gap-2 has-[:disabled]:opacity-50",
+      containerClassName
+    )}
+    className={cn("disabled:cursor-not-allowed", className)}
+    {...props}
+  />
+))
+InputOTP.displayName = "InputOTP"
+
+const InputOTPGroup = React.forwardRef<
+  React.ElementRef<"div">,
+  React.ComponentPropsWithoutRef<"div">
+>(({ className, ...props }, ref) => (
+  <div ref={ref} className={cn("flex items-center", className)} {...props} />
+))
+InputOTPGroup.displayName = "InputOTPGroup"
+
+const InputOTPSlot = React.forwardRef<
+  React.ElementRef<"div">,
+  React.ComponentPropsWithoutRef<"div"> & { index: number }
+>(({ index, className, ...props }, ref) => {
+  const inputOTPContext = React.useContext(OTPInputContext)
+  const { char, hasFakeCaret, isActive } = inputOTPContext.slots[index]
+
+  return (
+    <div
+      ref={ref}
+      className={cn(
+        "relative flex h-10 w-10 items-center justify-center border-y border-r border-input text-sm transition-all first:rounded-l-md first:border-l last:rounded-r-md",
+        isActive && "z-10 ring-2 ring-ring ring-offset-background",
+        className
+      )}
+      {...props}
+    >
+      {char}
+      {hasFakeCaret && (
+        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
+          <div className="h-4 w-px animate-caret-blink bg-foreground duration-1000" />
+        </div>
+      )}
+    </div>
+  )
+})
+InputOTPSlot.displayName = "InputOTPSlot"
+
+const InputOTPSeparator = React.forwardRef<
+  React.ElementRef<"div">,
+  React.ComponentPropsWithoutRef<"div">
+>(({ ...props }, ref) => (
+  <div ref={ref} role="separator" {...props}>
+    <Dot />
+  </div>
+))
+InputOTPSeparator.displayName = "InputOTPSeparator"
+
+export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }

+ 129 - 0
src/components/ui/toast.tsx

@@ -0,0 +1,129 @@
+"use client"
+
+import * as React from "react"
+import * as ToastPrimitives from "@radix-ui/react-toast"
+import { cva, type VariantProps } from "class-variance-authority"
+import { X } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+
+const ToastProvider = ToastPrimitives.Provider
+
+const ToastViewport = React.forwardRef<
+  React.ElementRef<typeof ToastPrimitives.Viewport>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
+>(({ className, ...props }, ref) => (
+  <ToastPrimitives.Viewport
+    ref={ref}
+    className={cn(
+      "fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
+      className
+    )}
+    {...props}
+  />
+))
+ToastViewport.displayName = ToastPrimitives.Viewport.displayName
+
+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<typeof ToastPrimitives.Root>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
+    VariantProps<typeof toastVariants>
+>(({ className, variant, ...props }, ref) => {
+  return (
+    <ToastPrimitives.Root
+      ref={ref}
+      className={cn(toastVariants({ variant }), className)}
+      {...props}
+    />
+  )
+})
+Toast.displayName = ToastPrimitives.Root.displayName
+
+const ToastAction = React.forwardRef<
+  React.ElementRef<typeof ToastPrimitives.Action>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
+>(({ className, ...props }, ref) => (
+  <ToastPrimitives.Action
+    ref={ref}
+    className={cn(
+      "inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
+      className
+    )}
+    {...props}
+  />
+))
+ToastAction.displayName = ToastPrimitives.Action.displayName
+
+const ToastClose = React.forwardRef<
+  React.ElementRef<typeof ToastPrimitives.Close>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
+>(({ className, ...props }, ref) => (
+  <ToastPrimitives.Close
+    ref={ref}
+    className={cn(
+      "absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
+      className
+    )}
+    toast-close=""
+    {...props}
+  >
+    <X className="h-4 w-4" />
+  </ToastPrimitives.Close>
+))
+ToastClose.displayName = ToastPrimitives.Close.displayName
+
+const ToastTitle = React.forwardRef<
+  React.ElementRef<typeof ToastPrimitives.Title>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
+>(({ className, ...props }, ref) => (
+  <ToastPrimitives.Title
+    ref={ref}
+    className={cn("text-sm font-semibold", className)}
+    {...props}
+  />
+))
+ToastTitle.displayName = ToastPrimitives.Title.displayName
+
+const ToastDescription = React.forwardRef<
+  React.ElementRef<typeof ToastPrimitives.Description>,
+  React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
+>(({ className, ...props }, ref) => (
+  <ToastPrimitives.Description
+    ref={ref}
+    className={cn("text-sm opacity-90", className)}
+    {...props}
+  />
+))
+ToastDescription.displayName = ToastPrimitives.Description.displayName
+
+type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
+
+type ToastActionElement = React.ReactElement<typeof ToastAction>
+
+export {
+  type ToastProps,
+  type ToastActionElement,
+  ToastProvider,
+  ToastViewport,
+  Toast,
+  ToastTitle,
+  ToastDescription,
+  ToastClose,
+  ToastAction,
+}

+ 35 - 0
src/components/ui/toaster.tsx

@@ -0,0 +1,35 @@
+"use client"
+
+import { useToast } from "@/hooks/use-toast"
+import {
+  Toast,
+  ToastClose,
+  ToastDescription,
+  ToastProvider,
+  ToastTitle,
+  ToastViewport,
+} from "@/components/ui/toast"
+
+export function Toaster() {
+  const { toasts } = useToast()
+
+  return (
+    <ToastProvider>
+      {toasts.map(function ({ id, title, description, action, ...props }) {
+        return (
+          <Toast key={id} {...props}>
+            <div className="grid gap-1">
+              {title && <ToastTitle>{title}</ToastTitle>}
+              {description && (
+                <ToastDescription>{description}</ToastDescription>
+              )}
+            </div>
+            {action}
+            <ToastClose />
+          </Toast>
+        )
+      })}
+      <ToastViewport />
+    </ToastProvider>
+  )
+}

+ 194 - 0
src/hooks/use-toast.ts

@@ -0,0 +1,194 @@
+"use client"
+
+// 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<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 {
+        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)
+  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)
+
+  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 }