diff --git a/package-lock.json b/package-lock.json index 6f28d4b..d4aa75f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@fullcalendar/list": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.83.0", "@tanstack/react-query-devtools": "^5.83.0", "axios": "^1.11.0", @@ -21,6 +22,8 @@ "react": "19.1.0", "react-datepicker": "^8.7.0", "react-dom": "19.1.0", + "react-hook-form": "^7.65.0", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { @@ -2095,6 +2098,18 @@ "@fullcalendar/core": "~6.1.19" } }, + "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", @@ -2850,6 +2865,12 @@ "dev": 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/@svgr/babel-plugin-add-jsx-attribute": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", @@ -7932,6 +7953,22 @@ "react": "^19.1.0" } }, + "node_modules/react-hook-form": { + "version": "7.65.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.65.0.tgz", + "integrity": "sha512-xtOzDz063WcXvGWaHgLNrNzlsdFgtUWcb32E6WFaGTd7kPZG3EeDusjdZfUsPwKCKVXy1ZlntifaHZ4l8pAsmw==", + "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-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -9184,6 +9221,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", diff --git a/package.json b/package.json index cdb8122..2d00e38 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@fullcalendar/list": "^6.1.19", "@fullcalendar/react": "^6.1.19", "@fullcalendar/timegrid": "^6.1.19", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.83.0", "@tanstack/react-query-devtools": "^5.83.0", "axios": "^1.11.0", @@ -24,6 +25,8 @@ "react": "19.1.0", "react-datepicker": "^8.7.0", "react-dom": "19.1.0", + "react-hook-form": "^7.65.0", + "zod": "^4.1.12", "zustand": "^5.0.8" }, "devDependencies": { diff --git a/src/app/join/_components/JoinForm.tsx b/src/app/join/_components/JoinForm.tsx index 392ef18..18d0886 100644 --- a/src/app/join/_components/JoinForm.tsx +++ b/src/app/join/_components/JoinForm.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { useState, useEffect } from "react"; +import React, { useState } from "react"; import EmptyCheckCircle from "@/assets/EmptyCheckCircle.svg"; import FilledCheckCircle from "@/assets/FilledCheckCircle.svg"; import EyeInvisible from "@/assets/EyeInvisible.svg"; @@ -9,134 +9,74 @@ import ServiceTermsSection from "./ServiceTermsSection"; import { useRouter } from "next/navigation"; import { AxiosError } from "axios"; import { useSignUp } from "@/hooks/useAuth"; +import { joinFormSchema } from "@/lib/schemas/authSchema"; +import { JoinFormData } from "@/lib/schemas/authSchema"; +import { SubmitHandler, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; export default function JoinForm() { const router = useRouter(); const { mutate, isPending } = useSignUp(); - - const [email, setEmail] = useState(""); - const [emailError, setEmailError] = useState(""); - - const [name, setName] = useState(""); - - const [password, setPassword] = useState(""); - const [passwordError, setPasswordError] = useState(""); - const [password2, setPassword2] = useState(""); - const [password2Error, setPassword2Error] = useState(""); const [isVisiblePassword, setIsVisiblePassword] = useState(false); - - const [allAgree, setAllAgree] = useState(false); - const [firstAgree, setFirstAgree] = useState(false); - const [secondAgree, setSecondAgree] = useState(false); - const [showServiceTerms, setShowServiceTerms] = useState(false); const [showPrivacyTerms, setShowPrivacyTerms] = useState(false); - // 이메일 유효성 검사 - const validateEmail = (email: string) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }; - - // 닉네임 유효성 검사 - const validateName = (name: string) => { - return name.trim().length > 0; - }; - - // 비밀번호 유효성 검사 - const validatePassword = (password: string) => { - const passwordRegex = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)[\w\W]{8,20}$/; - return passwordRegex.test(password); - }; - - // 이메일 블러 시 에러 처리 - const handleEmailBlur = () => { - if (!email) { - setEmailError("이메일을 입력해주세요."); - } else if (!validateEmail(email)) { - setEmailError("이메일 형식이 올바르지 않습니다."); - } else { - setEmailError(""); - } - }; - - // 비밀번호 블러 시 에러 처리 - const passwordBlur = () => { - if (!password) { - setPasswordError("비밀번호를 입력해주세요."); - } else if (!validatePassword(password)) { - setPasswordError("비밀번호 형식이 올바르지 않습니다."); - } else setPasswordError(""); + const { + register, + handleSubmit, + watch, + setValue, + formState: { errors, isValid }, + } = useForm({ + resolver: zodResolver(joinFormSchema), + mode: "onBlur", + defaultValues: { + email: "", + nickname: "", + password: "", + password2: "", + firstAgree: false, + secondAgree: false, + }, + }); + + const onSubmit: SubmitHandler = (data) => { + const { email, nickname, password } = data; + + mutate( + { email, password, nickname }, + { + onSuccess: () => { + alert("인증 메일을 발송했습니다. 메일함을 확인해주세요."); + router.replace("/login"); + }, + onError: (err) => { + const error = err as AxiosError<{ message: string }>; + const message = error.response?.data?.message; + + switch (message) { + case "이미 가입된 이메일입니다.": + alert("이미 가입된 이메일입니다."); + break; + case "이미 사용중인 닉네임입니다.": + alert("이미 사용중인 닉네임입니다."); + default: + break; + } + }, + } + ); }; - // 비밀번호 일치 확인 - const password2Blur = () => { - if (password !== password2) { - setPassword2Error("비밀번호가 일치하지 않습니다."); - } else { - setPassword2Error(""); - } - }; + const firstAgree = watch("firstAgree"); + const secondAgree = watch("secondAgree"); + const allAgree = firstAgree && secondAgree; - // 전체동의 -> 개별 동의 모두 적용 const toggleAllAgree = () => { const next = !allAgree; - setAllAgree(next); - setFirstAgree(next); - setSecondAgree(next); - }; - - // 개별 동의 변경 시 전체 동의 자동 반영 - useEffect(() => { - if (firstAgree && secondAgree) { - setAllAgree(true); - } else { - setAllAgree(false); - } - }, [firstAgree, secondAgree]); - - // 제출 조건: 이메일/비번 유효 + 비밀번호 일치 + 필수 동의 두 개 - const isDisabledSubmit = - !validateEmail(email) || - !validateName(name) || - !validatePassword(password) || - password2Error !== "" || - !firstAgree || - !secondAgree || - isPending; - - const submitSignUp = async (e: React.FormEvent) => { - e.preventDefault(); - const data = { - email, - password, - nickname: name, - }; - - mutate(data, { - onSuccess: () => { - alert("인증 메일을 발송했습니다. 메일함을 확인해주세요."); - router.replace("/login"); - }, - onError: (err) => { - const error = err as AxiosError<{ message: string }>; - const message = error.response?.data?.message; - - switch (message) { - case "이미 가입된 이메일입니다.": - alert("이미 가입된 이메일입니다."); - break; - case "이미 사용중인 닉네임입니다.": - alert("이미 사용중인 닉네임입니다."); - - default: - break; - } - }, - }); + setValue("firstAgree", next, { shouldValidate: true }); + setValue("secondAgree", next, { shouldValidate: true }); }; - - // 스타일 변수 const inputWrapper = "flex flex-col gap-2"; const inputStyle = "border border-[#D9D9D9] rounded-xs h-8 px-2"; const checkBoxWrapper = "flex flex-row justify-between"; @@ -146,9 +86,7 @@ export default function JoinForm() { return (
{ - submitSignUp(e); - }} + onSubmit={handleSubmit(onSubmit)} > {/* 이메일 */}
@@ -159,11 +97,11 @@ export default function JoinForm() { type="text" id="id" className={inputStyle} - onBlur={handleEmailBlur} - value={email} - onChange={(e) => setEmail(e.target.value)} + {...register("email")} /> - {emailError && {emailError}} + {errors.email && ( + {errors.email.message} + )}
{/* 이름 */} @@ -176,10 +114,11 @@ export default function JoinForm() { id="name" placeholder="닉네임을 입력하세요." className={inputStyle} - onChange={(e) => { - setName(e.target.value.trim()); - }} + {...register("nickname")} /> + {errors.nickname && ( + {errors.nickname.message} + )} {/* 비밀번호 */} @@ -190,46 +129,35 @@ export default function JoinForm() { setPassword(e.target.value)} - value={password} className={inputStyle} - onBlur={passwordBlur} + {...register("password")} /> - {passwordError && {passwordError}} + {errors.password && ( + {errors.password.message} + )} {/* 비밀번호 확인 */}
setPassword2(e.target.value)} - onBlur={password2Blur} - disabled={!validatePassword(password)} - className={`w-full - ${ - !validatePassword(password) - ? `${inputStyle} bg-[#F5F5F5]` - : inputStyle - } - `} + className={inputStyle + " w-full " + (!watch("password") ? "bg-[#F5F5F5]" : "")} + {...register("password2")} />
- {password2Error && ( - {password2Error} + {errors.password2 && ( + {errors.password2.message} )} - {/* 전체동의 */}