diff --git a/client/package-lock.json b/client/package-lock.json index e7aaaa4..a7740b5 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.1", @@ -41,6 +42,7 @@ "sonner": "^1.6.1", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", + "validator": "^13.12.0", "zod": "^3.23.8", "zustand": "^5.0.1", "zustand-persist": "^0.4.0" @@ -52,6 +54,7 @@ "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "eslint": "^8.57.0", @@ -1403,25 +1406,107 @@ } }, "node_modules/@radix-ui/react-popover": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.1.tgz", - "integrity": "sha512-3y1A3isulwnWhvTTwmIreiB8CF4L+qRjZnK1wYLO7pplddzXKby/GnZ2M7OZY3qgnl6p9AodUIHRYGXNah8Y7g==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.2.tgz", + "integrity": "sha512-u2HRUyWW+lOiA2g0Le0tMmT55FGOEWHwPFt1EPfbLly7uXQExFo5duNKqG2DzmFXIdqOeNd+TpE8baHWJCyP9w==", + "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.0", "@radix-ui/react-compose-refs": "1.1.0", - "@radix-ui/react-context": "1.1.0", - "@radix-ui/react-dismissable-layer": "1.1.0", - "@radix-ui/react-focus-guards": "1.1.0", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.1", + "@radix-ui/react-focus-guards": "1.1.1", "@radix-ui/react-focus-scope": "1.1.0", "@radix-ui/react-id": "1.1.0", "@radix-ui/react-popper": "1.2.0", - "@radix-ui/react-portal": "1.1.1", - "@radix-ui/react-presence": "1.1.0", + "@radix-ui/react-portal": "1.1.2", + "@radix-ui/react-presence": "1.1.1", "@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-slot": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0", "aria-hidden": "^1.1.1", - "react-remove-scroll": "2.5.7" + "react-remove-scroll": "2.6.0" + }, + "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-popover/node_modules/@radix-ui/react-context": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.1.tgz", + "integrity": "sha512-UASk9zi+crv9WteK/NU4PLvOoL3OuE6BWVKNF6hPRBtYBDXQ2u5iu3O59zUlJiTVvkyuycnqrztsHVJwcK9K+Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.1.tgz", + "integrity": "sha512-QSxg29lfr/xcev6kSz7MAlmDnzbP1eI/Dwn3Tp1ip0KT5CUELsxkekFEMVBEoykI3oV39hKT4TKZzBNMbcTZYQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.0", + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-callback-ref": "1.1.0", + "@radix-ui/react-use-escape-keydown": "1.1.0" + }, + "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-popover/node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.1.tgz", + "integrity": "sha512-pSIwfrT1a6sIoDASCSpFwOasEwKTZWDw/iBdtnqKO7v6FeOzYJ7U53cPzYFVR3geGGXgVHaH+CdngrrAzqUGxg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.2.tgz", + "integrity": "sha512-WeDYLGPxJb/5EGBoedyJbT0MpoULmwnIPMJMSldkuiMsBAv7N1cRdsTWZWht9vpPOiN3qyiGAtbK2is47/uMFg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.0.0", + "@radix-ui/react-use-layout-effect": "1.1.0" }, "peerDependencies": { "@types/react": "*", @@ -1438,6 +1523,55 @@ } } }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-presence": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.1.tgz", + "integrity": "sha512-IeFXVi4YS1K0wVZzXNrbaaUvIJ3qdY+/Ih4eHFhWA9SwGR9UDX7Ck8abvL57C4cv3wwMvUE0OG69Qc3NCcTe/A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0" + }, + "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-popover/node_modules/react-remove-scroll": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.0.tgz", + "integrity": "sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.6", + "react-style-singleton": "^2.2.1", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.0", + "use-sidecar": "^1.1.2" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-popper": { "version": "1.2.0", "license": "MIT", @@ -2217,6 +2351,13 @@ "@types/react": "*" } }, + "node_modules/@types/validator": { + "version": "13.12.2", + "resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.12.2.tgz", + "integrity": "sha512-6SlHBzUW8Jhf3liqrGGXyTJSIFe4nqlJ5A5KaMZ2l/vbM3Wh3KSybots/wfWVzNLK4D1NZluDlSQIbIEPx6oyA==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.15.0.tgz", @@ -8206,6 +8347,15 @@ "version": "1.0.2", "license": "MIT" }, + "node_modules/validator": { + "version": "13.12.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.12.0.tgz", + "integrity": "sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/which": { "version": "2.0.2", "license": "ISC", diff --git a/client/package.json b/client/package.json index 1245101..ee65af0 100644 --- a/client/package.json +++ b/client/package.json @@ -15,14 +15,14 @@ "prepare": "cd .. && husky client/.husky" }, "dependencies": { - "@hookform/resolvers": "^3.9.0", + "@hookform/resolvers": "^3.9.1", "@radix-ui/react-alert-dialog": "^1.1.1", "@radix-ui/react-avatar": "^1.1.1", "@radix-ui/react-dialog": "^1.1.2", - "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-dropdown-menu": "^2.1.1", "@radix-ui/react-icons": "^1.3.0", "@radix-ui/react-label": "^2.1.0", + "@radix-ui/react-popover": "^1.1.2", "@radix-ui/react-select": "^2.1.1", "@radix-ui/react-slot": "^1.1.0", "@tanstack/react-query": "^5.45.1", @@ -44,11 +44,12 @@ "react": "^18.3.1", "react-day-picker": "^8.10.1", "react-dom": "^18.3.1", - "react-hook-form": "^7.52.1", + "react-hook-form": "^7.53.1", "react-icons": "^5.2.1", "sonner": "^1.6.1", "tailwind-merge": "^2.3.0", "tailwindcss-animate": "^1.0.7", + "validator": "^13.12.0", "zod": "^3.23.8", "zustand": "^5.0.1", "zustand-persist": "^0.4.0" @@ -60,6 +61,7 @@ "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", + "@types/validator": "^13.12.2", "@typescript-eslint/eslint-plugin": "^8.15.0", "@typescript-eslint/parser": "^8.15.0", "eslint": "^8.57.0", diff --git a/client/src/components/main/EventPage.tsx b/client/src/components/main/EventPage.tsx index 1bc07a5..e2afc83 100644 --- a/client/src/components/main/EventPage.tsx +++ b/client/src/components/main/EventPage.tsx @@ -6,9 +6,9 @@ import Link from "next/link"; import RsvpButton from "@/components/main/RsvpButton"; import RsvpListModal from "@/components/main/RsvpListModal"; +import SignUpModal from "@/components/modal/sign-up"; import LogInModal from "@/components/ui/LogInModal"; import PageCard from "@/components/ui/page-card"; -import SignUpModal from "@/components/ui/SignUpModal"; import { useUser } from "@/hooks/useUser"; import type { Event } from "@/types/event"; diff --git a/client/src/components/main/header/Navbar.tsx b/client/src/components/main/header/Navbar.tsx index fd219dc..bd8e427 100644 --- a/client/src/components/main/header/Navbar.tsx +++ b/client/src/components/main/header/Navbar.tsx @@ -1,9 +1,8 @@ import Image from "next/image"; import Link from "next/link"; -import { useState } from "react"; +import SignUpModal from "@/components/modal/sign-up"; import LogInModal from "@/components/ui/LogInModal"; -import SignUpModal from "@/components/ui/SignUpModal"; import { useAuth } from "@/context/AuthProvider"; import { DropDownNav } from "./DropDown"; diff --git a/client/src/components/modal/sign-up.tsx b/client/src/components/modal/sign-up.tsx new file mode 100644 index 0000000..57fefc6 --- /dev/null +++ b/client/src/components/modal/sign-up.tsx @@ -0,0 +1,253 @@ +import { zodResolver } from "@hookform/resolvers/zod"; +import { ReloadIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/router"; +import { ReactNode } from "react"; +import { useForm } from "react-hook-form"; +import { toast } from "sonner"; +import validator from "validator"; +import { z } from "zod"; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/BetterDialog"; +import { Button } from "@/components/ui/button"; +import { + Form, + FormControl, + FormField, + FormInput, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import LogInModal from "@/components/ui/LogInModal"; +import { SelectBranch } from "@/components/ui/select-branch"; +import { useAuth } from "@/context/AuthProvider"; +import { useDelayedPending } from "@/hooks/useDelayedPending"; +import { useRegister } from "@/hooks/useUser"; + +const schema = z + .object({ + email: z.string().email(), + phone: z + .string() + .refine(validator.isMobilePhone, "Must be a valid phone number"), + first_name: z.string().min(1).max(255), + last_name: z.string().min(1).max(255), + password: z.string().min(6), + password_confirm: z.string().min(6), + branch_id: z.number(), + }) + .superRefine(({ password, password_confirm }, ctx) => { + if (password != password_confirm) { + ctx.addIssue({ + code: "custom", + message: "Passwords must match", + path: ["password_confirm"], + }); + } + }); + +type Props = { + children: ReactNode; +}; + +export default function SignUpModal({ children }: Props) { + return ( + + {children} + + + Create an account + + + + + ); +} + +function SignUpForm() { + const router = useRouter(); + const { login } = useAuth(); + const { mutate: register, isPending } = useRegister({ + onSuccess: (_, details) => { + login(details.email, details.password).then(() => { + router.push("/profile"); + toast.success("Your account has been created."); + }); + }, + onError: (error) => { + const errorMessage = error.response?.data?.["error"]; + + if ( + typeof errorMessage === "string" && + errorMessage.includes("duplicate") + ) { + form.setError("email", { message: "Email is already in use" }); + } else { + toast.error("Something went wrong. Please try again later."); + } + }, + }); + + const showIsPending = useDelayedPending(isPending); + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: { + email: "", + phone: "", + first_name: "", + last_name: "", + password: "", + password_confirm: "", + }, + }); + + const onSubmit = (values: z.infer) => { + register({ + email: values.email, + phone: values.phone, + firstName: values.first_name, + lastName: values.last_name, + password: values.password, + branch: values.branch_id, + }); + }; + + return ( +
+ +
+ ( + + First Name + + + + + + )} + /> + ( + + Last Name + + + + + + )} + /> +
+ ( + + Email + + + + + + )} + /> + ( + + Phone Number + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + ( + + Password Confirmation + + + + + + )} + /> + ( + + Local Co-Exist Branch + + + + + + )} + /> +
+ + + + + + +
+ + + ); +} diff --git a/client/src/components/ui/BetterDialog.tsx b/client/src/components/ui/BetterDialog.tsx index e27a67b..35c293a 100644 --- a/client/src/components/ui/BetterDialog.tsx +++ b/client/src/components/ui/BetterDialog.tsx @@ -29,14 +29,17 @@ DialogOverlay.displayName = DialogPrimitive.Overlay.displayName; const DialogContent = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, children, ...props }, ref) => ( + React.ComponentPropsWithoutRef & { + wrapHeight?: boolean; + } +>(({ className, children, wrapHeight = false, ...props }, ref) => ( { - alert("Account created successfully."); - login(details.email, details.password).then(() => { - router.push("/profile"); - toast.success("You are now logged in."); - }); - }, - onError: (error) => { - if (!error.status) { - alert( - "There was an server error when trying to create an account. Please try again later.", - ); - } else { - alert(`Registration Error. Status Code = ${error.status}`); - } - }, - }); - - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [confirmpassword, setConfirmPassword] = useState(""); - const [firstname, setFirstName] = useState(""); - const [lastname, setLastName] = useState(""); - const [branch, setBranch] = useState(NaN); - const [phone, setPhone] = useState(""); - const onErrorStyle = "border-2 border-red-500"; - const [error, setError] = useState({ - email: false, - firstname: false, - lastname: false, - phone: false, - password: false, - confirmpassword: false, - city: false, - }); - const [emsg, setMsg] = useState(Array(6).fill(false)); - // 0 - empty fields - // 1 - invalid email - // 2 - invalid phone - // 3 - password mismatch - // 4 - duplicate email - // 5 - no branch selected - // - - function emptyFields() { - let msg = Array(6).fill(false); - let temp = { - email: false, - firstname: false, - lastname: false, - phone: false, - password: false, - confirmpassword: false, - city: false, - }; - - const fields = { - email, - firstname, - lastname, - password, - confirmpassword, - city: branch, - }; - - //check each field if its empty - Object.entries(fields).forEach(([key, value]) => { - if (typeof value === "number") { - if (Number.isNaN(value)) { - temp[key as keyof typeof temp] = true; - } - } else { - if (!value.trim().length) { - temp[key as keyof typeof temp] = true; - } - } - }); - - //if atleast one empty show error message to fill out required fields - if ( - temp["email"] || - temp["firstname"] || - temp["lastname"] || - temp["password"] || - temp["confirmpassword"] || - temp["city"] - ) { - msg[0] = true; - setMsg(msg); - setError(temp); - return true; - } - } - - const handleSubmit = async (e: React.FormEvent) => { - let msg = Array(6).fill(false); - let temp = { - email: false, - firstname: false, - lastname: false, - phone: false, - password: false, - confirmpassword: false, - city: false, - }; - - e.preventDefault(); - - // front end checks for empty fields - if (emptyFields()) { - return; - } - - // mismatching password - if (password !== confirmpassword) { - temp["password"] = true; - temp["confirmpassword"] = true; - msg[3] = true; - setMsg(msg); - setError(temp); - return; - } - - //invalid phone number - if (phone.trim().length) { - if (!/^\d+$/.test(phone) || phone.length !== 10) { - temp["phone"] = true; - msg[2] = true; - setMsg(msg); - setError(temp); - return; - } - } - - if (Number.isNaN(branch)) { - temp["city"] = true; - msg[6] = true; - setError(temp); - setMsg(msg); - return; - } - - //make api call - register({ - email, - password, - firstName: firstname, - lastName: lastname, - phone, - branch, - }); - }; - - return ( - - {children} - {/* Outer green container */} - -
- {/*Image */} -
- CO-EXIST LOGO -
-
- - - Sign up - - -
- -
- {/* Labels */} -
- {emsg[0] ? ( - - Please fill out all required fields (*). - - ) : ( - "" - )} - -
- - setEmail(e.target.value)} - /> -
- - {emsg[1] ? ( - - Invalid Email. - - ) : ( - "" - )} - - {emsg[4] ? ( - - Email already has been used. - - ) : ( - "" - )} - - {/* Name labels */} -
-
- - setFirstName(e.target.value)} - /> -
-
- - setLastName(e.target.value)} - /> -
-
- -
- - setPhone(e.target.value)} - /> -
- - {emsg[2] ? ( - - Enter a valid phone number. - - ) : ( - "" - )} - -
- - setPassword(e.target.value)} - /> -
- -
- - setConfirmPassword(e.target.value)} - /> -
- - {emsg[3] ? ( - - Passwords do not match. - - ) : ( - "" - )} - - {/* Select label */} -
- {/* Component uses branch api to get cities from backend*/} - - - - {emsg[6] ? ( - - Please select a branch. - - ) : ( - "" - )} -
-
- - -
- -
-
-
-
-
-
- ); -} - -export default SignUpModal; diff --git a/client/src/components/ui/form.tsx b/client/src/components/ui/form.tsx index 0710ab4..8881959 100644 --- a/client/src/components/ui/form.tsx +++ b/client/src/components/ui/form.tsx @@ -1,5 +1,3 @@ -"use client"; - import * as LabelPrimitive from "@radix-ui/react-label"; import { Slot } from "@radix-ui/react-slot"; import * as React from "react"; @@ -12,6 +10,7 @@ import { useFormContext, } from "react-hook-form"; +import { Input, InputProps } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { cn } from "@/lib/utils"; @@ -80,7 +79,7 @@ const FormItem = React.forwardRef< return ( -
+
); }); @@ -88,17 +87,22 @@ FormItem.displayName = "FormItem"; const FormLabel = React.forwardRef< React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, ...props }, ref) => { + React.ComponentPropsWithoutRef & { + required?: boolean; + } +>(({ className, children, required = false, ...props }, ref) => { const { error, formItemId } = useFormField(); return ( ); }); FormLabel.displayName = "FormLabel"; @@ -145,12 +149,12 @@ FormDescription.displayName = "FormDescription"; const FormMessage = React.forwardRef< HTMLParagraphElement, - React.HTMLAttributes ->(({ className, children, ...props }, ref) => { + React.HTMLAttributes & { preserveSpace?: boolean } +>(({ className, children, preserveSpace = false, ...props }, ref) => { const { error, formMessageId } = useFormField(); const body = error ? String(error?.message) : children; - if (!body) { + if (!body && !preserveSpace) { return null; } @@ -161,17 +165,32 @@ const FormMessage = React.forwardRef< className={cn("text-sm font-medium text-destructive", className)} {...props} > - {body} + {body ?? (preserveSpace ? <>  : null)}

); }); FormMessage.displayName = "FormMessage"; +const FormInput = React.forwardRef( + ({ className, ...props }, ref) => { + const { error } = useFormField(); + return ( + + ); + }, +); +FormInput.displayName = "FormInput"; + export { Form, FormControl, FormDescription, FormField, + FormInput, FormItem, FormLabel, FormMessage, diff --git a/client/src/hooks/useUser.ts b/client/src/hooks/useUser.ts index bb4a7f1..b2f1420 100644 --- a/client/src/hooks/useUser.ts +++ b/client/src/hooks/useUser.ts @@ -33,7 +33,11 @@ export const useUser = () => { export const useRegister = ( args?: Omit< - UseMutationOptions, + UseMutationOptions< + unknown, + AxiosError<{ [key: string]: unknown }>, + RegistrationDetails + >, "mutationKey" | "mutationFn" >, ) => {