diff --git a/src/app/login/_temp/login-temp-actions.tsx b/src/app/login/_temp/login-temp-actions.tsx index 4805be3e..e210e745 100644 --- a/src/app/login/_temp/login-temp-actions.tsx +++ b/src/app/login/_temp/login-temp-actions.tsx @@ -1,18 +1,16 @@ 'use client'; import { MyPageActionButton } from '@/components/pages/user/mypage/mypage-setting-button'; -import { useLogout, useRefresh, useWithdraw } from '@/hooks/use-auth'; +import { useLogout, useWithdraw } from '@/hooks/use-auth'; const LoginTempActions = () => { const logout = useLogout(); const withdraw = useWithdraw(); - const refresh = useRefresh(); return (
로그아웃 회원탈퇴 - refresh
); }; diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 393549aa..634d56f0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,7 +1,7 @@ import { cookies } from 'next/headers'; import { Icon } from '@/components/icon'; -import { LoginForm, LoginToastEffect } from '@/components/pages/login'; +import { LoginForm, LoginToastEffect } from '@/components/pages/auth'; import { AuthSwitch } from '@/components/shared'; import LoginTempActions from './_temp/login-temp-actions'; diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx index 20aca9bb..4c1cd862 100644 --- a/src/app/signup/page.tsx +++ b/src/app/signup/page.tsx @@ -1,5 +1,5 @@ import { Icon } from '@/components/icon'; -import { SignupForm } from '@/components/pages/signup'; +import { SignupForm } from '@/components/pages/auth'; import { AuthSwitch } from '@/components/shared'; const SignupPage = () => { diff --git a/src/components/pages/auth/auth-button/index.tsx b/src/components/pages/auth/auth-button/index.tsx new file mode 100644 index 00000000..1d730e47 --- /dev/null +++ b/src/components/pages/auth/auth-button/index.tsx @@ -0,0 +1,23 @@ +import { type AnyFormState } from '@tanstack/react-form'; + +import { Button } from '@/components/ui'; + +interface Props { + state: AnyFormState; + type: 'login' | 'signup'; + canSubmitAll?: boolean; +} + +export const AuthSubmitButton = ({ state, type, canSubmitAll = true }: Props) => { + const { canSubmit, isSubmitting, isPristine } = state; + + const disabled = !canSubmit || isSubmitting || isPristine || !canSubmitAll; + + const buttonName = type === 'login' ? '로그인하기' : '회원가입하기'; + + return ( + + ); +}; diff --git a/src/components/pages/auth/fields/email-field/index.tsx b/src/components/pages/auth/fields/email-field/index.tsx new file mode 100644 index 00000000..59cada87 --- /dev/null +++ b/src/components/pages/auth/fields/email-field/index.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { type AnyFieldApi } from '@tanstack/react-form'; + +import { FormInput } from '@/components/shared'; +import { getHintMessage } from '@/lib/auth/utils'; +import { cn } from '@/lib/utils'; + +interface AvailabilityButtonProps { + onClick: () => void; + disabled: boolean; +} + +type EmailCheck = { + hint?: string; + state: { status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error' }; + isChecking: boolean; + check: (value: string) => void | Promise; + reset: () => void; +}; + +interface Props { + field: AnyFieldApi; + withAvailabilityCheck?: boolean; + emailCheck?: EmailCheck; +} + +const AvailabilityButton = ({ onClick, disabled }: AvailabilityButtonProps) => { + return ( + + ); +}; + +export const EmailField = ({ field, withAvailabilityCheck = false, emailCheck }: Props) => { + const hintMessage = getHintMessage(field); + + if (!withAvailabilityCheck || !emailCheck) { + return ( + field.handleChange(e.target.value), + }} + labelName='이메일' + /> + ); + } + + const trimmedValue = field.state.value.trim(); + const hasValidationError = field.state.meta.errors.length > 0; + + const availabilityButtonDisabled = !trimmedValue || hasValidationError || emailCheck.isChecking; + + const iconButton = ( + { + void emailCheck.check(trimmedValue); + }} + /> + ); + + return ( + { + field.handleChange(e.target.value); + emailCheck.reset(); + }, + }} + labelName='이메일' + /> + ); +}; diff --git a/src/components/pages/auth/fields/index.ts b/src/components/pages/auth/fields/index.ts new file mode 100644 index 00000000..23ab7920 --- /dev/null +++ b/src/components/pages/auth/fields/index.ts @@ -0,0 +1,4 @@ +export { EmailField } from './email-field'; +export { NicknameField } from './nickname-field'; +export { PasswordField } from './password-field'; +export { TermsAgreementField } from './termsAgreement-field'; diff --git a/src/components/pages/auth/fields/nickname-field/index.tsx b/src/components/pages/auth/fields/nickname-field/index.tsx new file mode 100644 index 00000000..f0d1c438 --- /dev/null +++ b/src/components/pages/auth/fields/nickname-field/index.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { type AnyFieldApi } from '@tanstack/react-form'; + +import { FormInput } from '@/components/shared'; +import { getHintMessage } from '@/lib/auth/utils'; +import { cn } from '@/lib/utils'; + +interface AvailabilityButtonProps { + onClick: () => void; + disabled: boolean; +} + +type NicknameCheck = { + hint?: string; + state: { status: 'idle' | 'checking' | 'available' | 'unavailable' | 'error' }; + isChecking: boolean; + check: (value: string) => void | Promise; + reset: () => void; +}; + +interface Props { + field: AnyFieldApi; + nicknameCheck: NicknameCheck; +} + +const AvailabilityButton = ({ onClick, disabled }: AvailabilityButtonProps) => { + return ( + + ); +}; + +export const NicknameField = ({ field, nicknameCheck }: Props) => { + const hintMessage = getHintMessage(field); + + const trimmedValue = field.state.value.trim(); + const hasValidationError = field.state.meta.errors.length > 0; + + const availabilityButtonDisabled = + !trimmedValue || hasValidationError || nicknameCheck.isChecking; + + const iconButton = ( + { + void nicknameCheck.check(trimmedValue); + }} + /> + ); + + return ( + { + field.handleChange(e.target.value); + nicknameCheck.reset(); + }, + }} + labelName='닉네임' + /> + ); +}; diff --git a/src/components/pages/auth/fields/password-field/index.tsx b/src/components/pages/auth/fields/password-field/index.tsx new file mode 100644 index 00000000..be014cf0 --- /dev/null +++ b/src/components/pages/auth/fields/password-field/index.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useState } from 'react'; + +import { type AnyFieldApi } from '@tanstack/react-form'; + +import { Icon } from '@/components/icon'; +import { FormInput } from '@/components/shared'; +import { getHintMessage } from '@/lib/auth/utils'; + +interface PasswordToggleButtonProps { + isVisible: boolean; + onToggle: () => void; +} + +interface Props { + field: AnyFieldApi; + passwordType: 'loginPassword' | 'signupPassword' | 'confirmPassword'; +} + +const PasswordToggleButton = ({ isVisible, onToggle }: PasswordToggleButtonProps) => { + return ( + + ); +}; + +export const PasswordField = ({ field, passwordType }: Props) => { + const [isVisible, setIsVisible] = useState(false); + + const hintMessage = getHintMessage(field); + + const isConfirmPassword = passwordType === 'confirmPassword'; + const isLogin = passwordType === 'loginPassword'; + + const handleToggle = () => { + setIsVisible((prev) => !prev); + }; + + const iconButton = ; + + return ( + field.handleChange(e.target.value), + onBlur: () => field.handleBlur(), + }} + labelName={!isConfirmPassword ? '비밀번호' : '비밀번호 확인'} + /> + ); +}; diff --git a/src/components/pages/auth/fields/termsAgreement-field/index.tsx b/src/components/pages/auth/fields/termsAgreement-field/index.tsx new file mode 100644 index 00000000..c17c8eb0 --- /dev/null +++ b/src/components/pages/auth/fields/termsAgreement-field/index.tsx @@ -0,0 +1,42 @@ +'use client'; + +import { type AnyFieldApi } from '@tanstack/react-form'; + +import { Icon } from '@/components/icon'; +import { useModal } from '@/components/ui'; +import { cn } from '@/lib/utils'; + +import { SignupAgreementModal } from '../../signup/signup-agreement-modal'; + +interface Props { + field: AnyFieldApi; +} + +export const TermsAgreementField = ({ field }: Props) => { + const { open } = useModal(); + const checked = Boolean(field.state.value); + + return ( +
+ + + +
+ ); +}; diff --git a/src/components/pages/auth/index.ts b/src/components/pages/auth/index.ts new file mode 100644 index 00000000..3d1e49f7 --- /dev/null +++ b/src/components/pages/auth/index.ts @@ -0,0 +1,3 @@ +export { LoginForm } from './login/login-form'; +export { LoginToastEffect } from './login/login-toast-effect'; +export { SignupForm } from './signup/signup-form'; diff --git a/src/components/pages/auth/login/login-form/index.tsx b/src/components/pages/auth/login/login-form/index.tsx new file mode 100644 index 00000000..538cb80c --- /dev/null +++ b/src/components/pages/auth/login/login-form/index.tsx @@ -0,0 +1,66 @@ +'use client'; + +import { useEffect } from 'react'; + +import { useForm, useStore } from '@tanstack/react-form'; + +import { EmailField, PasswordField } from '@/components/pages/auth/fields'; +import { useLogin } from '@/hooks/use-auth'; +import { loginSchema } from '@/lib/schema/auth'; + +import { AuthSubmitButton } from '../../auth-button'; + +export const LoginForm = () => { + const { handleLogin, loginError, clearLoginError } = useLogin(); + + const form = useForm({ + defaultValues: { + email: '', + password: '', + }, + validators: { + onSubmit: loginSchema, + onChange: loginSchema, + }, + onSubmit: async ({ value, formApi }) => { + const payload = { + email: value.email, + password: value.password, + }; + + await handleLogin(payload, formApi); + }, + }); + + const { email, password } = useStore(form.baseStore, (state) => state.values); + + useEffect(() => { + clearLoginError(); + }, [email, password, clearLoginError]); + + return ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > +
+ } name='email' /> + } + name='password' + /> +
+ +
+ } + selector={(state) => state} + /> + {loginError &&

{loginError}

} +
+
+ ); +}; diff --git a/src/components/pages/login/login-toast-effect/index.tsx b/src/components/pages/auth/login/login-toast-effect/index.tsx similarity index 100% rename from src/components/pages/login/login-toast-effect/index.tsx rename to src/components/pages/auth/login/login-toast-effect/index.tsx diff --git a/src/components/pages/signup/signup-agreement-modal/index.tsx b/src/components/pages/auth/signup/signup-agreement-modal/index.tsx similarity index 100% rename from src/components/pages/signup/signup-agreement-modal/index.tsx rename to src/components/pages/auth/signup/signup-agreement-modal/index.tsx diff --git a/src/components/pages/auth/signup/signup-form/index.tsx b/src/components/pages/auth/signup/signup-form/index.tsx new file mode 100644 index 00000000..cb0c6cb2 --- /dev/null +++ b/src/components/pages/auth/signup/signup-form/index.tsx @@ -0,0 +1,108 @@ +'use client'; + +import { useForm } from '@tanstack/react-form'; + +import { API } from '@/api'; +import { useSignup } from '@/hooks/use-auth'; +import { useAvailabilityCheck } from '@/hooks/use-auth/use-auth-availabilityCheck'; +import { signupSchema } from '@/lib/schema/auth'; + +import { AuthSubmitButton } from '../../auth-button'; +import { EmailField, NicknameField, PasswordField, TermsAgreementField } from '../../fields'; + +export const SignupForm = () => { + const signup = useSignup(); + + const form = useForm({ + defaultValues: { + email: '', + nickname: '', + password: '', + confirmPassword: '', + termsAgreement: false, + }, + validators: { + onChange: signupSchema, + onSubmit: signupSchema, + }, + onSubmit: async ({ value, formApi }) => { + const payload = { + email: value.email, + password: value.password, + nickName: value.nickname, + }; + + await signup(payload, formApi); + }, + }); + + const emailCheck = useAvailabilityCheck( + API.userService.getEmailAvailability, + (email) => ({ email }), + { + checking: '확인 중...', + available: '사용 가능한 이메일입니다.', + unavailable: '이미 사용 중인 이메일입니다.', + error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + ); + + const nicknameCheck = useAvailabilityCheck( + API.userService.getNicknameAvailability, + (nickName) => ({ nickName }), + { + checking: '확인 중...', + available: '사용 가능한 닉네임입니다.', + unavailable: '이미 사용 중인 닉네임입니다.', + error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + }, + ); + + const canSubmitAll = emailCheck.isAvailable && nicknameCheck.isAvailable; + + return ( +
{ + e.preventDefault(); + void form.handleSubmit(); + }} + > +
+ ( + + )} + name='email' + /> + } + name='nickname' + /> + + } + name='password' + /> + } + name='confirmPassword' + /> +
+ +
+ } + name='termsAgreement' + /> + + ( + + )} + selector={(state) => state} + /> +
+
+ ); +}; diff --git a/src/components/pages/login/index.ts b/src/components/pages/login/index.ts deleted file mode 100644 index bc0bcb1b..00000000 --- a/src/components/pages/login/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { LoginForm } from './login-form'; -export { LoginToastEffect } from './login-toast-effect'; diff --git a/src/components/pages/login/login-form/index.tsx b/src/components/pages/login/login-form/index.tsx deleted file mode 100644 index b2783ff0..00000000 --- a/src/components/pages/login/login-form/index.tsx +++ /dev/null @@ -1,113 +0,0 @@ -'use client'; - -import { type AnyFieldApi, useForm } from '@tanstack/react-form'; - -import { FormInput } from '@/components/shared'; -import { Button } from '@/components/ui'; -import { useLogin } from '@/hooks/use-auth'; -import { loginSchema } from '@/lib/schema/auth'; - -const getHintMessage = (field: AnyFieldApi) => { - const { - meta: { errors, isTouched }, - } = field.state; - const { submissionAttempts } = field.form.state; - - const firstError = errors[0] as { message?: string } | undefined; - const showError = isTouched || submissionAttempts > 0; - - return showError ? firstError?.message : undefined; -}; - -export const LoginForm = () => { - const login = useLogin(); - - const form = useForm({ - defaultValues: { - email: '', - password: '', - }, - validators: { - onSubmit: loginSchema, - onChange: loginSchema, - }, - onSubmit: async ({ value, formApi }) => { - const payload = { - email: value.email, - password: value.password, - }; - - await login(payload, formApi); - }, - }); - - return ( -
{ - e.preventDefault(); - void form.handleSubmit(); - }} - > -
- - {(field) => { - const hintMessage = getHintMessage(field); - - return ( - field.handleChange(e.target.value), - }} - labelName='이메일' - /> - ); - }} - - - - {(field) => { - const hintMessage = getHintMessage(field); - - return ( - field.handleChange(e.target.value), - }} - labelName='비밀번호' - /> - ); - }} - -
- - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - isPristine: state.isPristine, - })} - > - {({ canSubmit, isSubmitting, isPristine }) => { - const disabled = !canSubmit || isSubmitting || isPristine; - - return ( - - ); - }} - -
- ); -}; diff --git a/src/components/pages/signup/index.ts b/src/components/pages/signup/index.ts deleted file mode 100644 index 93e783fa..00000000 --- a/src/components/pages/signup/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SignupForm } from './signup-form'; diff --git a/src/components/pages/signup/signup-form/index.tsx b/src/components/pages/signup/signup-form/index.tsx deleted file mode 100644 index bd2bbdc4..00000000 --- a/src/components/pages/signup/signup-form/index.tsx +++ /dev/null @@ -1,252 +0,0 @@ -'use client'; - -import { type AnyFieldApi, useForm } from '@tanstack/react-form'; - -import { API } from '@/api'; -import { Icon } from '@/components/icon'; -import { FormInput } from '@/components/shared'; -import { Button, useModal } from '@/components/ui'; -import { useSignup } from '@/hooks/use-auth'; -import { useAvailabilityCheck } from '@/hooks/use-auth/use-auth-availabilityCheck'; -import { signupSchema } from '@/lib/schema/auth'; -import { cn } from '@/lib/utils'; - -import { SignupAgreementModal } from '../signup-agreement-modal'; - -const getHintMessage = (field: AnyFieldApi) => { - const { - meta: { errors, isTouched }, - } = field.state; - const { submissionAttempts } = field.form.state; - - const firstError = errors[0] as { message?: string } | undefined; - const showError = isTouched || submissionAttempts > 0; - - return showError ? firstError?.message : undefined; -}; - -export const SignupForm = () => { - const signup = useSignup(); - const { open } = useModal(); - - const form = useForm({ - defaultValues: { - email: '', - nickname: '', - password: '', - confirmPassword: '', - termsAgreement: false, - }, - validators: { - onChange: signupSchema, - onSubmit: signupSchema, - }, - onSubmit: async ({ value, formApi }) => { - const payload = { - email: value.email, - password: value.password, - nickName: value.nickname, - }; - - await signup(payload, formApi); - }, - }); - - const emailCheck = useAvailabilityCheck( - API.userService.getEmailAvailability, - (email) => ({ email }), - { - checking: '확인 중...', - available: '사용 가능한 이메일입니다.', - unavailable: '이미 사용 중인 이메일입니다.', - error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - }, - ); - - const nicknameCheck = useAvailabilityCheck( - API.userService.getNicknameAvailability, - (nickName) => ({ nickName }), - { - checking: '확인 중...', - available: '사용 가능한 닉네임입니다.', - unavailable: '이미 사용 중인 닉네임입니다.', - error: '중복 확인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', - }, - ); - - const canSubmit = emailCheck.isAvailable && nicknameCheck.isAvailable; - - return ( -
{ - e.preventDefault(); - void form.handleSubmit(); - }} - > -
- - {(field) => { - const validationHint = getHintMessage(field); - - const trimmedValue = field.state.value.trim(); - const hasValidationError = field.state.meta.errors.length > 0; - - const availabilityButtonDisabled = - !trimmedValue || hasValidationError || emailCheck.isChecking; - - return ( - { - field.handleChange(e.target.value); - emailCheck.reset(); - }, - }} - labelName='이메일' - onClick={() => { - void emailCheck.check(trimmedValue); - console.log(nicknameCheck.state); - }} - /> - ); - }} - - - - {(field) => { - const validationHint = getHintMessage(field); - - const trimmedValue = field.state.value.trim(); - const hasValidationError = field.state.meta.errors.length > 0; - - const availabilityButtonDisabled = - !trimmedValue || hasValidationError || nicknameCheck.isChecking; - - return ( - { - field.handleChange(e.target.value); - nicknameCheck.reset(); - }, - }} - labelName='닉네임' - onClick={() => { - nicknameCheck.check(trimmedValue); - console.log(nicknameCheck.state); - }} - /> - ); - }} - - - - {(field) => { - const hintMessage = getHintMessage(field); - - return ( - field.handleChange(e.target.value), - }} - labelName='비밀번호' - /> - ); - }} - - - - {(field) => { - const hintMessage = getHintMessage(field); - - return ( - field.handleChange(e.target.value), - }} - labelName='비밀번호 확인' - /> - ); - }} - -
- -
- - {(field) => { - const checked = Boolean(field.state.value); - return ( -
- - - -
- ); - }} -
- - ({ - canSubmit: state.canSubmit, - isSubmitting: state.isSubmitting, - isPristine: state.isPristine, - })} - > - {({ canSubmit: formCanSubmit, isSubmitting, isPristine }) => { - const disabled = !formCanSubmit || isSubmitting || isPristine || !canSubmit; - - return ( - - ); - }} - -
-
- ); -}; diff --git a/src/components/pages/user/mypage/mypage-setting/index.tsx b/src/components/pages/user/mypage/mypage-setting/index.tsx index a23e2384..f61ca9fc 100644 --- a/src/components/pages/user/mypage/mypage-setting/index.tsx +++ b/src/components/pages/user/mypage/mypage-setting/index.tsx @@ -1,4 +1,5 @@ 'use client'; +import { useLogout, useWithdraw } from '@/hooks/use-auth'; import { useUpdateMyNotification } from '@/hooks/use-user/use-user-notification'; import { User } from '@/types/service/user'; @@ -10,6 +11,8 @@ interface Props { export const MyPageSetting = ({ user }: Props) => { const { mutate } = useUpdateMyNotification(); + const logout = useLogout(); + const withdraw = useWithdraw(); return (
@@ -19,8 +22,8 @@ export const MyPageSetting = ({ user }: Props) => { > 알림 받기 - console.log('로그아웃')}>로그아웃 - console.log('회원탈퇴')}>회원탈퇴 + 로그아웃 + 회원탈퇴
); }; diff --git a/src/components/shared/form-input/index.tsx b/src/components/shared/form-input/index.tsx index 26de7bef..2b3d991c 100644 --- a/src/components/shared/form-input/index.tsx +++ b/src/components/shared/form-input/index.tsx @@ -1,21 +1,13 @@ 'use client'; -import { InputHTMLAttributes, useId, useState } from 'react'; +import { InputHTMLAttributes, useId } from 'react'; -import { Icon } from '@/components/icon'; import { Hint, Input, Label } from '@/components/ui'; import { cn } from '@/lib/utils'; -interface PasswordToggleButtonProps { - isVisible: boolean; - onToggle: () => void; -} - -interface AvailabilityButtonProps { - isEmailField: boolean; - onClick: () => void; - disabled: boolean; -} +type InputPropsWithIcon = InputHTMLAttributes & { + iconButton?: React.ReactNode; +}; interface FormInputProps { className?: string; @@ -25,7 +17,7 @@ interface FormInputProps { availabilityButtonDisabled?: boolean; availabilityStatus?: AvailabilityState; required?: boolean; - inputProps?: InputHTMLAttributes; + inputProps?: InputPropsWithIcon; onClick?: () => void; } @@ -36,59 +28,20 @@ type AvailabilityState = | { status: 'unavailable' } | { status: 'error' }; -const PasswordToggleButton = ({ isVisible, onToggle }: PasswordToggleButtonProps) => { - return ( - - ); -}; - -const AvailabilityButton = ({ isEmailField, onClick, disabled }: AvailabilityButtonProps) => { - return ( - - ); -}; - export const FormInput = ({ className, labelName, hintMessage, availabilityHint, - availabilityButtonDisabled = false, availabilityStatus, required = true, inputProps = {}, - onClick, }: FormInputProps) => { - const { type = 'text', id, required: _, ...restInputProps } = inputProps; + const { type, id, required: _, iconButton, ...restInputProps } = inputProps; const generatedId = useId(); - const [isVisible, setIsVisible] = useState(false); - const isEmailField = type === 'email'; - const isPasswordField = type === 'password'; - const inputType = isPasswordField && isVisible ? 'text' : type; const inputId = id ?? generatedId; - const isAvailability = !isPasswordField && typeof onClick === 'function'; let tone: 'default' | 'error' | 'success' = 'default'; @@ -100,26 +53,6 @@ export const FormInput = ({ tone = 'success'; } - const handleToggle = () => { - setIsVisible((prev) => !prev); - }; - - let iconButton: React.ReactNode = null; - - if (isPasswordField) { - iconButton = ; - } - - if (!isPasswordField && isAvailability) { - iconButton = ( - - ); - } - return (