diff --git a/src/assets/auth_logo.svg b/src/assets/auth_logo.svg index f4dfe7bb..f3acf445 100644 --- a/src/assets/auth_logo.svg +++ b/src/assets/auth_logo.svg @@ -1,8 +1,7 @@ - - + + - diff --git a/src/components/common/ValidationInput.tsx b/src/components/common/ValidationInput.tsx index 3a4f9ba3..66ff43bc 100644 --- a/src/components/common/ValidationInput.tsx +++ b/src/components/common/ValidationInput.tsx @@ -6,7 +6,8 @@ import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri'; * ValidationInput 컴포넌트 params, 모든 params는 optional * * @params {string} [label] - 입력 필드의 label 텍스트 - * @params {boolean} [required] - 입력 필드 필수 여부 + * @params {boolean} [disabled] - 입력 필드 disabled 여부. 기본값은 'false' + * @params {boolean} [required] - 입력 필드 필수 여부. 기본값은 'true' * @params {string} [errors] - 유효성 검증 후 오류 발생 시 표시할 오류 메시지 * @params {UseFormRegisterReturn} [register] - react-hook-form의 resister 함수. * register('password', {required: ...})부분을 그대로 파라미터에 넣으면 됩니다. @@ -30,6 +31,7 @@ import { RiEyeFill, RiEyeOffFill } from 'react-icons/ri'; type ValidationInputProps = { label?: string; + disabled?: boolean; required?: boolean; errors?: string; register?: UseFormRegisterReturn; @@ -42,6 +44,7 @@ type ValidationInputProps = { export default function ValidationInput({ label, + disabled = false, required = true, errors, register, @@ -58,49 +61,42 @@ export default function ValidationInput({ }; return ( -
-
- {label && ( - - )} -
+
+ {label && ( + + )}
-
- - {type === 'password' && ( -
- {showPassword ? ( - - ) : ( - - )} -
- )} - {isButtonInput && ( - - )} -
+ + {type === 'password' && ( +
+ {showPassword ? ( + + ) : ( + + )} +
+ )} + {isButtonInput && ( + + )}
- {errors &&

{errors}

} -
+ {errors &&

{errors}

} + ); } diff --git a/src/components/user/authForm/FooterLinks.tsx b/src/components/user/auth-form/FooterLinks.tsx similarity index 100% rename from src/components/user/authForm/FooterLinks.tsx rename to src/components/user/auth-form/FooterLinks.tsx diff --git a/src/components/user/auth-form/LinkContainer.tsx b/src/components/user/auth-form/LinkContainer.tsx new file mode 100644 index 00000000..135e41d2 --- /dev/null +++ b/src/components/user/auth-form/LinkContainer.tsx @@ -0,0 +1,95 @@ +import { ChangeEvent, useState } from 'react'; +import { FaPlus, FaMinus } from 'react-icons/fa6'; +import { useFormContext } from 'react-hook-form'; +import { USER_SETTINGS } from '@constants/userSettings'; +import useToast from '@hooks/useToast'; + +type LinkContainerProps = { + initialLinks: string[]; +}; + +export default function LinkContainer({ initialLinks }: LinkContainerProps) { + const { setValue } = useFormContext(); + const [link, setLink] = useState(''); + const [links, setLinks] = useState(initialLinks); + const [isFocused, setIsFocused] = useState(false); + const { toastWarn } = useToast(); + + const handleFocus = () => setIsFocused(true); + + const handleBlur = () => setIsFocused(false); + + const handleLinkChange = (e: ChangeEvent) => setLink(e.target.value); + + const handleAddLink = (newLink: string) => { + if (newLink.trim() === '') return; + if (links.length === USER_SETTINGS.MAX_LINK_COUNT) { + setLink(''); + return toastWarn(`링크는 최대 ${USER_SETTINGS.MAX_LINK_COUNT}개까지 등록할 수 있습니다.`); + } + + const isIncludedLink = links.includes(newLink); + if (isIncludedLink) return toastWarn('이미 등록된 링크입니다.'); + + const updatedLinks = [...links, newLink.trim()]; + setLinks(updatedLinks); + setValue('links', updatedLinks); + setLink(''); + }; + + const handleRemoveLink = (removeLink: string) => { + const filteredData = links.filter((linkItem) => linkItem !== removeLink); + setLinks(filteredData); + setValue('links', filteredData); + }; + + return ( +
+ +
+ {links.map((linkItem) => ( +
+ + +
+ ))} +
+ + +
+
+
+ ); +} diff --git a/src/components/user/auth-form/ProfileImageContainer.tsx b/src/components/user/auth-form/ProfileImageContainer.tsx new file mode 100644 index 00000000..0ce7163f --- /dev/null +++ b/src/components/user/auth-form/ProfileImageContainer.tsx @@ -0,0 +1,60 @@ +import { GoPlusCircle } from 'react-icons/go'; +import { FaRegTrashCan } from 'react-icons/fa6'; +import { useFormContext } from 'react-hook-form'; +import { convertBytesToString } from '@utils/converter'; +import { USER_SETTINGS } from '@constants/userSettings'; +import useToast from '@hooks/useToast'; + +type ProfileImageContainerProps = { + imageUrl: string; + setImageUrl: React.Dispatch>; +}; + +export default function ProfileImageContainer({ imageUrl, setImageUrl }: ProfileImageContainerProps) { + const { setValue } = useFormContext(); + const { toastWarn } = useToast(); + + // 이미지 관련 코드 + const handleChangeImg = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + + if (!file) return; + if (file.size > USER_SETTINGS.MAX_IMAGE_SIZE) { + e.target.value = ''; + return toastWarn( + `최대 ${convertBytesToString(USER_SETTINGS.MAX_IMAGE_SIZE)} 이하의 이미지 파일만 업로드 가능합니다.`, + ); + } + + const image = URL.createObjectURL(file); + setImageUrl(image); + setValue('profileUrl', image); + }; + + const handleRemoveImg = () => { + setImageUrl(''); + setValue('profileUrl', ''); + }; + + return ( +
+
+ {imageUrl ? ( + <> + profileImage +
+

+ +

+
+ + ) : ( + + )} +
+
+ ); +} diff --git a/src/components/user/auth-form/VerificationButton.tsx b/src/components/user/auth-form/VerificationButton.tsx new file mode 100644 index 00000000..49e8d399 --- /dev/null +++ b/src/components/user/auth-form/VerificationButton.tsx @@ -0,0 +1,36 @@ +import Timer from '@components/common/Timer'; + +type VerificationButtonProps = { + isVerificationRequested: boolean; + isSubmitting: boolean; + requestCode: () => void; + expireVerificationCode: () => void; + buttonLabel: string; +}; + +export default function VerificationButton({ + isVerificationRequested, + isSubmitting, + requestCode, + expireVerificationCode, + buttonLabel, +}: VerificationButtonProps) { + return ( +
+ {!isVerificationRequested ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/globals.css b/src/globals.css index 42d25138..cd0b6740 100644 --- a/src/globals.css +++ b/src/globals.css @@ -113,6 +113,6 @@ } .auth-btn { - @apply flex h-30 cursor-pointer items-center justify-center rounded-lg bg-sub px-8 font-bold; + @apply flex h-25 cursor-pointer items-center justify-center rounded-lg bg-sub px-8 font-bold; } } diff --git a/src/hooks/useEmailVerification.ts b/src/hooks/useEmailVerification.ts new file mode 100644 index 00000000..58ccf0ce --- /dev/null +++ b/src/hooks/useEmailVerification.ts @@ -0,0 +1,43 @@ +import { useState } from 'react'; +import { UseFormSetError } from 'react-hook-form'; +import useToast from '@hooks/useToast'; +import type { EmailVerificationForm, UserSignUpForm } from '@/types/UserType'; + +export default function useEmailVerification() { + const [isVerificationRequested, setIsVerificationRequested] = useState(false); + const { toastSuccess, toastError } = useToast(); + + // 이메일 인증번호 요청 함수 + const requestVerificationCode = () => { + if (!isVerificationRequested) { + setIsVerificationRequested(true); + toastSuccess('인증번호가 발송되었습니다. 이메일을 확인해 주세요.'); + } + }; + + // 인증번호 확인 함수 + const verifyCode = (verificationCode: string, setError: UseFormSetError) => { + if (verificationCode === '1234') return true; + + // 인증번호 불일치 + setError('code', { + type: 'manual', + message: '인증번호가 일치하지 않습니다.', + }); + toastError('인증번호가 유효하지 않습니다. 다시 시도해 주세요.'); + return false; + }; + + // 인증 코드 만료 + const expireVerificationCode = () => { + setIsVerificationRequested(false); + toastError('인증 시간이 만료되었습니다. 다시 시도해 주세요.'); + }; + + return { + isVerificationRequested, + requestVerificationCode, + verifyCode, + expireVerificationCode, + }; +} diff --git a/src/components/user/authForm/AuthForm.tsx b/src/layouts/AuthFormLayout.tsx similarity index 60% rename from src/components/user/authForm/AuthForm.tsx rename to src/layouts/AuthFormLayout.tsx index 395f6523..2d67c45d 100644 --- a/src/components/user/authForm/AuthForm.tsx +++ b/src/layouts/AuthFormLayout.tsx @@ -1,12 +1,12 @@ import { FormEvent, ReactNode } from 'react'; -type AuthFormProps = { +type AuthFormLayoutProps = { children: ReactNode; onSubmit: (e: FormEvent) => void; marginTop: 'mt-34.9' | 'mt-40'; }; -export default function AuthForm({ children, onSubmit, marginTop }: AuthFormProps) { +export default function AuthFormLayout({ children, onSubmit, marginTop }: AuthFormLayoutProps) { return ( <>
@@ -14,7 +14,7 @@ export default function AuthForm({ children, onSubmit, marginTop }: AuthFormProp
Grow Up your Life with us.
-
+ {children}
diff --git a/src/layouts/page/AuthLayout.tsx b/src/layouts/page/AuthLayout.tsx index 7b44bc32..c562d6e6 100644 --- a/src/layouts/page/AuthLayout.tsx +++ b/src/layouts/page/AuthLayout.tsx @@ -1,5 +1,5 @@ import { Outlet } from 'react-router-dom'; -import AuthGULogo from '@/assets/auth_logo.svg'; +import AuthGULogo from '@assets/auth_logo.svg'; export default function AuthLayout() { return ( diff --git a/src/layouts/page/SettingLayout.tsx b/src/layouts/page/SettingLayout.tsx index 40f65035..350562ca 100644 --- a/src/layouts/page/SettingLayout.tsx +++ b/src/layouts/page/SettingLayout.tsx @@ -1,7 +1,7 @@ import { Outlet, useLocation } from 'react-router-dom'; import ListSidebar from '@components/sidebar/ListSidebar'; +import ListSetting from '@components/sidebar/ListSetting'; import { USER_INFO_DUMMY } from '@mocks/mockData'; -import ListSetting from '@/components/sidebar/ListSetting'; const navList = [ { @@ -13,7 +13,7 @@ const navList = [ route: 'password', }, { - label: 'My Teams', + label: '팀 관리', route: 'teams', }, ]; @@ -39,7 +39,7 @@ export default function SettingLayout() { {getTitle()} -
+
diff --git a/src/pages/setting/UserAuthenticatePage.tsx b/src/pages/setting/UserAuthenticatePage.tsx index a6132574..03eb8dc3 100644 --- a/src/pages/setting/UserAuthenticatePage.tsx +++ b/src/pages/setting/UserAuthenticatePage.tsx @@ -1,75 +1,42 @@ -import { useState } from 'react'; import { useForm } from 'react-hook-form'; -import useToast from '@/hooks/useToast'; -import ValidationInput from '@/components/common/ValidationInput'; -import { USER_AUTH_VALIDATION_RULES } from '@/constants/formValidationRules'; -import Timer from '@/components/common/Timer'; +import { USER_AUTH_VALIDATION_RULES } from '@constants/formValidationRules'; +import useEmailVerification from '@hooks/useEmailVerification'; +import ValidationInput from '@components/common/ValidationInput'; +import VerificationButton from '@components/user/auth-form/VerificationButton'; import { EmailVerificationForm } from '@/types/UserType'; -// TODO: 회원가입 폼과 겹치는 로직 컴포넌트로 분리 function UserAuthenticatePage() { - const [isVerificationRequested, setIsVerificationRequested] = useState(false); - const [isTimerVisible, setIsTimerVisible] = useState(false); - const { toastSuccess, toastError } = useToast(); + const { isVerificationRequested, requestVerificationCode, verifyCode, expireVerificationCode } = + useEmailVerification(); const { register, handleSubmit, setError, - watch, formState: { errors, isSubmitting }, + watch, } = useForm({ mode: 'onChange', }); - // 이메일 인증번호 요청 함수 - const requestCode = () => { - if (!isVerificationRequested) { - setIsVerificationRequested(true); - toastSuccess('인증번호가 발송되었습니다. 이메일을 확인해 주세요.'); - setIsTimerVisible(true); - } - }; - - // 인증번호 체크 함수 - const verifyCode = (code: string) => { - if (code === '1234') { - return true; - } - - // 인증번호 불일치 - setError('code', { - type: 'manual', - message: '인증번호가 일치하지 않습니다.', - }); - return false; - }; - - // 타이머 만료 - const handleTimerTimeout = () => { - setIsTimerVisible(false); - setIsVerificationRequested(false); - toastError('인증 시간이 만료되었습니다. 다시 시도해 주세요.'); - }; - const onSubmit = async (data: EmailVerificationForm) => { - console.log(data); - - const verifyResult = verifyCode(watch('code')); - if (!verifyResult) return toastError('인증번호가 유효하지 않습니다. 다시 시도해 주세요.'); + const verifyResult = verifyCode(watch('code'), setError); + if (!verifyResult) return; // TODO: 인증 성공 후 전역 상태관리 및 리다이렉트 로직 작성 + console.log(data); }; return ( -
+

개인정보 변경을 위한 이메일 인증 단계입니다.
인증요청 버튼 클릭 후, 이메일로 발송된 인증번호를 입력해주세요.

-
+ + {/* 이메일 */} - {!isVerificationRequested ? ( - - ) : ( - - )} -
+
diff --git a/src/pages/setting/UserSettingPage.tsx b/src/pages/setting/UserSettingPage.tsx index 34ef9eb2..a18041de 100644 --- a/src/pages/setting/UserSettingPage.tsx +++ b/src/pages/setting/UserSettingPage.tsx @@ -1,3 +1,95 @@ +import { useState } from 'react'; +import { FormProvider, useForm } from 'react-hook-form'; +import { USER_INFO_DUMMY } from '@mocks/mockData'; +import { USER_AUTH_VALIDATION_RULES } from '@constants/formValidationRules'; +import ValidationInput from '@components/common/ValidationInput'; +import ProfileImageContainer from '@/components/user/auth-form/ProfileImageContainer'; +import LinkContainer from '@/components/user/auth-form/LinkContainer'; +import type { EditUserInfoForm } from '@/types/UserType'; + export default function UserSettingPage() { - return
UserSettingPage
; + const [imageUrl, setImageUrl] = useState(USER_INFO_DUMMY.profileUrl); + const methods = useForm({ + mode: 'onChange', + defaultValues: { + id: USER_INFO_DUMMY.id, + email: USER_INFO_DUMMY.email, + nickname: USER_INFO_DUMMY.nickname, + bio: USER_INFO_DUMMY.bio, + links: USER_INFO_DUMMY.links, + profileUrl: USER_INFO_DUMMY.profileUrl, + }, + }); + + // form 전송 함수 + const onSubmit = async (data: EditUserInfoForm) => { + const { id, email, profileUrl, ...filteredData } = data; + console.log(data); + + // TODO: 폼 제출 로직 작성 + }; + + return ( + +
+
+ {/* 프로필 이미지 */} + + + {/* 아이디 */} + + + {/* 이메일 */} + + + {/* 닉네임, 중복 확인 */} + + + {/* 자기소개 */} +
+ +