diff --git a/bun.lockb b/bun.lockb index f5945cd..fcc706e 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index b338346..421bd67 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@radix-ui/react-label": "^2.0.2", "@radix-ui/react-separator": "^1.0.3", "@radix-ui/react-slot": "^1.0.2", + "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@react-three/drei": "^9.93.0", "@react-three/fiber": "^8.15.14", @@ -23,6 +24,7 @@ "@splinetool/runtime": "^1.0.26", "@tweenjs/tween.js": "^21.0.0", "class-variance-authority": "^0.7.0", + "classnames": "^2.5.1", "clsx": "^2.1.0", "framer-motion": "^10.18.0", "jsonwebtoken": "^9.0.2", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6e820f8..aaf7abf 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -6,6 +6,7 @@ import React from "react"; import { cn } from "@/lib/utils"; import TokenRefresh from "@/components/auth/TokenRefresh"; +import { Toaster } from "@/components/ui/toaster"; const fontSans = FontSans({ subsets: ["latin"], @@ -22,6 +23,7 @@ export default function RootLayout({ children }: { children: React.ReactNode }) {/* */} {children} + ); diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9b5ae89..52d8eb0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,6 +1,4 @@ "use client"; -import { login } from "@/services/authService"; -import { useDispatch } from "react-redux"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import { Input } from "@/components/ui/input"; @@ -9,44 +7,44 @@ import { Button } from "@/components/ui/button"; import { FaGithub, FaGoogle } from "react-icons/fa6"; import { IoLogInOutline } from "react-icons/io5"; import Image from "next/image"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; -import { useState } from "react"; -import { FetchEventResult } from "next/dist/server/web/types"; +import { useFormStatus, useFormState } from "react-dom"; +import { loginUser } from "@/lib/actions"; +import cx from "classnames"; +import { useEffect } from "react"; +import { useToast } from "@/components/ui/use-toast"; -const formSchema = z.object({ - email: z.string().email({ message: "Invalid email" }).min(5), - password: z.string().min(8, { message: "Password must be at least 8 characters long" }) -}); +const SubmitButton = () => { + const { pending } = useFormStatus(); -const LoginForm = () => { - const dispatch = useDispatch(); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + return ( + + ); +}; - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { +const LoginForm = () => { + const { toast } = useToast(); + const [formState, formAction] = useFormState(loginUser, { + message: "", + errors: undefined, + fieldValues: { email: "", password: "" } }); - async function onSubmit(values: z.infer) { - setLoading(true); - try { - const result = (await login(dispatch, values)) as PromiseFulfilledResult; - setError(result?.status === undefined ? "Invalid email or password" : ""); - setLoading(false); - } catch (error) { - console.error(error); - setError("Invalid email or password"); - setLoading(false); - throw error; + useEffect(() => { + if (formState.message === "success") { + toast({ + title: "Logged In!", + description: "Welcome back! You will be redirected any moment", + variant: "default", + className: "border-emerald-300" + }); } - } + }, [formState, toast]); return (
@@ -83,65 +81,53 @@ const LoginForm = () => {
-
- - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - {error &&

{error}

} -
-
- -
- -
+ + + + + + {formState?.message === "error" ? ( +
+

{formState?.errors?.email}

+

{formState?.errors?.password}

+
+ ) : ( + "" + )} +
+
+ +
+
- - Forgot password? -
- - - + + Forgot password? + +
+ +
diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 537c919..45dce82 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,6 +1,4 @@ "use client"; -import { signup } from "@/services/authService"; -import { useDispatch } from "react-redux"; import Link from "next/link"; import { Separator } from "@/components/ui/separator"; import { Input } from "@/components/ui/input"; @@ -9,47 +7,36 @@ import { Button } from "@/components/ui/button"; import { FaGithub, FaGoogle } from "react-icons/fa6"; import { IoLogInOutline } from "react-icons/io5"; import Image from "next/image"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { useForm } from "react-hook-form"; -import * as z from "zod"; -import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { useState } from "react"; -import { FetchEventResult } from "next/dist/server/web/types"; -import { RegisterInputs } from "@/types"; +import { useFormState, useFormStatus } from "react-dom"; +import { registerUser } from "@/lib/actions"; -const formSchema = z.object({ - name: z.string().min(3, { message: "Name must be at least 3 characters long" }), - email: z.string().email({ message: "Invalid email" }).min(5), - password: z.string().min(8, { message: "Password must be at least 8 characters long" }) -}); +const SubmitButton = () => { + const { pending } = useFormStatus(); + + return ( + + ); +}; const SignupForm = () => { const [loading, setLoading] = useState(false); const [error, setError] = useState(""); - const form = useForm>({ - resolver: zodResolver(formSchema), - defaultValues: { + const [formState, formAction] = useFormState(registerUser, { + message: "", + errors: undefined, + fieldValues: { name: "", email: "", - password: "" + password: "", + confirmPassword: "" } }); - async function onSubmit(values: z.infer) { - setLoading(true); - try { - const result = (await signup(values as RegisterInputs)) as PromiseFulfilledResult; - setError(result?.status === undefined ? "Invalid email or password" : ""); - setLoading(false); - } catch (error) { - console.error(error); - setError("Invalid email or password"); - setLoading(false); - throw error; - } - } - return (
@@ -85,105 +72,56 @@ const SignupForm = () => {
-
- - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - ( - - - - - - - )} - /> - {error &&

{error}

} -
-
- -
- -
+ + + + + + + {error &&

{error}

} +
+
+ +
+
- - - +
+ + +
diff --git a/src/components/ui/toast.tsx b/src/components/ui/toast.tsx new file mode 100644 index 0000000..28a233b --- /dev/null +++ b/src/components/ui/toast.tsx @@ -0,0 +1,110 @@ +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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +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, + 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/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..9f42138 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,26 @@ +"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(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && {description}} +
+ {action} + +
+ ); + })} + +
+ ); +} diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts new file mode 100644 index 0000000..c62a8eb --- /dev/null +++ b/src/components/ui/use-toast.ts @@ -0,0 +1,187 @@ +// 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"]; + }; + +interface 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: 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; + +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(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/src/lib/actions.ts b/src/lib/actions.ts index ca896de..fed8fde 100644 --- a/src/lib/actions.ts +++ b/src/lib/actions.ts @@ -1,6 +1,7 @@ "use server"; import { cookies } from "next/headers"; +import { ZodError, z } from "zod"; interface StoreTokenRequest { token: string; @@ -34,59 +35,207 @@ export async function deleteToken() { } export async function refreshAccessToken() { - console.log("refreshing access token"); - if (!cookies().get("accessToken")) { - return { status: 401, message: "No access token" }; - } - await fetch("http://localhost:8080/api/test/refresh", { - method: "POST", - headers: { - "Content-Type": "application/json" - /* Authorization: cookies().get("accessToken")?.value */ + try { + if (!cookies().get("accessToken")) { + return { status: 401, message: "No access token" }; } - /* body: JSON.stringify({ refreshToken: cookies().get("refreshToken") }) */ - }) - .then((response) => { - if (!response.ok) { - throw new Error("Network error"); + await fetch("http://localhost:8080/api/test/refresh", { + method: "POST", + headers: { + "Content-Type": "application/json" + /* Authorization: cookies().get("accessToken")?.value */ } - return response.json(); + /* body: JSON.stringify({ refreshToken: cookies().get("refreshToken") }) */ }) - .then((data) => { - if (data.accessToken) { - storeToken({ token: data.accessToken, refresh_token: data.refreshToken }); - return data; + .then((response) => { + if (!response.ok) { + throw new Error("Network error"); + } + return response.json(); + }) + .then((data) => { + if (data.accessToken) { + storeToken({ token: data.accessToken, refresh_token: data.refreshToken }); + return data; + } + }); + } catch (error) { + console.error("There was a problem with the Fetch operation: ", error); + } +} + +export async function getUser() { + try { + if (!cookies().get("accessToken")) { + return { status: 401, message: "No access token" }; + } + await fetch("http://localhost:8080/api/test/user", { + method: "GET", + headers: { + "Content-Type": "application/json" } }) - .catch((error) => { - console.error("There was a problem with the Fetch operation:", error); - }); + .then((response) => { + if (!response.ok) { + throw new Error("Network error"); + } + return response.json(); + }) + .then((data) => { + return data; + }); + } catch (error) { + console.error("There was a problem with the Fetch operation: ", error); + } } -export async function getUser() { - if (!cookies().get("accessToken")) { - return { status: 401, message: "No access token" }; +export async function getAccessToken() { + return cookies().get("accessToken")?.value; +} + +type LoginFormState = { + message: string; + errors: Record | undefined; + fieldValues: { email: string; password: string }; +}; + +export async function loginUser(prevState: LoginFormState, formData: FormData): Promise { + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const schema = z.object({ + email: z.string().email({ message: "Please enter your email in format: yourname@example.com" }).min(5), + password: z.string().min(8, { message: "Your Password must be at least 8 characters long" }) + }); + const parse = schema.safeParse({ + email: formData.get("email"), + password: formData.get("password") + }); + + if (!parse.success) { + return { + message: "error", + errors: { + email: parse.error.flatten().fieldErrors["email"]?.[0] ?? "", + password: parse.error.flatten().fieldErrors["password"]?.[0] ?? "" + }, + fieldValues: { email, password } + }; } - await fetch("http://localhost:8080/api/test/user", { - method: "GET", - headers: { - "Content-Type": "application/json" - } - }) - .then((response) => { + const data = parse.data; + + await new Promise((resolve) => setTimeout(resolve, 1000)); + try { + return { + message: "success", + errors: undefined, + fieldValues: { + email: "", + password: "" + } + }; + } catch (error) { + const zodError = error as ZodError; + const errorMap = zodError.flatten().fieldErrors; + return { + message: "error", + errors: { email: errorMap["email"]?.[0] ?? "", password: errorMap["password"]?.[0] ?? "" }, + fieldValues: { email, password } + }; + } + /* try { + await fetch("http://localhost:8080/api/auth/signin", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + email: formData.get("email"), + password: formData.get("password") + }) + }).then((response) => { if (!response.ok) { - throw new Error("Network error"); + return { status: response.status, message: response.statusText }; } return response.json(); - }) - .then((data) => { - return data; - }) - .catch((error) => { - console.error("There was a problem with the Fetch operation:", error); }); + } catch (error) { + const zodError = error as ZodError; + const errorMap = zodError.flatten().fieldErrors; + return { + message: "error", + errors: { email: errorMap["email"]?.[0] ?? "", password: errorMap["password"]?.[0] ?? "" }, + fieldValues: { email, password } + }; + } */ } -export async function getAccessToken() { - return cookies().get("accessToken")?.value; +export type SignupFormState = { + message: string; + errors: Record | undefined; + fieldValues: { name: string; email: string; password: string; confirmPassword: string }; +}; + +export async function registerUser(prevState: SignupFormState, formData: FormData): Promise { + const name = formData.get("name") as string; + const email = formData.get("email") as string; + const password = formData.get("password") as string; + const confirmPassword = formData.get("confirmPassword") as string; + const schema = z + .object({ + name: z.string().min(3), + email: z.string().email(), + password: z.string().min(8), + confirmPassword: z.string().min(8) + }) + .refine((data) => data.password === data.confirmPassword, { + message: "Passwords don't match", + path: ["confirmPassword"] + }); + const parse = schema.safeParse({ + name: formData.get("name"), + email: formData.get("email"), + password: formData.get("password"), + confirmPassword: formData.get("confirmPassword") + }); + + if (!parse.success) { + return { + message: "error", + errors: { + name: parse.error.flatten().fieldErrors["name"]?.[0] ?? "", + email: parse.error.flatten().fieldErrors["email"]?.[0] ?? "", + password: parse.error.flatten().fieldErrors["password"]?.[0] ?? "", + confirmPassword: parse.error.flatten().fieldErrors["confirmPassword"]?.[0] ?? "" + }, + fieldValues: { name, email, password, confirmPassword } + }; + } + const data = parse.data; + console.log(data); + + try { + return { + message: "Success", + errors: undefined, + fieldValues: { + name: "", + email: "", + password: "", + confirmPassword: "" + } + }; + } catch (error) { + const zodError = error as ZodError; + const errorMap = zodError.flatten().fieldErrors; + return { + message: "error", + errors: { + name: errorMap["name"]?.[0] ?? "", + email: errorMap["email"]?.[0] ?? "", + password: errorMap["password"]?.[0] ?? "", + confirmPassword: errorMap["confirmPassword"]?.[0] ?? "" + }, + fieldValues: { name, email, password, confirmPassword } + }; + } }