diff --git a/package-lock.json b/package-lock.json index 8411e4f..5913709 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,11 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@prisma/client": "^6.15.0", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", "@rainbow-me/rainbowkit": "^2.2.8", "@tanstack/react-query": "^5.87.1", "bcryptjs": "^3.0.2", @@ -23,7 +26,9 @@ "next-safe-action": "^8.0.11", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.63.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "viem": "^2.37.5", "wagmi": "^2.16.9", @@ -562,6 +567,18 @@ "viem": ">=2.0.0" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -1683,6 +1700,29 @@ } } }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", @@ -3121,6 +3161,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -8897,6 +8943,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.63.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.63.0.tgz", + "integrity": "sha512-ZwueDMvUeucovM2VjkCf7zIHcs1aAlDimZu2Hvel5C5907gUzMpm4xCrQXtRzCvsBqFjonB4m3x4LzCFI1ZKWA==", + "license": "MIT", + "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-icons": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", @@ -9527,6 +9589,16 @@ "atomic-sleep": "^1.0.0" } }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", diff --git a/package.json b/package.json index b78e3e5..d6f704c 100644 --- a/package.json +++ b/package.json @@ -15,8 +15,11 @@ "prisma:reset": "prisma migrate reset" }, "dependencies": { + "@hookform/resolvers": "^5.2.2", "@prisma/client": "^6.15.0", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-slot": "^1.2.3", "@rainbow-me/rainbowkit": "^2.2.8", "@tanstack/react-query": "^5.87.1", "bcryptjs": "^3.0.2", @@ -29,7 +32,9 @@ "next-safe-action": "^8.0.11", "react": "19.1.0", "react-dom": "19.1.0", + "react-hook-form": "^7.63.0", "react-icons": "^5.5.0", + "sonner": "^2.0.7", "tailwind-merge": "^3.3.1", "viem": "^2.37.5", "wagmi": "^2.16.9", diff --git a/src/actions/auth/signin/logic.ts b/src/actions/auth/signin/logic.ts index 7f89554..3f78998 100644 --- a/src/actions/auth/signin/logic.ts +++ b/src/actions/auth/signin/logic.ts @@ -1,13 +1,13 @@ import 'server-only'; import { prisma } from "@/lib/prisma"; -import { signinInput } from "./schema"; import bcrypt from "bcryptjs"; import { error, Result, success } from "@/lib/result"; import { getSession } from "@/lib/session"; import type { User } from "@prisma/client"; +import { SigninInput } from './schema'; -export async function signin(input: signinInput): Promise>> { +export async function signin(input: SigninInput): Promise>> { const { email, password } = input; const normalisedEmail = email.toLowerCase().trim(); diff --git a/src/actions/auth/signin/schema.ts b/src/actions/auth/signin/schema.ts index 995d4fa..7bb7c7b 100644 --- a/src/actions/auth/signin/schema.ts +++ b/src/actions/auth/signin/schema.ts @@ -5,4 +5,4 @@ export const signinSchema = z.object({ password: z.string().min(1,'Password is required') }); -export type signinInput = z.infer \ No newline at end of file +export type SigninInput = z.infer \ No newline at end of file diff --git a/src/actions/auth/signup/action.ts b/src/actions/auth/signup/action.ts index 9edaffc..f2ca780 100644 --- a/src/actions/auth/signup/action.ts +++ b/src/actions/auth/signup/action.ts @@ -1,15 +1,15 @@ 'use server'; import { actionClient } from '@/lib/action'; -import { Signup} from './logic'; -import { SignupSchema} from './schema'; +import { signup} from './logic'; +import { signupSchema} from './schema'; export const signupAction = actionClient - .inputSchema(SignupSchema) + .inputSchema(signupSchema) .metadata({ actionName: 'signup' }) .action(async ({ parsedInput }) => { try { - const result = await Signup(parsedInput); + const result = await signup(parsedInput); if (result.success) { return result.data; diff --git a/src/actions/auth/signup/logic.ts b/src/actions/auth/signup/logic.ts index 45a4396..078f63b 100644 --- a/src/actions/auth/signup/logic.ts +++ b/src/actions/auth/signup/logic.ts @@ -2,12 +2,12 @@ import 'server-only'; import { prisma } from "@/lib/prisma"; import bcrypt from "bcryptjs"; -import { signupInput } from "./schema"; import { error, Result, success } from "@/lib/result"; import { getSession } from "@/lib/session"; import type { User } from "@prisma/client"; +import { SignupInput } from './schema'; -export async function Signup(input: signupInput): Promise> { +export async function signup(input: SignupInput): Promise>> { const { email, name, password } = input; const normalisedEmail = email.toLowerCase().trim(); @@ -34,5 +34,8 @@ export async function Signup(input: signupInput): Promise> { session.user = { id: user.id }; await session.save(); - return success(user); + // Omit password before returning + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { password: _password, ...userWithoutPassword } = user; + return success(userWithoutPassword); } \ No newline at end of file diff --git a/src/actions/auth/signup/schema.ts b/src/actions/auth/signup/schema.ts index 208e79b..fc139f7 100644 --- a/src/actions/auth/signup/schema.ts +++ b/src/actions/auth/signup/schema.ts @@ -1,14 +1,22 @@ import { z } from "zod"; -export const signupSchema = z.object({ - name: z.string().min(1, 'Name is required'), - email: z.string().email('Invalid email format'), - password: z.string() - .min(8, 'Password must be at least 8 characters long') - .regex(/(?=.*[a-z])/, 'Password must contain at least one lowercase letter') - .regex(/(?=.*[A-Z])/, 'Password must contain at least one uppercase letter') - .regex(/(?=.*\d)/, 'Password must contain at least one number') +export const signupSchema = z + .object({ + name: z.string().min(1, 'Name is required'), + email: z.string().email('Invalid email format'), + password: z.string() + .min(8, 'Password must be at least 8 characters long') + .regex(/(?=.*[a-z])/, 'Password must contain at least one lowercase letter') + .regex(/(?=.*[A-Z])/, 'Password must contain at least one uppercase letter') + .regex(/(?=.*\d)/, 'Password must contain at least one number'), + confirmPassword: z.string().min(1, 'Please confirm your password'), + terms: z.literal(true, { + errorMap: () => ({ message: 'You must accept the terms' }) + }), + }) + .refine((data) => data.password === data.confirmPassword, { + message: 'Passwords do not match', + path: ['confirmPassword'], + }); -}) - -export type signupInput = z.infer \ No newline at end of file +export type SignupInput = z.infer \ No newline at end of file diff --git a/src/app/(auth)/signin/page.tsx b/src/app/(auth)/signin/page.tsx new file mode 100644 index 0000000..69da413 --- /dev/null +++ b/src/app/(auth)/signin/page.tsx @@ -0,0 +1,9 @@ +"use client"; + +import { SigninPageContainer } from "@/components/pages/signin/SigninPageContainer"; + + + +export default function SignInPage() { + return ; +} diff --git a/src/app/(auth)/signup/page.tsx b/src/app/(auth)/signup/page.tsx new file mode 100644 index 0000000..8548712 --- /dev/null +++ b/src/app/(auth)/signup/page.tsx @@ -0,0 +1,7 @@ +import { SignupPageContainer } from "@/components/pages/signup/SignupPageContainer"; + +export default function Signup(){ + return( + + ) +} \ No newline at end of file diff --git a/src/app/auth/signin/page.tsx b/src/app/auth/signin/page.tsx deleted file mode 100644 index 6fee84b..0000000 --- a/src/app/auth/signin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import LoginForm from "@/components/LoginForm"; - -export default function SignInPage() { - return ; -} diff --git a/src/app/auth/signup/page.tsx b/src/app/auth/signup/page.tsx deleted file mode 100644 index e71e51d..0000000 --- a/src/app/auth/signup/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import RegisterForm from "@/components/RegisterForm"; - -export default function SignUpPage() { - return ; -} diff --git a/src/components/pages/signin/SigninPageContainer.tsx b/src/components/pages/signin/SigninPageContainer.tsx new file mode 100644 index 0000000..cc9af05 --- /dev/null +++ b/src/components/pages/signin/SigninPageContainer.tsx @@ -0,0 +1,115 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { signinAction } from "@/actions/auth/signin/action"; +import { useForm } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; +import { Form } from "@/components/ui/form"; +import FormInput from "@/components/shared/Form/FormInput"; +import { signinSchema } from "@/actions/auth/signin/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { FormButton } from "@/components/shared/Form/FormButton"; + +type SignInFormValues = z.infer; + +export function SigninPageContainer() { + const form = useForm({ + mode: "onChange", + resolver: zodResolver(signinSchema), + defaultValues: { + email: "", + password: "", + }, + }); + const router = useRouter(); + + const { execute, isExecuting } = useAction(signinAction, { + onSuccess: () => { + toast.success("Signed in successfully!"); + router.push("/"); + }, + onError: (error) => { + const fieldErrors = error.error.validationErrors?.fieldErrors; + const errorMessage = + error.error.serverError ?? + (fieldErrors + ? Object.entries(fieldErrors) + .map(([key, value]) => `${key}: ${value}`) + .join(", ") + : "An unknown error occurred"); + toast.error(errorMessage); + }, + }); + + return ( +
+ + + + Sign in to Sentiopulse + + + Enter your credentials to access your account + + + +
+ + + + + + + + + Sign in + +

+ Don't have an account?{" "} + + Sign up + +

+
+
+ +
+
+ ); +} diff --git a/src/components/pages/signup/SignupPageContainer.tsx b/src/components/pages/signup/SignupPageContainer.tsx new file mode 100644 index 0000000..f32a16d --- /dev/null +++ b/src/components/pages/signup/SignupPageContainer.tsx @@ -0,0 +1,154 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { + Card, + CardContent, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { signupAction } from "@/actions/auth/signup/action"; +import { useForm } from "react-hook-form"; +import { useAction } from "next-safe-action/hooks"; +import { Form } from "@/components/ui/form"; +import FormInput from "@/components/shared/Form/FormInput"; +import FormCheckbox from "@/components/shared/Form/FormCheckbox"; +import { signupSchema } from "@/actions/auth/signup/schema"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { FormButton } from "@/components/shared/Form/FormButton"; + +type SignUpFormValues = z.infer; + +export function SignupPageContainer() { + const form = useForm({ + mode: "onChange", + resolver: zodResolver(signupSchema), + defaultValues: { + name: "", + email: "", + password: "", + confirmPassword: "", + terms: true, + }, + }); + const router = useRouter(); + + const { execute, isExecuting } = useAction(signupAction, { + onSuccess: () => { + toast.success("Account created successfully!"); + form.reset(); + router.push("/"); + }, + onError: (error) => { + const fieldErrors = error.error.validationErrors?.fieldErrors; + const errorMessage = + error.error.serverError ?? + (fieldErrors + ? Object.entries(fieldErrors) + .map(([key, value]) => `${key}: ${value}`) + .join(", ") + : "An unknown error occurred"); + toast.error(errorMessage); + }, + }); + + return ( +
+ + + + Create your account + + + +
+ + + + + + + + I agree to the{" "} + + Terms of Service + {" "} + and{" "} + + Privacy Policy + + + } + required + /> + + + + + Create account + +

+ Already have an account?{" "} + + Sign in + +

+
+
+ +
+
+ ); +} diff --git a/src/components/shared/Form/FormButton.tsx b/src/components/shared/Form/FormButton.tsx new file mode 100644 index 0000000..6771dcd --- /dev/null +++ b/src/components/shared/Form/FormButton.tsx @@ -0,0 +1,27 @@ +import { Button } from "@/components/ui/button"; + +type FormButtonProps = { + type?: "button" | "submit" | "reset"; + loading?: boolean; + disabled?: boolean; + className?: string; + children: React.ReactNode; +}; + +export function FormButton({ + type = "submit", + loading = false, + disabled = false, + className = "", + children, +}: FormButtonProps) { + return ( + + ); +} \ No newline at end of file diff --git a/src/components/shared/Form/FormCheckbox.tsx b/src/components/shared/Form/FormCheckbox.tsx new file mode 100644 index 0000000..c683695 --- /dev/null +++ b/src/components/shared/Form/FormCheckbox.tsx @@ -0,0 +1,53 @@ +import { Control, FieldValues, Path } from "react-hook-form"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +type FormCheckboxProps = { + control: Control; + name: Path; + label: React.ReactNode; + required?: boolean; +}; + +export function FormCheckbox({ + control, + name, + label, + required = false, +}: FormCheckboxProps) { + return ( + ( + +
+ + + + + {label} + {required && *} + +
+ +
+ )} + /> + ); +} + +export default FormCheckbox; diff --git a/src/components/shared/Form/FormInput.tsx b/src/components/shared/Form/FormInput.tsx new file mode 100644 index 0000000..515dee1 --- /dev/null +++ b/src/components/shared/Form/FormInput.tsx @@ -0,0 +1,83 @@ +import { Control, FieldValues, Path } from "react-hook-form"; +import { Input } from "@/components/ui/input"; +import { + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; + +export function FormInput({ + placeholder, + control, + name, + label, + type = "text", + required = false, + helperText, + endComponent, + min, + step, + max, +}: { + max?: number; + step?: number; + label: string; + placeholder: string; + control: Control; + name: Path; + type?: React.HTMLInputTypeAttribute; + required?: boolean; + helperText?: string; + endComponent?: React.ReactNode; + min?: number; +}) { + return ( + ( + + + {label} {required && *} + +
+ + { + if (type === "number") { + const value = e.target.value; + if (value === "" || value === null || value === undefined) { + field.onChange(null); + } else { + field.onChange(Number(value)); + } + } else { + field.onChange(e.target.value); + } + }} + type={type} + required={required} + /> + + {endComponent} +
+ {helperText && ( +
+ {helperText} +
+ )} + +
+ )} + /> + ); +} + +export default FormInput; diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx new file mode 100644 index 0000000..524b986 --- /dev/null +++ b/src/components/ui/form.tsx @@ -0,0 +1,167 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" +import { Slot } from "@radix-ui/react-slot" +import { + Controller, + FormProvider, + useFormContext, + useFormState, + type ControllerProps, + type FieldPath, + type FieldValues, +} 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 = FieldPath, +> = { + name: TName +} + +const FormFieldContext = React.createContext( + {} as FormFieldContextValue +) + +const FormField = < + TFieldValues extends FieldValues = FieldValues, + TName extends FieldPath = FieldPath, +>({ + ...props +}: ControllerProps) => { + return ( + + + + ) +} + +const useFormField = () => { + const fieldContext = React.useContext(FormFieldContext) + const itemContext = React.useContext(FormItemContext) + const { getFieldState } = useFormContext() + const formState = useFormState({ name: fieldContext.name }) + const fieldState = getFieldState(fieldContext.name, formState) + + if (!fieldContext) { + throw new Error("useFormField should be used within ") + } + + 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( + {} as FormItemContextValue +) + +function FormItem({ className, ...props }: React.ComponentProps<"div">) { + const id = React.useId() + + return ( + +
+ + ) +} + +function FormLabel({ + className, + ...props +}: React.ComponentProps) { + const { error, formItemId } = useFormField() + + return ( +