diff --git a/src/user/ui-calendar/Calendar.tsx b/src/components/mypage/Calendar.tsx similarity index 98% rename from src/user/ui-calendar/Calendar.tsx rename to src/components/mypage/Calendar.tsx index 4ce6d06a..639642df 100644 --- a/src/user/ui-calendar/Calendar.tsx +++ b/src/components/mypage/Calendar.tsx @@ -3,7 +3,7 @@ import Image from 'next/image'; import { subMonths } from 'date-fns'; import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; import useCalendar from '../../hooks/useCalendar'; -import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../utill/constants'; +import { DAY_LIST, DATE_MONTH_FIXER, iconPaths } from '../../user/utill/constants'; import CalendarHeader from './CalendarHeader'; interface CalendarProps { diff --git a/src/user/ui-calendar/CalendarHeader.tsx b/src/components/mypage/CalendarHeader.tsx similarity index 98% rename from src/user/ui-calendar/CalendarHeader.tsx rename to src/components/mypage/CalendarHeader.tsx index 2c337e1c..4148b1d2 100644 --- a/src/user/ui-calendar/CalendarHeader.tsx +++ b/src/components/mypage/CalendarHeader.tsx @@ -5,7 +5,7 @@ import { EmotionTypeEN } from '@/types/emotion'; import ARROW_BOTTOM_ICON from '../../../public/icon/arrow-bottom-icon.svg'; import ARROW_RIGHT_ICON from '../../../public/icon/arrow-right-icon.svg'; import ARROW_LEFT_ICON from '../../../public/icon/arrow-left-icon.svg'; -import { iconPaths } from '../utill/constants'; +import { iconPaths } from '../../user/utill/constants'; interface CalendarHeaderProps { currentDate: Date; diff --git a/src/user/ui-chart/Chart.tsx b/src/components/mypage/Chart.tsx similarity index 98% rename from src/user/ui-chart/Chart.tsx rename to src/components/mypage/Chart.tsx index 6e89af4c..8d8a1da7 100644 --- a/src/user/ui-chart/Chart.tsx +++ b/src/components/mypage/Chart.tsx @@ -1,6 +1,6 @@ import { EmotionLog, EmotionTypeEN } from '@/types/emotion'; import Image from 'next/image'; -import { iconPaths } from '../utill/constants'; +import { iconPaths } from '../../user/utill/constants'; interface ChartProps { monthlyEmotionLogs: EmotionLog[]; diff --git a/src/user/ui-content/MyComment.tsx b/src/components/mypage/MyComment.tsx similarity index 100% rename from src/user/ui-content/MyComment.tsx rename to src/components/mypage/MyComment.tsx diff --git a/src/user/ui-content/MyEpigrams.tsx b/src/components/mypage/MyEpigrams.tsx similarity index 100% rename from src/user/ui-content/MyEpigrams.tsx rename to src/components/mypage/MyEpigrams.tsx diff --git a/src/user/ui-profile/Profile.tsx b/src/components/mypage/Profile.tsx similarity index 96% rename from src/user/ui-profile/Profile.tsx rename to src/components/mypage/Profile.tsx index 42fd5f74..7f2570b8 100644 --- a/src/user/ui-profile/Profile.tsx +++ b/src/components/mypage/Profile.tsx @@ -3,7 +3,7 @@ import { UserProfileProps } from '@/types/user'; import { useState } from 'react'; import { Button } from '@/components/ui/button'; import { Dialog, DialogTrigger, DialogContent } from '@/components/ui/dialog'; -import { sampleImage } from '../utill/constants'; +import { sampleImage } from '../../user/utill/constants'; import ProfileEdit from './ProfileEdit'; export default function Profile({ image, nickname }: UserProfileProps) { diff --git a/src/components/mypage/ProfileEdit.tsx b/src/components/mypage/ProfileEdit.tsx new file mode 100644 index 00000000..75cd8c90 --- /dev/null +++ b/src/components/mypage/ProfileEdit.tsx @@ -0,0 +1,118 @@ +import Image from 'next/image'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useRef, useState } from 'react'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { useCreatePresignedUrl, useUpdateMe } from '@/hooks/userQueryHooks'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { PatchMeRequestType, PatchMeRequest } from '@/schema/user'; +import fileNameChange from '../../user/utill/fileNameChange'; + +interface UserProfileEditProps { + initialValues: { + image: string; + nickname: string; + }; + onModalClose: () => void; +} + +export default function ProfileEdit({ initialValues, onModalClose }: UserProfileEditProps) { + // 이미지 업로드 훅 + const createPresignedUrl = useCreatePresignedUrl(); + // 닉네임 중복 처리를 위한 변수 + const [focusedField, setFocusedField] = useState(false); + + const fileInputRef = useRef(null); + + const form = useForm({ + resolver: zodResolver(PatchMeRequest), + mode: 'onBlur', + defaultValues: initialValues, + }); + + const { setValue, getValues, setFocus } = form; + + // error 반환 시 닉네임 focus를 위한 함수 + const handleFieldError = () => { + setFocus('nickname'); + setFocusedField(true); + }; + + // 회원정보 수정 훅 + const updateMe = useUpdateMe(handleFieldError, onModalClose); + + // 프로필 사진 변경 클릭 + const handleImageEditClick = () => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }; + + // 이미지 변경 시 + async function handleImageChange(e: React.ChangeEvent): Promise { + const { files } = e.currentTarget; + + if (files && files.length > 0) { + const file = files[0]; + + // 중복된 파일명 및 한글파일이 저장되지 않도록 파일이름 포멧 변경 + const newFileName = fileNameChange(); + const newFile = new File([file], `${newFileName}.${file.name.split('.').pop()}`, { type: file.type }); + + createPresignedUrl.mutate( + { image: newFile }, + { + onSuccess: (data) => { + setValue('image', data.url); + }, + }, + ); + } + } + + return ( +
+ updateMe.mutate(values))}> + + 프로필 수정 +
+
+ 유저 프로필 + handleImageChange(e)} className='hidden' ref={fileInputRef} /> +
+
+ ( + + + 닉네임 + + + setValue('nickname', e.target.value.trim())} + className={`lg:h-16 h-11 px-4 lg:text-xl md:text-base placeholder-blue-400 rounded-xl bg-blue-200 font-pretendard ${fieldState.invalid || focusedField ? 'border-2 border-state-error' : 'focus:border-blue-500'}`} + /> + + + + )} + /> +
+
+ + + +
+
+ + ); +} diff --git a/src/hooks/userQueryHooks.ts b/src/hooks/userQueryHooks.ts index 4665c85a..a8182e94 100644 --- a/src/hooks/userQueryHooks.ts +++ b/src/hooks/userQueryHooks.ts @@ -3,21 +3,57 @@ import { updateMe, createPresignedUrl } from '@/apis/user'; import { GetUserRequestType, PatchMeRequestType, PostPresignedUrlRequestType, PostPresignedUrlResponseType } from '@/schema/user'; import { MutationOptions } from '@/types/query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { isAxiosError } from 'axios'; +import { toast } from '@/components/ui/use-toast'; export const useMeQuery = () => useQuery(queries.user.getMe()); export const useUserQuery = (request: GetUserRequestType) => useQuery(queries.user.getUser(request)); -export const useUpdateMe = (options: MutationOptions) => { +export const useUpdateMe = (onRegisterError: () => void, onModalClose: () => void) => { const queryClient = useQueryClient(); return useMutation({ mutationFn: (request: PatchMeRequestType) => updateMe(request), - ...options, - onSuccess: (...arg) => { + onSuccess: () => { queryClient.invalidateQueries(queries.user.getMe()); - if (options?.onSuccess) { - options?.onSuccess(...arg); + onModalClose(); + toast({ + title: '프로필 수정 완료', + description: '프로필 수정이 완료되었습니다.', + className: 'bg-illust-green text-white font-semibold', + }); + }, + onError: (error) => { + if (!isAxiosError(error)) { + return; } + + const { status, data } = error.response || {}; + + const errorMessage = data?.message || '잘못된 요청입니다. 입력 값을 확인해주세요.'; + + // NOTE: swagger 문서에서 닉네임 관련은 400 에러로 응답 옴. + if (status === 400) { + if (errorMessage.includes('Validation Failed')) { + toast({ + description: '닉네임 입력은 필수입니다.', + className: 'bg-state-error text-white font-semibold', + }); + } else { + toast({ + description: errorMessage, + className: 'bg-state-error text-white font-semibold', + }); + } + + onRegisterError(); + return; + } + + toast({ + description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'bg-state-error text-white font-semibold', + }); }, }); }; @@ -27,7 +63,15 @@ export const useCreatePresignedUrl = (options?: MutationOptions createPresignedUrl(request), ...options, - onSuccess: (data: PostPresignedUrlResponseType) => - // 이미지 URL 반환 - data.url, + onSuccess: (data: PostPresignedUrlResponseType) => data.url, // 이미지 URL 반환 + onError: (error) => { + if (!isAxiosError(error)) { + return; + } + + toast({ + description: '알 수 없는 오류가 발생했습니다. 잠시 후 다시 시도해주세요.', + className: 'bg-state-error text-white font-semibold', + }); + }, }); diff --git a/src/pageLayout/MypageLayout/EmotionMonthlyLogs.tsx b/src/pageLayout/MypageLayout/EmotionMonthlyLogs.tsx index 4020e531..a11522fd 100644 --- a/src/pageLayout/MypageLayout/EmotionMonthlyLogs.tsx +++ b/src/pageLayout/MypageLayout/EmotionMonthlyLogs.tsx @@ -1,8 +1,8 @@ import { useMonthlyEmotionLogs } from '@/hooks/useGetEmotion'; import { Emotion } from '@/types/emotion'; import { useEffect, useState } from 'react'; -import Calendar from '../../user/ui-calendar/Calendar'; -import Chart from '../../user/ui-chart/Chart'; +import Calendar from '../../components/mypage/Calendar'; +import Chart from '../../components/mypage/Chart'; interface EmotionMonthlyLogsProps { userId: number; diff --git a/src/pageLayout/MypageLayout/MyContent.tsx b/src/pageLayout/MypageLayout/MyContent.tsx index 4b6c4ebb..a41cc2ae 100644 --- a/src/pageLayout/MypageLayout/MyContent.tsx +++ b/src/pageLayout/MypageLayout/MyContent.tsx @@ -1,13 +1,13 @@ import { useEffect, useState } from 'react'; import useGetEpigrams from '@/hooks/useGetEpigrams'; -import MyEpigrams from '@/user/ui-content/MyEpigrams'; +import MyEpigrams from '@/components/mypage/MyEpigrams'; import Image from 'next/image'; import { useToast } from '@/components/ui/use-toast'; import { EpigramsResponse } from '@/types/epigram.types'; import { CommentResponseType } from '@/schema/comment'; import useCommentsHook from '@/hooks/useCommentsHook'; import useGetMyContentHook from '@/hooks/useGetMyContentHook'; -import MyComment from '@/user/ui-content/MyComment'; +import MyComment from '@/components/mypage/MyComment'; import UserInfo from '@/types/user'; import useDeleteCommentMutation from '@/hooks/useDeleteCommentHook'; import { Button } from '@/components/ui/button'; diff --git a/src/pageLayout/MypageLayout/MyPageLayout.tsx b/src/pageLayout/MypageLayout/MyPageLayout.tsx index 37fae1ae..7c12d158 100644 --- a/src/pageLayout/MypageLayout/MyPageLayout.tsx +++ b/src/pageLayout/MypageLayout/MyPageLayout.tsx @@ -2,7 +2,7 @@ import NewHeader from '@/components/Header/NewHeader'; import { useMeQuery } from '@/hooks/userQueryHooks'; import UserInfo from '@/types/user'; import EmotionMonthlyLogs from '@/pageLayout/MypageLayout/EmotionMonthlyLogs'; -import Profile from '@/user/ui-profile/Profile'; +import Profile from '@/components/mypage/Profile'; import { useRouter } from 'next/navigation'; import TodayEmotion from '@/components/main/TodayEmotion'; import MyContent from './MyContent'; diff --git a/src/schema/user.ts b/src/schema/user.ts index 45565a5e..8e4d24bc 100644 --- a/src/schema/user.ts +++ b/src/schema/user.ts @@ -3,7 +3,7 @@ import { MAX_FILE_SIZE, ACCEPTED_IMAGE_TYPES } from '@/user/utill/constants'; export const PatchMeRequest = z.object({ image: z.string().url(), - nickname: z.string(), + nickname: z.string().min(1, { message: '닉네임은 필수 입력입니다.' }).max(20, { message: '닉네임은 최대 20자까지 가능합니다.' }), }); export const GetUserRequest = z.object({ diff --git a/src/user/ui-profile/ProfileEdit.tsx b/src/user/ui-profile/ProfileEdit.tsx deleted file mode 100644 index 218f2c7a..00000000 --- a/src/user/ui-profile/ProfileEdit.tsx +++ /dev/null @@ -1,140 +0,0 @@ -import Image from 'next/image'; -import { UserProfileProps } from '@/types/user'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import Label from '@/components/ui/label'; -import { DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; -import { useToast } from '@/components/ui/use-toast'; -import { useEffect, useRef } from 'react'; -import { Form, Formik, useFormik } from 'formik'; -import { useCreatePresignedUrl, useUpdateMe } from '@/hooks/userQueryHooks'; -import * as Yup from 'yup'; -import { AxiosError } from 'axios'; -import fileNameChange from '../utill/fileNameChange'; - -interface UserProfileEditProps { - initialValues: { - image: string; - nickname: string; - }; - onModalClose: () => void; -} - -const validationSchema = Yup.object().shape({ - nickname: Yup.string().min(1, '닉네임은 1자 이상 30자 이하여야 합니다.').max(30, '닉네임은 1자 이상 30자 이하여야 합니다.').required('닉네임은 필수 항목입니다.'), -}); - -export default function ProfileEdit({ initialValues, onModalClose }: UserProfileEditProps) { - const createPresignedUrl = useCreatePresignedUrl(); - const fileInputRef = useRef(null); - - const { toast } = useToast(); - - const handleSubmit = async () => { - await formik.submitForm(); // Formik의 submitForm 함수 호출 - }; - - const { mutate: updateMe } = useUpdateMe({ - onSuccess: () => { - onModalClose(); - toast({ - description: '프로필 수정이 완료되었습니다.', - }); - }, - onError: () => { - toast({ - description: '프로필 수정 실패', - }); - }, - }); - - const formik = useFormik({ - initialValues: { - image: '', - nickname: '', - }, - validationSchema, - onSubmit: async (values, { setSubmitting }) => { - try { - // 프로필 업데이트 - await updateProfile(values); - setSubmitting(false); - } catch (error) { - // 에러 처리 - } finally { - setSubmitting(false); - } - }, - }); - - const updateProfile = (values: UserProfileProps) => { - updateMe(values); - }; - - // 프로필 사진 변경 클릭 - const handleImageEditClick = () => { - if (fileInputRef.current) { - fileInputRef.current.click(); - } - }; - - // 이미지 변경 시 - async function handleImageChange(e: React.ChangeEvent): Promise { - const { files } = e.currentTarget; - if (files && files.length > 0) { - const file = files[0]; - - try { - // 중복된 파일명 및 한글파일이 저장되지 않도록 파일이름 포멧 변경 - const newFileName = fileNameChange(); - const newFile = new File([file], `${newFileName}.${file.name.split('.').pop()}`, { type: file.type }); - - // presignedUrl 구하는 함수 (s3 업로드까지 같이) - const { url } = await createPresignedUrl.mutateAsync({ image: newFile }); - formik.setFieldValue('image', url); - } catch (error) { - // 에러 처리: 실패 시 토스트 메시지 - const axiosError = error as AxiosError; - - onModalClose(); - const errorMessage = `(error: ${axiosError.response?.status}) 잘못 된 요청입니다. 관리자에게 문의해주세요`; - - toast({ - description: errorMessage, - className: 'bg-red-400 text-white', - }); - } - } - } - - useEffect(() => { - formik.setValues(initialValues); - }, [initialValues]); - - return ( - - {({ isSubmitting }) => ( -
- - 프로필 수정 -
-
- 유저 프로필 - handleImageChange(e)} className='hidden' ref={fileInputRef} /> -
-
- - -
-
- - - -
-
- )} -
- ); -}