diff --git a/src/api/auth.ts b/src/api/auth.ts new file mode 100644 index 0000000..489d59e --- /dev/null +++ b/src/api/auth.ts @@ -0,0 +1,27 @@ +import { defaultInstance } from './httpRequest'; + +interface AuthTokenType { + accessToken: string; + refreshToken: string; +} + +const postLogin = async (loginInfo: { email: string; password: string }) => { + const result = await defaultInstance({ + method: 'POST', + url: '/login', + data: loginInfo, + }); + + return result; +}; + +const requestLogout = async () => { + const result = await defaultInstance({ + method: 'GET', + url: '/logout/success', + }); + + return result; +}; + +export { postLogin, requestLogout }; diff --git a/src/api/httpRequest.ts b/src/api/httpRequest.ts index 1a559ec..1e4fbf7 100644 --- a/src/api/httpRequest.ts +++ b/src/api/httpRequest.ts @@ -1,7 +1,11 @@ import axios, { AxiosError, AxiosInstance, AxiosRequestConfig } from 'axios'; -type HttpResponseType = { isSuccess: boolean; code: number; message: string }; -type HttpSuccessType = HttpResponseType & { result?: T }; +export type HttpResponseType = { + isSuccess: boolean; + code: number; + message: string; +}; +export type HttpSuccessType = HttpResponseType & { result?: T }; const BASE_URL = '/api'; @@ -16,7 +20,7 @@ authAxios.interceptors.response.use((config) => { return config; }); -class ResponseError extends Error { +export class ResponseError extends Error { isSuccess: boolean; code: number; diff --git a/src/api/info.ts b/src/api/info.ts new file mode 100644 index 0000000..653225e --- /dev/null +++ b/src/api/info.ts @@ -0,0 +1,33 @@ +import { defaultInstance } from './httpRequest'; + +const postSocialAccount = async (account: { socialAccountUrl: string }) => { + const url = `/social-accounts`; + + const result = await defaultInstance({ + method: 'POST', + url, + data: account, + }); + + return result; +}; + +const postInterest = async ({ + userId, + interestName, +}: { + userId: number; + interestName: string; +}) => { + const url = `/users/${userId}/interests`; + + const result = await defaultInstance({ + method: 'POST', + url, + data: { userId, interestName }, + }); + + return result; +}; + +export { postSocialAccount, postInterest }; diff --git a/src/api/mutations/auth.ts b/src/api/mutations/auth.ts new file mode 100644 index 0000000..6fdd7ce --- /dev/null +++ b/src/api/mutations/auth.ts @@ -0,0 +1,18 @@ +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { postLogin } from '../auth'; +import { HttpSuccessType, ResponseError } from '../httpRequest'; + +const useLogin = async ( + options: UseMutationOptions< + HttpSuccessType<{ accessToken: string; refreshToken: string }>, + ResponseError, + { email: string; password: string } + >, +) => { + return useMutation({ + mutationFn: (loginInfo) => postLogin(loginInfo), + ...options, + }); +}; + +export { useLogin }; diff --git a/src/api/mutations/info.ts b/src/api/mutations/info.ts new file mode 100644 index 0000000..60703eb --- /dev/null +++ b/src/api/mutations/info.ts @@ -0,0 +1,31 @@ +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { HttpSuccessType, ResponseError } from '../httpRequest'; +import { postInterest, postSocialAccount } from '../info'; + +const useAddSocialAccountMutation = ( + options: UseMutationOptions< + HttpSuccessType, + ResponseError, + { socialAccountUrl: string } + >, +) => { + return useMutation({ + mutationFn: postSocialAccount, + ...options, + }); +}; + +const useAddInterest = ( + options: UseMutationOptions< + HttpSuccessType, + ResponseError, + { userId: number; interestName: string } + >, +) => { + return useMutation({ + mutationFn: postInterest, + ...options, + }); +}; + +export { useAddSocialAccountMutation, useAddInterest }; diff --git a/src/api/mutations/sign-up.ts b/src/api/mutations/sign-up.ts new file mode 100644 index 0000000..e122dfd --- /dev/null +++ b/src/api/mutations/sign-up.ts @@ -0,0 +1,56 @@ +import { UseMutationOptions, useMutation } from '@tanstack/react-query'; +import { + UserSignUpType, + postConfirmCode, + postConfirmCodeByEamil, + postConfirmUsernameDuplicate, + postSignupUser, +} from '../sign-up'; +import { HttpSuccessType, ResponseError } from '../httpRequest'; + +export const useSelfSignUpMutation = ( + options: UseMutationOptions, ResponseError, string>, +) => + useMutation({ + mutationFn: postConfirmCodeByEamil, + ...options, + }); + +export const useEamilConfirmMutation = ( + options: UseMutationOptions< + HttpSuccessType<{ emailToken: string }>, + ResponseError, + { email: string; code: string } + > = {}, +) => { + return useMutation({ + mutationFn: postConfirmCode, + ...options, + }); +}; + +export const useConfirmUsernameDuplicate = ( + options: UseMutationOptions< + HttpSuccessType<{ usernameToken: string }>, + ResponseError, + string + > = {}, +) => { + return useMutation({ + mutationFn: postConfirmUsernameDuplicate, + ...options, + }); +}; + +export const useSignupUser = ( + options: UseMutationOptions< + HttpSuccessType, + ResponseError, + UserSignUpType + > = {}, +) => { + return useMutation({ + mutationFn: postSignupUser, + ...options, + }); +}; diff --git a/src/api/sign-up.ts b/src/api/sign-up.ts new file mode 100644 index 0000000..54ecfb3 --- /dev/null +++ b/src/api/sign-up.ts @@ -0,0 +1,69 @@ +import { defaultInstance } from './httpRequest'; + +type UserSignUpType = { + email: string; + password: string; + username: string; + emailToken: string; + usernameToken: string; +}; + +const postConfirmCodeByEamil = async (email: string) => { + const url = `/verification/email?email=${email}`; + + const result = await defaultInstance({ + method: 'POST', + url, + }); + + return result; +}; + +const postConfirmCode = async ({ + email, + code, +}: { + email: string; + code: string; +}) => { + const url = `/verification/email/${email}?code=${code}`; + + const result = await defaultInstance<{ emailToken: string }>({ + method: 'POST', + url, + }); + + return result; +}; + +const postConfirmUsernameDuplicate = async (username: string) => { + const url = `/verification/username?username=${username}`; + + const result = await defaultInstance<{ usernameToken: string }>({ + method: 'POST', + url, + }); + + return result; +}; + +const postSignupUser = async (userInfo: UserSignUpType) => { + const url = `/users`; + + const result = await defaultInstance({ + method: 'POST', + url, + data: userInfo, + }); + + return result; +}; + +export { + postConfirmCodeByEamil, + postConfirmCode, + postConfirmUsernameDuplicate, + postSignupUser, +}; + +export type { UserSignUpType }; diff --git a/src/app/(AuthLayout)/auth/github/page.tsx b/src/app/(AuthLayout)/auth/github/page.tsx index f03cbfc..f51226b 100644 --- a/src/app/(AuthLayout)/auth/github/page.tsx +++ b/src/app/(AuthLayout)/auth/github/page.tsx @@ -1,40 +1,121 @@ 'use client'; +import { useForm } from 'react-hook-form'; import { useRouter } from 'next/navigation'; -import { KeyboardEvent } from 'react'; +import { KeyboardEvent, useEffect } from 'react'; import Logo from '@/components/common/Logo'; import Field from '@/components/common/Field'; import Input from '@/components/common/Input'; import Button from '@/components/common/Button'; import validators from '@/utils/validate'; -import useSignUpState, { ISignUpFormValueType } from '@/hooks/useSignUpState'; +import useSignUpState, { + CONFIRM_STATES, + ISignUpFormValueType, +} from '@/hooks/useSignUpState'; +import useSignupMutation from '@/hooks/useSignUpMutation'; +import { postLogin } from '@/api/auth'; +import { saveAccessToken, saveRefreshToken } from '@/utils/manageToken'; import styles from './page.module.scss'; const GithubSignupPage = () => { const router = useRouter(); + const formMethods = useForm({ + mode: 'onChange', + defaultValues: { + email: '', + confirm: '', + password: '', + repassword: '', + username: '', + }, + }); + const { - checkConfirmNumber, - checkNickname, - requestConfirmNumber, - handleSubmit, + watch, register, - formState: { errors }, + resetField, + clearErrors, + handleSubmit, + formState: { errors, isValid }, + } = formMethods; + + const { + signupToken, isEmailConfirmed, isEmailPending, - isNicknameConfirmed, + isEmailRequest, + isEmailRetry, + isUsernameConfirmed, + changeConfirmState, + changeUsernameState, + changeSignupToken, } = useSignUpState(); - const onSubmit = (data: ISignUpFormValueType) => { - console.log(data); - router.push('/auth/info'); - }; + const { + isRequestEmailPending, + isRequestConfirmPending, + isDuplicateUsernamePending, + isRequestSignupPending, + requestCodeByEmail, + requestConfirmCode, + requestCheckDuplicateUsername, + requestSignupUser, + } = useSignupMutation({ + formMethods, + changeSignupToken, + changeConfirmState, + changeUsernameState, + }); const preventEnter = (e: KeyboardEvent) => { - if (e.key === 'Enter') e.preventDefault(); + if (e.key === 'Enter') { + e.preventDefault(); + } + }; + + const resetEmail = () => { + resetField('email'); + resetField('confirm'); + changeConfirmState(CONFIRM_STATES.PENDING); + }; + + const onSubmit = (values: ISignUpFormValueType) => { + const { email, password, username } = values; + + const payload = { + email, + password, + username, + ...signupToken, + }; + + requestSignupUser(payload, { + onSuccess: async (res) => { + if (res.isSuccess) { + const authResults = await postLogin({ email, password }); + const { result, isSuccess } = authResults; + + if (isSuccess && result) { + const { accessToken, refreshToken } = result; + saveAccessToken(accessToken); + saveRefreshToken(refreshToken); + router.push('/auth/info'); + } + } + }, + }); }; + const [password, repassword] = watch(['password', 'repassword']); + + useEffect(() => { + if (password !== '' && password === repassword) { + clearErrors(['password', 'repassword']); + } + }, [password, repassword, clearErrors]); + return (
@@ -43,100 +124,137 @@ const GithubSignupPage = () => {

회원가입

- - * + + * 이메일 - - + + - - - - {errors.email?.message} - + {(isEmailPending || isEmailRetry) && ( + + )} + {isEmailRequest && ( + changeConfirmState('retry')} + isConfirm={isEmailConfirmed} + timerDuration={300_000} // 5분 + disabled={isRequestEmailPending || !isEmailRetry} + /> + )} + {isEmailConfirmed && ( + + )} + + {errors.email?.message} {!isEmailPending && ( - - * + + * 인증번호 - - + + - - - {errors.confirm?.message} - + + {errors.confirm?.message} )} - - - * + + + * 닉네임 - - + + - - - {errors.nickname?.message} - + + {errors.username?.message}
- +
); diff --git a/src/app/(AuthLayout)/auth/info/page.tsx b/src/app/(AuthLayout)/auth/info/page.tsx index 5db3bd1..af546eb 100644 --- a/src/app/(AuthLayout)/auth/info/page.tsx +++ b/src/app/(AuthLayout)/auth/info/page.tsx @@ -49,10 +49,22 @@ const AdditionalInfoPage = () => { const submitHandler: SubmitHandler = (values) => { const { links, interests, myInterests } = values; + + const myInterestsName = myInterests.map(({ myInterest }) => ({ + userId: 1, + interestName: myInterest, + })); + const interestName = interests.map((interest) => ({ + userId: 1, + interestName: interest, + })); + + const interestNames = [...interestName, ...myInterestsName]; + const socialAccounts = links.map(({ link }) => link).filter((v) => !!v); + const additionalInfo = { - interests, - links: links.map(({ link }) => link).filter((v) => !!v), - myInterests: myInterests.map(({ myInterest }) => myInterest), + links: socialAccounts, + interestNames, }; console.log(additionalInfo); @@ -71,8 +83,8 @@ const AdditionalInfoPage = () => {

추가 정보

- 링크 (최대 3개) - + 링크 (최대 3개) + {linkFields.map((field, index) => ( { )} - + - - 추천 관심사 - 추천 관심사 + {RECOMMANED_INTERESTS.map((interest) => ( ))} - + - - 나만의 관심사를 추가해보세요! (최대 10개) - + 나만의 관심사를 추가해보세요! (최대 10개) {errors.myInterests && (

{errors.myInterests.message}

)} - {myInterestsFields.map((field, index) => ( @@ -124,6 +133,7 @@ const AdditionalInfoPage = () => { onRemove={removeMyInterest} /> ))} + {myInterestsFields.length !== MY_INTERESTS_FIELDS_LIMIT && ( )} - +
diff --git a/src/app/(AuthLayout)/auth/page.tsx b/src/app/(AuthLayout)/auth/page.tsx index b68ec16..579665d 100644 --- a/src/app/(AuthLayout)/auth/page.tsx +++ b/src/app/(AuthLayout)/auth/page.tsx @@ -52,7 +52,7 @@ const LoginPage = () => { maxLength={30} placeholder="이메일을 입력해주세요." errorMessage={isDirty ? errors.email?.message : undefined} - {...register('email', validator.email(true))} + {...register('email', validator.email)} /> {({ isToggle, toggleHandler }) => ( @@ -63,7 +63,7 @@ const LoginPage = () => { maxLength={20} placeholder="비밀번호를 입력해주세요." errorMessage={isDirty ? errors.password?.message : undefined} - {...register('password', validator.password())} + {...register('password', validator.password)} inputPostFix={ - - - {errors.email?.message} - + {(isEmailPending || isEmailRetry) && ( + + )} + {isEmailRequest && ( + changeConfirmState('retry')} + isConfirm={isEmailConfirmed} + timerDuration={300_000} // 5분 + disabled={isRequestEmailPending || !isEmailRetry} + /> + )} + {isEmailConfirmed && ( + + )} + + {errors.email?.message} {!isEmailPending && ( - - * + + * 인증번호 - - + + - - - {errors.confirm?.message} - + + {errors.confirm?.message} )} - - * + + * 비밀번호 - - + + { maxLength={20} state={errors.password?.message ? 'fail' : 'normal'} onKeyDown={preventEnter} - {...register('password', validators.password())} + {...register('password', validators.password)} /> - - - {errors.password?.message} - + + {errors.password?.message} - - * + + * 비밀번호 확인 - - + + { maxLength={20} state={errors.repassword?.message ? 'fail' : 'normal'} onKeyDown={preventEnter} - {...register( - 'repassword', - validators.repassword(watch('password')), - )} + {...register('repassword', validators.repassword)} /> - - - {errors.repassword?.message} - + + {errors.repassword?.message} - - * + + * 닉네임 - - + + - - - {errors.nickname?.message} - + + {errors.username?.message} - + ); diff --git a/src/components/common/Field/Field.tsx b/src/components/common/Field/Field.tsx index 4189e2c..2bccef6 100644 --- a/src/components/common/Field/Field.tsx +++ b/src/components/common/Field/Field.tsx @@ -1,25 +1,30 @@ +'use client'; + import clsx from 'clsx'; -import { PropsWithChildren } from 'react'; +import { ButtonHTMLAttributes, PropsWithChildren, useEffect } from 'react'; +import useTimer from '@/hooks/useTimer'; +import { timeFormatter } from '@/utils/formatter'; import CautionIcon from '@/assets/icons/caution.svg'; import Typo from '../Typo'; +import Button from '../Button'; import styles from './Field.module.scss'; -interface FieldProps extends PropsWithChildren { +interface ContainerProps extends PropsWithChildren { className?: string; } -const Field = ({ className, children }: FieldProps) => { +const Container = ({ className, children }: ContainerProps) => { return
{children}
; }; -interface FieldLabelProps extends PropsWithChildren { +interface LabelProps extends PropsWithChildren { htmlFor?: string; className?: string; } -const FieldLabel = ({ htmlFor, className, children }: FieldLabelProps) => { +const Label = ({ htmlFor, className, children }: LabelProps) => { return (