diff --git a/package.json b/package.json index 3b02265..7b42d17 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "@hookform/resolvers": "^5.2.2", "@tailwindcss/vite": "^4.1.18", "@tanstack/react-query": "^5.90.17", + "axios": "^1.13.4", "clsx": "^2.1.1", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 69de35a..76983cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@tanstack/react-query': specifier: ^5.90.17 version: 5.90.17(react@19.2.3) + axios: + specifier: ^1.13.4 + version: 1.13.4 clsx: specifier: ^2.1.1 version: 2.1.1 @@ -1099,6 +1102,9 @@ packages: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1107,6 +1113,9 @@ packages: resolution: {integrity: sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==} engines: {node: '>=4'} + axios@1.13.4: + resolution: {integrity: sha512-1wVkUaAO6WyaYtCkcYCOx12ZgpGf9Zif+qXa4n+oYzK558YryKqiL6UWwd5DqiH3VRW0GYhTZQ/vlgJrCoNQlg==} + axobject-query@4.1.0: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} @@ -1208,6 +1217,10 @@ packages: colorette@2.0.20: resolution: {integrity: sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@2.0.3: resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} @@ -1324,6 +1337,10 @@ packages: resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} engines: {node: '>= 0.4'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -1621,10 +1638,23 @@ packages: flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} engines: {node: '>= 0.4'} + form-data@4.0.5: + resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} + engines: {node: '>= 6'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -2220,6 +2250,14 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mimic-function@5.0.1: resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} engines: {node: '>=18'} @@ -2392,6 +2430,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3843,12 +3884,22 @@ snapshots: async-function@1.0.0: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 axe-core@4.11.1: {} + axios@1.13.4: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.5 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + axobject-query@4.1.0: {} bail@2.0.2: {} @@ -3943,6 +3994,10 @@ snapshots: colorette@2.0.20: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@2.0.3: {} commander@14.0.2: {} @@ -4054,6 +4109,8 @@ snapshots: has-property-descriptors: 1.0.2 object-keys: 1.1.1 + delayed-stream@1.0.0: {} + dequal@2.0.3: {} detect-libc@2.1.2: {} @@ -4482,10 +4539,20 @@ snapshots: flatted@3.3.3: {} + follow-redirects@1.15.11: {} + for-each@0.3.5: dependencies: is-callable: 1.2.7 + form-data@4.0.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + fsevents@2.3.3: optional: true @@ -5191,6 +5258,12 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mimic-function@5.0.1: {} mini-svg-data-uri@1.4.4: {} @@ -5360,6 +5433,8 @@ snapshots: property-information@7.1.0: {} + proxy-from-env@1.1.0: {} + punycode@2.3.1: {} react-dom@19.2.3(react@19.2.3): diff --git a/src/api/auth/auth.ts b/src/api/auth/auth.ts new file mode 100644 index 0000000..8196aeb --- /dev/null +++ b/src/api/auth/auth.ts @@ -0,0 +1,29 @@ +import type { ICommonResponse } from "@/types/common/common"; + +import type { + IEmailSendRequest, + IEmailSendResponse, + IEmailVerifyRequest, +} from "../../types/auth/auth"; + +import { axiosInstance } from "@/lib/axiosInstance"; + +// 이메일 인증 코드 전송 +export const sendEmail = async ({ + email, +}: IEmailSendRequest): Promise> => { + const { data } = await axiosInstance.post("/api/users/email-send", { email }); + return data; +}; + +// 이메일 인증 코드 검증 +export const verifyEmail = async ({ + email, + authCode, +}: IEmailVerifyRequest): Promise> => { + const { data } = await axiosInstance.post("/api/users/email-verify", { + email, + authCode, + }); + return data; +}; diff --git a/src/components/auth/signupStep/Step01Email.tsx b/src/components/auth/signupStep/Step01Email.tsx index 8b25659..6d20209 100644 --- a/src/components/auth/signupStep/Step01Email.tsx +++ b/src/components/auth/signupStep/Step01Email.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { type SubmitHandler, useForm, useWatch } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { toast } from "sonner"; @@ -6,6 +6,9 @@ import type { z } from "zod"; import { step01Schema } from "@/utils/validation"; +import { useAuth } from "@/hooks/auth/useAuth"; +import { useTimer } from "@/hooks/useTimer"; + import CommonAuthInput from "@/components/auth/common/CommonAuthInput"; import Button from "@/components/common/Button"; @@ -19,6 +22,7 @@ type TStep01FormValues = z.infer; export default function SignupEmail({ onNext }: IStep01EmailProps) { const { setEmail } = useAuthStore(); + const { useSendCode, useCheckCode } = useAuth(); const [sendCode, setSendCode] = useState(false); const [, setCodeVerify] = useState(false); @@ -38,20 +42,54 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) { const watchedEmail = useWatch({ control, name: "email" }); const watchedCode = useWatch({ control, name: "code" }); + const { formattedTime, restart, stop, isExpired } = useTimer(180, { + onExpire: () => { + toast.error("인증 시간이 만료되었습니다. 다시 시도해주세요."); + }, + }); + + const resetVerification = useCallback(() => { + setSendCode(false); + stop(); + }, [stop]); + const postSendCode = async () => { setCodeVerify(false); const isEmailValid = await trigger("email"); if (isEmailValid && watchedEmail) { - setSendCode(true); - toast.success("인증번호가 발송되었습니다.", { - description: "테스트용: 아무 번호나 입력하세요", - }); + useSendCode.mutate( + { email: watchedEmail }, + { + onSuccess: () => { + setSendCode(true); + toast.success("인증번호가 발송되었습니다."); + restart(); + }, + onError: (error) => { + toast.error( + error.response?.data?.message || "메일 발송에 실패했습니다.", + ); + }, + }, + ); } }; const onSubmit: SubmitHandler = async (data) => { - setEmail(data.email); - onNext(); + useCheckCode.mutate( + { email: data.email, authCode: data.code }, + { + onSuccess: () => { + setEmail(data.email); + onNext(); + }, + onError: (error) => { + setCodeError( + error.response?.data?.message || "인증번호가 올바르지 않습니다.", + ); + }, + }, + ); }; useEffect(() => { @@ -60,8 +98,8 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) { }, [watchedCode, watchedEmail]); useEffect(() => { - setSendCode(false); - }, [watchedEmail]); + resetVerification(); + }, [watchedEmail, resetVerification]); return (
@@ -88,6 +126,7 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) { className="shrink-0 h-13.5! border border-brand-400 text-status-blue bg-white hover:bg-gray-50 px-4 rounded-15 font-body2 whitespace-nowrap" onClick={postSendCode} type="button" + disabled={useSendCode.isPending} > 인증번호 받기 @@ -108,10 +147,15 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) { : "인증번호를 입력하세요" } type="text" - timer={sendCode ? "03:00" : undefined} + timer={sendCode ? formattedTime : undefined} {...register("code")} - error={!!errors.code || !!codeError} - errorMessage={errors.code?.message || codeError} + disabled={isExpired} + error={!!errors.code || !!codeError || (isExpired && sendCode)} + errorMessage={ + isExpired + ? "인증 시간이 만료되었습니다." + : errors.code?.message || codeError + } />
@@ -121,7 +165,7 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) { fullWidth onClick={handleSubmit(onSubmit)} variant="gradient" - disabled={!isValid} + disabled={!isValid || useCheckCode.isPending || isExpired} > 다음으로 @@ -131,7 +175,7 @@ export default function SignupEmail({ onNext }: IStep01EmailProps) {