diff --git a/components/AuthComponent/SigninForm.tsx b/components/AuthComponent/SigninForm.tsx index edac9c4..c62c9bd 100644 --- a/components/AuthComponent/SigninForm.tsx +++ b/components/AuthComponent/SigninForm.tsx @@ -1,10 +1,20 @@ -"use client"; +"use client" -import React, { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Alert, AlertDescription } from "@/components/ui/alert"; +import React from "react" +import { useRouter } from "next/navigation" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import * as z from "zod" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Alert, AlertDescription } from "@/components/ui/alert" import { Card, CardContent, @@ -12,33 +22,29 @@ import { CardFooter, CardHeader, CardTitle, -} from "@/components/ui/card"; -import { useAuthStore } from "@/store/AuthStore/useAuthStore"; -import LoadingButton from "../Buttons/LoadingButton"; -import AuthBottom from "./AuthBottom"; +} from "@/components/ui/card" +import { useAuthStore } from "@/store/AuthStore/useAuthStore" +import LoadingButton from "../Buttons/LoadingButton" +import AuthBottom from "./AuthBottom" +import { signinSchema } from "@/validations/validation" -interface FormData { - email: string; - password: string; -} +type SigninFormValues = z.infer<typeof signinSchema> export default function SigninForm() { - const { isSigningIn, signin, signinError } = useAuthStore(); - const router = useRouter(); - const [formData, setFormData] = useState<FormData>({ - email: "", - password: "", - }); + const { isSigningIn, signin, signinError } = useAuthStore() + const router = useRouter() - const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { - const { name, value } = e.target; - setFormData((prev) => ({ ...prev, [name]: value })); - }; + const form = useForm<SigninFormValues>({ + resolver: zodResolver(signinSchema), + defaultValues: { + email: "", + password: "", + }, + }) - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - signin(formData, router); - }; + const onSubmit = (data: SigninFormValues) => { + signin(data, router) + } return ( <main className="flex min-h-screen items-center justify-center px-4"> @@ -50,46 +56,47 @@ export default function SigninForm() { </CardDescription> </CardHeader> <CardContent> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} name="email" - type="email" - placeholder="you@example.com" - value={formData.email} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="you@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" + <FormField + control={form.control} name="password" - type="password" - placeholder="••••••••" - value={formData.password} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="••••••••" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - {signinError && ( - <Alert variant="destructive"> - <AlertDescription>{signinError}</AlertDescription> - </Alert> - )} - - <LoadingButton - loading={isSigningIn} - loadingTitle="Signing in" - title="Signin" - type="submit" - /> - </form> + {signinError && ( + <Alert variant="destructive"> + <AlertDescription>{signinError}</AlertDescription> + </Alert> + )} + <LoadingButton + loading={isSigningIn} + loadingTitle="Signing in" + title="Sign in" + type="submit" + /> + </form> + </Form> </CardContent> <CardFooter className="flex justify-center"> <AuthBottom @@ -100,5 +107,5 @@ export default function SigninForm() { </CardFooter> </Card> </main> - ); -} + ) +} \ No newline at end of file diff --git a/components/AuthComponent/SignupForm.tsx b/components/AuthComponent/SignupForm.tsx index e3e8963..8b93fe1 100644 --- a/components/AuthComponent/SignupForm.tsx +++ b/components/AuthComponent/SignupForm.tsx @@ -1,17 +1,23 @@ "use client"; -import React, { useState } from "react"; +import React from "react"; import { useRouter } from "next/navigation"; -import Link from "next/link"; -import { Button } from "@/components/ui/button"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import * as z from "zod"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; import { Select, SelectContent, - SelectGroup, SelectItem, - SelectLabel, SelectTrigger, SelectValue, } from "@/components/ui/select"; @@ -24,33 +30,29 @@ import { CardTitle, } from "@/components/ui/card"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { UserType } from "@/types/typeInterfaces"; import { useAuthStore } from "@/store/AuthStore/useAuthStore"; import AuthBottom from "./AuthBottom"; import LoadingButton from "../Buttons/LoadingButton"; +import { signupSchema } from "@/validations/validation"; +type SignupFormValues = z.infer<typeof signupSchema>; export default function SignupForm() { const { isSigningUp, signup, signupError } = useAuthStore(); const router = useRouter(); - const [formData, setFormData] = useState<UserType>({ - fullName: "", - email: "", - password: "", - leetcodeUsername: "", - gender: "", - }); - - const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => { - setFormData({ ...formData, [e.target.name]: e.target.value }); - }; - const handleSelectChange = (value: string) => { - setFormData({ ...formData, gender: value }); - }; + const form = useForm<SignupFormValues>({ + resolver: zodResolver(signupSchema), + defaultValues: { + fullName: "", + email: "", + password: "", + leetcodeUsername: "", + gender: "", + }, + }); - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - signup(formData, router); + const onSubmit = (data: SignupFormValues) => { + signup(data, router); }; return ( @@ -65,93 +67,98 @@ export default function SignupForm() { </CardDescription> </CardHeader> <CardContent> - <form onSubmit={handleSubmit} className="space-y-4"> - <div className="space-y-2"> - <Label htmlFor="fullName">Full Name</Label> - <Input - id="fullName" + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} name="fullName" - type="text" - placeholder="Your Name" - value={formData.fullName} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>Full Name</FormLabel> + <FormControl> + <Input placeholder="Your Name" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - <div className="space-y-2"> - <Label htmlFor="email">Email</Label> - <Input - id="email" + <FormField + control={form.control} name="email" - type="email" - placeholder="you@example.com" - value={formData.email} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>Email</FormLabel> + <FormControl> + <Input placeholder="you@example.com" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - <div className="space-y-2"> - <Label htmlFor="password">Password</Label> - <Input - id="password" + <FormField + control={form.control} name="password" - type="password" - minLength={6} - placeholder="••••••" - value={formData.password} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>Password</FormLabel> + <FormControl> + <Input type="password" placeholder="••••••" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - <div className="space-y-2"> - <Label htmlFor="leetcodeUsername">LeetCode Username</Label> - <Input - id="leetcodeUsername" + <FormField + control={form.control} name="leetcodeUsername" - type="text" - placeholder="Your LeetCode Username" - value={formData.leetcodeUsername} - onChange={handleChange} - required + render={({ field }) => ( + <FormItem> + <FormLabel>LeetCode Username</FormLabel> + <FormControl> + <Input placeholder="Your LeetCode Username" {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} /> - </div> - - <div className="space-y-2"> - <Label htmlFor="gender">Gender</Label> - <Select - value={formData.gender} - onValueChange={handleSelectChange} - > - <SelectTrigger className="w-full"> - <SelectValue placeholder="Select Gender" /> - </SelectTrigger> - <SelectContent> - <SelectGroup> - <SelectLabel>Options</SelectLabel> - <SelectItem value="male">Male</SelectItem> - <SelectItem value="female">Female</SelectItem> - <SelectItem value="other">Other</SelectItem> - </SelectGroup> - </SelectContent> - </Select> - </div> - - {signupError && ( - <Alert variant="destructive"> - <AlertDescription>{signupError}</AlertDescription> - </Alert> - )} - - <LoadingButton - loading={isSigningUp} - loadingTitle="Registering" - title="Register" - type="submit" - /> - </form> + <FormField + control={form.control} + name="gender" + render={({ field }) => ( + <FormItem> + <FormLabel>Gender</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="Select Gender" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="male">Male</SelectItem> + <SelectItem value="female">Female</SelectItem> + <SelectItem value="other">Other</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + {signupError && ( + <Alert variant="destructive"> + <AlertDescription>{signupError}</AlertDescription> + </Alert> + )} + <LoadingButton + loading={isSigningUp} + loadingTitle="Registering" + title="Register" + type="submit" + /> + </form> + </Form> </CardContent> <CardFooter className="flex justify-center"> <AuthBottom diff --git a/components/ui/form.tsx b/components/ui/form.tsx new file mode 100644 index 0000000..ce264ae --- /dev/null +++ b/components/ui/form.tsx @@ -0,0 +1,178 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + ControllerProps, + FieldPath, + FieldValues, + FormProvider, + useFormContext, +} from "react-hook-form" + +import { cn } from "@/lib/utils" +import { Label } from "@/components/ui/label" + +const Form = FormProvider + +type FormFieldContextValue< + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +> = { + name: TName +} + +const FormFieldContext = React.createContext<FormFieldContextValue>( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues> +>({ + ...props +}: ControllerProps<TFieldValues, TName>) => { + return ( + <FormFieldContext.Provider value={{ name: props.name }}> + <Controller {...props} /> + </FormFieldContext.Provider> + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState, formState } = useFormContext() + + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within <FormField>") + } + + const { id } = itemContext + + return { + id, + name: fieldContext.name, + formItemId: `${id}-form-item`, + formDescriptionId: `${id}-form-item-description`, + formMessageId: `${id}-form-item-message`, + ...fieldState, + } +} + +type FormItemContextValue = { + id: string +} + +const FormItemContext = React.createContext<FormItemContextValue>( + {} as FormItemContextValue +) + +const FormItem = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes<HTMLDivElement> +>(({ className, ...props }, ref) => { + const id = React.useId() + + return ( + <FormItemContext.Provider value={{ id }}> + <div ref={ref} className={cn("space-y-2", className)} {...props} /> + </FormItemContext.Provider> + ) +}) +FormItem.displayName = "FormItem" + +const FormLabel = React.forwardRef< + React.ElementRef<typeof LabelPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> +>(({ className, ...props }, ref) => { + const { error, formItemId } = useFormField() + + return ( + <Label + ref={ref} + className={cn(error && "text-destructive", className)} + htmlFor={formItemId} + {...props} + /> + ) +}) +FormLabel.displayName = "FormLabel" + +const FormControl = React.forwardRef< + React.ElementRef<typeof Slot>, + React.ComponentPropsWithoutRef<typeof Slot> +>(({ ...props }, ref) => { + const { error, formItemId, formDescriptionId, formMessageId } = useFormField() + + return ( + <Slot + ref={ref} + id={formItemId} + aria-describedby={ + !error + ? `${formDescriptionId}` + : `${formDescriptionId} ${formMessageId}` + } + aria-invalid={!!error} + {...props} + /> + ) +}) +FormControl.displayName = "FormControl" + +const FormDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, ...props }, ref) => { + const { formDescriptionId } = useFormField() + + return ( + <p + ref={ref} + id={formDescriptionId} + className={cn("text-sm text-muted-foreground", className)} + {...props} + /> + ) +}) +FormDescription.displayName = "FormDescription" + +const FormMessage = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes<HTMLParagraphElement> +>(({ className, children, ...props }, ref) => { + const { error, formMessageId } = useFormField() + const body = error ? String(error?.message) : children + + if (!body) { + return null + } + + return ( + <p + ref={ref} + id={formMessageId} + className={cn("text-sm font-medium text-destructive", className)} + {...props} + > + {body} + </p> + ) +}) +FormMessage.displayName = "FormMessage" + +export { + useFormField, + Form, + FormItem, + FormLabel, + FormControl, + FormDescription, + FormMessage, + FormField, +} diff --git a/package-lock.json b/package-lock.json index 47dd760..a66db1d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,9 @@ "": { "name": "leetcode-journal", "version": "0.1.0", + "hasInstallScript": true, "dependencies": { + "@hookform/resolvers": "^3.10.0", "@prisma/client": "^6.2.1", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-dialog": "^1.1.4", @@ -28,6 +30,7 @@ "next": "15.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1", @@ -236,6 +239,14 @@ "integrity": "sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==", "license": "MIT" }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1311,7 +1322,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.1.tgz", "integrity": "sha512-UUw5E4e/2+4kFMH7+YxORXGWggtY6sM8WIwh5RZchhLuUg2H1hc98Py+pr8HMz6rdaYrK2t296ZEjYLOCO5uUw==", - "license": "MIT", "dependencies": { "@radix-ui/react-primitive": "2.0.1" }, @@ -1574,7 +1584,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.1.tgz", "integrity": "sha512-RApLLOcINYJA+dMVbOju7MYv1Mb2EBp2nH4HdDzXTSyaR5optlm6Otrz1euW3HbdOR8UmmFK06TD+A9frYWv+g==", - "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.1" }, @@ -5766,6 +5775,21 @@ "react": "^19.0.0" } }, + "node_modules/react-hook-form": { + "version": "7.54.2", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.54.2.tgz", + "integrity": "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", diff --git a/package.json b/package.json index e4a6ba7..2324d94 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "postinstall": "prisma generate" }, "dependencies": { + "@hookform/resolvers": "^3.10.0", "@prisma/client": "^6.2.1", "@radix-ui/react-accordion": "^1.2.2", "@radix-ui/react-dialog": "^1.1.4", @@ -30,6 +31,7 @@ "next": "15.1.2", "react": "^19.0.0", "react-dom": "^19.0.0", + "react-hook-form": "^7.54.2", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", "zod": "^3.24.1", diff --git a/store/AuthStore/useAuthStore.ts b/store/AuthStore/useAuthStore.ts index 9634eca..b86b38a 100644 --- a/store/AuthStore/useAuthStore.ts +++ b/store/AuthStore/useAuthStore.ts @@ -68,7 +68,7 @@ export const useAuthStore = create<authStore>((set) => ({ const response = await axios.post('/api/auth/register', signupMetaData); if (response.status === 201) { set({ user: signupMetaData }); - router.push('/dashboard'); + router.push('/auth/signin'); set({ signupError: null }); } } catch (error: any) { diff --git a/validations/validation.ts b/validations/validation.ts index 4685424..167cbb3 100644 --- a/validations/validation.ts +++ b/validations/validation.ts @@ -1,9 +1,33 @@ import z from 'zod'; export const signupSchema = z.object({ - email: z.string().email({ message: "Invalid email address" }), - password: z.string().min(6, { message: "Password must be at least 6 characters long" }), - fullName: z.string().nonempty({ message: "Full name is required" }), - leetcodeUsername: z.string().nonempty({ message: "Leetcode username is required" }), - gender: z.string().nonempty({ message: "Gender is required" }) -}); \ No newline at end of file + email: z + .string() + .email({ message: "Please provide a valid email address." }), + password: z + .string() + .min(6, { message: "Password must be at least 6 characters." }) + .max(20, { message: "Your password cannot exceed 20 characters" }) + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*[@$!%*?&#^])[A-Za-z@$!%*?&#^]{6,}$/, + { + message: "Password must include at least one uppercase letter, one lowercase letter, and one special character", + } + ), + fullName: z + .string() + .nonempty({ message: "Full name is required." }), + leetcodeUsername: z + .string() + .nonempty({ message: "Leetcode username is required to connect your profile" }), + gender: z + .string() + .nonempty({ message: "Please select your gender " }), +}); + +export const signinSchema = z.object({ + email: z.string().email({ message: "Please provide a valid email address." }), + password: z.string().nonempty({ message: "Password is required." }), +}); + +