diff --git a/src/apis/users/queries.ts b/src/apis/users/queries.ts index 1a17f90..e14c41e 100644 --- a/src/apis/users/queries.ts +++ b/src/apis/users/queries.ts @@ -1,5 +1,6 @@ -import { useQuery } from '@tanstack/react-query'; -import { getUser } from '.'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { getUser, updateUser } from '.'; +import { UpdateUserForm } from './types'; export const useGetUser = () => { return useQuery({ @@ -7,3 +8,16 @@ export const useGetUser = () => { queryFn: () => getUser(), }); }; + +export const useUpdateUser = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: (params: UpdateUserForm) => { + return updateUser(params); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['user'] }); + }, + }); +}; diff --git a/src/apis/users/types.ts b/src/apis/users/types.ts index 254bfb3..45c009f 100644 --- a/src/apis/users/types.ts +++ b/src/apis/users/types.ts @@ -42,20 +42,19 @@ export type SignupFailResponse = FailResponse; export type SignupResponse = Promise; -type ProfileImageUrl = string | URL | null; +const profileImageUrlSchema = z + .instanceof(File) + .refine((file) => ['image/jpeg', 'image/jpg', 'image/png', 'image/ico'].includes(file.type), { + message: PROFILEEDIT_FORM_ERROR_MESSAGE.IMAGE.TYPE, + }) + .refine((file) => file.size < 2 * 1024 * 1024, { message: `2${PROFILEEDIT_FORM_ERROR_MESSAGE.IMAGE.SIZE}` }); export const updateUserFormSchema = z.object({ nickname: z.string().max(PROFILEEDIT_FORM_VALID_LENGTH.NICKNAME.MAX, PROFILEEDIT_FORM_ERROR_MESSAGE.NICKNAME.MAX), - profileImageUrl: z.string(), + profileImageUrl: z.union([z.string().url(), profileImageUrlSchema]).optional().nullable(), }); -export type UpdateUserForm = Omit, 'profileImageUrl'> & { - profileImageUrl: ProfileImageUrl; -}; - -const profileImageUrlSchema = z.instanceof(File).refine((file) => ['image/jpeg', 'image/jpg', 'image/png', 'image/svg+xml', 'image/ico'].includes(file.type), { - message: '지원되지 않는 이미지 파일입니다.', -}); +export type UpdateUserForm = z.infer; export const createProfileImageFormSchema = z.object({ image: profileImageUrlSchema, @@ -66,7 +65,7 @@ export interface CreateProfileImageForm { } export const profileImageUrlResponseSchema = z.object({ - profileImageUrl: z.union([z.string(), z.instanceof(URL)]), + profileImageUrl: z.string().url(), }); export type ProfileImageUrlResponse = z.infer; diff --git a/src/app/(after-login)/mypage/loading.tsx b/src/app/(after-login)/mypage/loading.tsx new file mode 100644 index 0000000..85131a7 --- /dev/null +++ b/src/app/(after-login)/mypage/loading.tsx @@ -0,0 +1,7 @@ +export default function loading() { + return ( +
+
내정보를 불러오는 중입니다.
+
+ ); +} diff --git a/src/app/(after-login)/mypage/page.tsx b/src/app/(after-login)/mypage/page.tsx index 514f704..50d79b1 100644 --- a/src/app/(after-login)/mypage/page.tsx +++ b/src/app/(after-login)/mypage/page.tsx @@ -1,14 +1,31 @@ +import { redirect } from 'next/navigation'; +import { User, userSchema } from '@/apis/users/types'; import PasswordEdit from '@/components/profile/PasswordEdit'; import ProfileEdit from '@/components/profile/ProfileEdit'; +import GoBackLink from '@/components/ui/Link/GoBackLink'; +import axiosServerHelper from '@/utils/network/axiosServerHelper'; +import { safeResponse } from '@/utils/network/safeResponse'; -export default function MyPage() { - return ( -
- {/* TODO: 돌아가기 컴포넌트 연동 */} +export default async function MyPage() { + const response = await axiosServerHelper('/users/me'); + const userData = safeResponse(response.data, userSchema); + + if (!userData) { + redirect('/login'); + } - + return ( +
+
+ +
+
+ {/* 프로필 수정 */} + - + {/* 비밀번호 수정 */} + +
); } diff --git a/src/components/dashboard-header/Profile.tsx b/src/components/dashboard-header/Profile.tsx index 9bea245..17f658f 100644 --- a/src/components/dashboard-header/Profile.tsx +++ b/src/components/dashboard-header/Profile.tsx @@ -45,7 +45,7 @@ export default function Profile() { ) : ( data && (
setIsMenuOpen((prev) => !prev)}> - + {data.nickname} {isMenuOpen && ( diff --git a/src/components/profile/PasswordEdit.tsx b/src/components/profile/PasswordEdit.tsx index e23a2b2..d72db80 100644 --- a/src/components/profile/PasswordEdit.tsx +++ b/src/components/profile/PasswordEdit.tsx @@ -1,22 +1,24 @@ 'use client'; +import { useRouter } from 'next/navigation'; +import { useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; -import { Input } from '@/components/ui/Field/Input'; import { zodResolver } from '@hookform/resolvers/zod'; +import { isAxiosError } from 'axios'; +import { Input } from '@/components/ui/Field/Input'; import { passwordSchema, PutPasswordFormData } from '@/apis/auth/types'; import { logout, putPassword } from '@/apis/auth'; import useAlert from '@/hooks/useAlert'; -import { isError } from 'es-toolkit/compat'; -import { isAxiosError } from 'axios'; -import SubmitButton from '@/components/auth/SubmitButton'; -import { useRouter } from 'next/navigation'; +import { Card, CardTitle } from '@/components//ui/Card/Card'; +import Button from '@/components/ui/Button/Button'; +import { getErrorMessage } from '@/utils/errorMessage'; export default function PasswordEdit() { const { register, handleSubmit, reset, - formState: { errors, isValid, isSubmitting }, + formState: { errors, isValid, isDirty, isSubmitting }, } = useForm({ resolver: zodResolver(passwordSchema), mode: 'onBlur', @@ -29,6 +31,7 @@ export default function PasswordEdit() { const alert = useAlert(); const router = useRouter(); + const queryClient = useQueryClient(); const onSubmit = async (putPasswordFormData: PutPasswordFormData) => { try { @@ -36,28 +39,52 @@ export default function PasswordEdit() { alert('비밀번호가 변경되었습니다!'); reset(); } catch (error) { - if (isAxiosError(error)) { - if (error?.status === 401) { - await alert('세션이 만료되어 로그인 페이지로 이동합니다.'); - await logout(); - router.replace('/login'); - return; - } - - alert(error.response?.data?.message ?? '알 수 없는 오류가 발생했습니다.'); - } else alert(isError(error) ? error.message : String(error)); + if (isAxiosError(error) && error?.status === 401) { + await alert('세션이 만료되어 로그인 페이지로 이동합니다.'); + await logout(); + queryClient.invalidateQueries(); + router.replace('/login'); + } else { + const message = getErrorMessage(error); + alert(message); + } } }; + const isDisabled = !isDirty || !isValid || isSubmitting; + return ( -
-

비밀번호 변경

+ + 비밀번호 변경
- - - - + + + + -
+ ); } diff --git a/src/components/profile/ProfileEdit.tsx b/src/components/profile/ProfileEdit.tsx index 47c0c8e..a8e1872 100644 --- a/src/components/profile/ProfileEdit.tsx +++ b/src/components/profile/ProfileEdit.tsx @@ -1,127 +1,122 @@ 'use client'; -import { useEffect, useState } from 'react'; -import { useForm, useWatch } from 'react-hook-form'; +import { useRouter } from 'next/navigation'; +import { Controller, useForm } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; -import { getUser, updateUser, createProfileImage } from '@/apis/users/index'; -import { UpdateUserForm, updateUserFormSchema } from '@/apis/users/types'; +import useAlert from '@/hooks/useAlert'; +import { UpdateUserForm, updateUserFormSchema, User } from '@/apis/users/types'; +import { useUpdateUser } from '@/apis/users/queries'; +import { createProfileImage } from '@/apis/users'; import { Input } from '@/components/ui/Field/Input'; -import SubmitButton from '@/components/auth/SubmitButton'; import { ImageUpload } from '@/components/ui/Field/ImageUpload'; -import useAlert from '@/hooks/useAlert'; +import Button from '@/components/ui/Button/Button'; +import { Card, CardTitle } from '@/components//ui/Card/Card'; +import { getErrorMessage } from '@/utils/errorMessage'; -export default function ProfileEdit() { - const [profileImageFile, setProfileImageFile] = useState(null); - const [currentEmail, setCurrentEmail] = useState(''); - const [currentNickname, setCurrentNickname] = useState(''); - const [profileImageUrl, setProfileImageUrl] = useState(''); - const [initialProfileImageUrl, setInitialProfileImageUrl] = useState(''); - const [isFormChanged, setIsFormChanged] = useState(false); - const [imageRemoved, setImageRemoved] = useState(false); - - const alert = useAlert(); +type ProfileEditProps = { + user: User; +}; +export default function ProfileEdit({ user }: ProfileEditProps) { const { register, handleSubmit, - setValue, reset, + setValue, control, - formState: { errors, isSubmitting, isValid }, + formState: { errors, isSubmitting, isValid, isDirty }, } = useForm({ resolver: zodResolver(updateUserFormSchema), mode: 'onChange', defaultValues: { - nickname: '', - profileImageUrl: '', + nickname: user.nickname, + profileImageUrl: user.profileImageUrl, }, }); + const router = useRouter(); + const alert = useAlert(); + const { mutateAsync: update } = useUpdateUser(); - const watchedNickname = useWatch({ control, name: 'nickname' }); - - useEffect(() => { - const changed = !!profileImageFile || imageRemoved || (watchedNickname !== '' && watchedNickname !== currentNickname); - setIsFormChanged(changed); - }, [profileImageFile, imageRemoved, watchedNickname, currentNickname]); - - useEffect(() => { - async function fetchUser() { - try { - const user = await getUser(); - setCurrentEmail(user.email); - setCurrentNickname(user.nickname); - const url = user.profileImageUrl || ''; - setProfileImageUrl(url); - setInitialProfileImageUrl(url); - setValue('profileImageUrl', url); - } catch { - alert('유저 정보를 불러오는 중 오류가 발생하였습니다.'); - } - } - fetchUser(); - }, []); - - const handleImageChange = (file: File | undefined) => { - if (!file) { - if (profileImageFile) { - setProfileImageFile(null); - setProfileImageUrl(initialProfileImageUrl); - setValue('profileImageUrl', initialProfileImageUrl); - setImageRemoved(true); - } else { - setProfileImageUrl(''); - setValue('profileImageUrl', ''); - setImageRemoved(true); - } - } else { - setProfileImageFile(file); - setImageRemoved(false); - } + const resetProfileImage = () => { + setValue('profileImageUrl', null, { + shouldDirty: true, + }); }; - const handleSave = async (data: UpdateUserForm) => { - let imageUrl = profileImageUrl; + const onSubmit = async (formData: UpdateUserForm) => { try { - if (profileImageFile) { - const uploadResponse = await createProfileImage({ image: profileImageFile }); - imageUrl = uploadResponse.profileImageUrl.toString(); - } - const newNickname = data.nickname.trim() !== '' ? data.nickname : currentNickname; - const updateData: UpdateUserForm = { - nickname: newNickname, - profileImageUrl: imageRemoved ? null : imageUrl === '' ? null : imageUrl, - }; - await updateUser(updateData); - alert('프로필이 성공적으로 업데이트되었습니다.'); - setCurrentNickname(newNickname); - if (imageRemoved) { - setProfileImageUrl(''); - setValue('profileImageUrl', ''); - setInitialProfileImageUrl(''); + const updatedFormData = { ...formData }; + if (formData.profileImageUrl instanceof File) { + const uploadedUrl = await createProfileImage({ image: formData.profileImageUrl }); + updatedFormData.profileImageUrl = uploadedUrl.profileImageUrl; } - reset(); - setIsFormChanged(false); - setImageRemoved(false); - } catch { - alert('프로필 업데이트 중 오류가 발생하였습니다.'); + + const data = await update(updatedFormData); + reset({ + nickname: data.nickname, + profileImageUrl: data.profileImageUrl, + }); + + alert('수정했습니다.'); + router.refresh(); + } catch (error) { + const message = getErrorMessage(error); + alert(message); } }; + const isDisabled = !isDirty || !isValid || isSubmitting; + return ( -
- 프로필 -
- {/* 프로필 이미지 업로드 */} -
- {}} className='w-full' /> + + 프로필 +
+
+
+ { + return ( + <> + + {field.value && !fieldState.isDirty && ( +
+ +
+ )} + + ); + }} + /> +
+
+ + + + +
- {/* 프로필 수정 폼 */} - - - - - -
-
+ + ); } diff --git a/src/components/ui/Field/ImageUpload.tsx b/src/components/ui/Field/ImageUpload.tsx index 8203b47..d59f423 100644 --- a/src/components/ui/Field/ImageUpload.tsx +++ b/src/components/ui/Field/ImageUpload.tsx @@ -7,16 +7,18 @@ import deleteIcon from '@/assets/icons/x_white.svg'; import addIcon from '@/assets/icons/plus.svg'; type ImageUploadProps = BaseField & - Omit, 'value' | 'onChange' | 'onBlur'> & { - value: File | string | undefined; - onChange: (file: File | undefined) => void; + Omit, 'value' | 'onChange' | 'onBlur' | 'defaultValue'> & { + value: File | string | undefined | null; + onChange: (file: File | undefined | null) => void; onBlur: () => void; + defaultValue?: string | null; }; -export function ImageUpload({ value, onChange, onBlur, label, required, error, className }: ImageUploadProps) { +export function ImageUpload({ defaultValue, value, onChange, onBlur, label, required, error, className }: ImageUploadProps) { const preview = value instanceof File ? URL.createObjectURL(value) : value; const fileRef = useRef(null); const id = useId(); + const showDeleteButton = preview && preview !== defaultValue; useEffect(() => { return () => { @@ -39,11 +41,11 @@ export function ImageUpload({ value, onChange, onBlur, label, required, error, c } function handleRemove() { + onChange(null); + if (fileRef.current) { fileRef.current.value = ''; } - - onChange(undefined); } return ( @@ -58,7 +60,7 @@ export function ImageUpload({ value, onChange, onBlur, label, required, error, c {preview ? thumbnail : 업로드} - {preview && ( + {showDeleteButton && ( diff --git a/src/constants/profileEdit.ts b/src/constants/profileEdit.ts index 42721de..a33f324 100644 --- a/src/constants/profileEdit.ts +++ b/src/constants/profileEdit.ts @@ -12,4 +12,8 @@ export const PROFILEEDIT_FORM_ERROR_MESSAGE = { NICKNAME: { MAX: '열 자 이하로 작성해 주세요', }, + IMAGE: { + TYPE: '지원되지 않는 이미지 파일입니다.', + SIZE: 'MB이하로 올려주세요', + }, } as const;