diff --git a/src/api/users.ts b/src/api/users.ts index b83c225..6dd0dd9 100644 --- a/src/api/users.ts +++ b/src/api/users.ts @@ -9,7 +9,12 @@ export interface UpdateUserRequest { newPassword?: string; } -// 내 정보 조회 - GET /{teamId}/users/me +// 프로필 이미지 업로드 응답 타입 +export interface UploadProfileImageResponse { + profileImageUrl: string; +} + +// 내 정보 조회 - GET /users/me export const getUsersMe = async () => { return apiFetch('/users/me'); }; @@ -29,3 +34,16 @@ export async function updateMe(body: UpdateUserRequest): Promise { body, }); } + +// 프로필 이미지 업로드 +export async function uploadProfileImage( + file: File +): Promise { + const formData = new FormData(); + formData.append('image', file); + + return apiFetch('/users/me/image', { + method: 'POST', + body: formData, + }); +} diff --git a/src/app/(common)/(mypage)/components/SideMenu.tsx b/src/app/(common)/(mypage)/components/SideMenu.tsx index 8482ab4..c579010 100644 --- a/src/app/(common)/(mypage)/components/SideMenu.tsx +++ b/src/app/(common)/(mypage)/components/SideMenu.tsx @@ -1,31 +1,24 @@ 'use client'; import Image from 'next/image'; -import { useEffect, useState } from 'react'; +import { usePathname } from 'next/navigation'; +import { useProfileImageContext } from '../mypage/context/ProfileImageContext'; + +import { useGetMyInfo } from '@/app/(common)/(mypage)/mypage/hooks/useUser'; import closeIcon from '@/assets/icons/sidemenu/ic-close.svg'; import EditableProfile from '@/components/ProfileEditable'; import SideMenuNav from '@/components/SideMenu/SideMenuNav'; export default function SideMenu({ onClose }: { onClose?: () => void }) { - const [profileImage, setProfileImage] = useState(); - - const handleImageChange = (file: File) => { - const preview = URL.createObjectURL(file); - setProfileImage(preview); - }; + const pathname = usePathname(); + const { data: userData } = useGetMyInfo(); + const { handleImageChange } = useProfileImageContext(); - useEffect(() => { - return () => { - if (profileImage) { - URL.revokeObjectURL(profileImage); - } - }; - }, [profileImage]); + const isMyInfoPage = pathname === '/mypage'; return ( diff --git a/src/app/(common)/(mypage)/layout.tsx b/src/app/(common)/(mypage)/layout.tsx index be0c8c6..6c3dc14 100644 --- a/src/app/(common)/(mypage)/layout.tsx +++ b/src/app/(common)/(mypage)/layout.tsx @@ -4,7 +4,10 @@ import { useState } from 'react'; import SideMenu from './components/SideMenu'; import { MenuProvider } from './context/MenuContext'; +import { ProfileImageProvider } from './mypage/context/ProfileImageContext'; +import { useProfileImage } from '@/app/(common)/(mypage)/mypage/hooks/useProfileImage'; +import { useGetMyInfo } from '@/app/(common)/(mypage)/mypage/hooks/useUser'; import { useRequireAuth } from '@/hooks/useRequireAuth'; import { cn } from '@/util/cn'; @@ -13,45 +16,47 @@ export default function MyPageLayout({ }: { children: React.ReactNode; }) { - //접속권한 제한 useRequireAuth(); const [isOpen, setIsOpen] = useState(false); const openMenu = () => setIsOpen(true); const closeMenu = () => setIsOpen(false); + + const { data: userData } = useGetMyInfo(); + const profileImageHook = useProfileImage(userData?.profileImageUrl); + return ( - -
-
- {/* PC / 태블릿 */} -
- -
+ + +
+
+
+ +
- {/* 모바일 */} -
- {/* 오버레이 */} -
- - {/* 왼쪽 슬라이드 메뉴 */}
- +
+
+ +
-
- {/* 메인 컨텐츠 */} -
{children}
+
{children}
+
-
- + + ); } diff --git a/src/app/(common)/(mypage)/mypage/context/ProfileImageContext.tsx b/src/app/(common)/(mypage)/mypage/context/ProfileImageContext.tsx new file mode 100644 index 0000000..04a700e --- /dev/null +++ b/src/app/(common)/(mypage)/mypage/context/ProfileImageContext.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { createContext, useContext, ReactNode } from 'react'; + +interface ProfileImageContextValue { + profileImage: File | null; + profileImagePreview: string | null; + handleImageChange: (file: File) => void; + resetImage: () => void; + updatePreview: (url: string) => void; +} + +const ProfileImageContext = createContext( + null +); + +export function ProfileImageProvider({ + children, + value, +}: { + children: ReactNode; + value: ProfileImageContextValue; +}) { + return ( + + {children} + + ); +} + +export function useProfileImageContext() { + const context = useContext(ProfileImageContext); + if (!context) { + throw new Error( + 'useProfileImageContext must be used within ProfileImageProvider' + ); + } + return context; +} diff --git a/src/app/(common)/(mypage)/mypage/hooks/useErrorHandler.ts b/src/app/(common)/(mypage)/mypage/hooks/useErrorHandler.ts new file mode 100644 index 0000000..3cbe0c0 --- /dev/null +++ b/src/app/(common)/(mypage)/mypage/hooks/useErrorHandler.ts @@ -0,0 +1,129 @@ +import { useRouter } from 'next/navigation'; +import { useCallback } from 'react'; + +import { ApiError } from '@/config/client'; + +/** + * 에러 처리 Hook + * - API 에러 핸들링 + * - 401 Unauthorized 시 로그인 페이지 리다이렉트 + * - 사용자 친화적 에러 메시지 표시 + */ +export function useErrorHandler() { + const router = useRouter(); + + /** + * 일반 에러 처리 + */ + const handleError = useCallback( + (error: unknown, fallbackMessage = '오류가 발생했습니다.') => { + console.error('Error:', error); + + if (error instanceof ApiError) { + // 401 Unauthorized - 로그인 페이지로 리다이렉트 + if (error.status === 401) { + alert('로그인이 필요합니다.'); + localStorage.removeItem('accessToken'); + router.push('/login'); + return; + } + + // 403 Forbidden + if (error.status === 403) { + alert('접근 권한이 없습니다.'); + return; + } + + // 404 Not Found + if (error.status === 404) { + alert('요청한 리소스를 찾을 수 없습니다.'); + return; + } + + // 409 Conflict + if (error.status === 409) { + alert(error.message || '이미 존재하는 데이터입니다.'); + return; + } + + // 422 Validation Error + if (error.status === 422) { + alert(error.message || '입력 데이터를 확인해주세요.'); + return; + } + + // 500 Internal Server Error + if (error.status >= 500) { + alert('서버 오류가 발생했습니다. 잠시 후 다시 시도해주세요.'); + return; + } + + // 기타 API 에러 + alert(error.message || fallbackMessage); + } else if (error instanceof Error) { + // 일반 JavaScript 에러 + alert(error.message || fallbackMessage); + } else { + // 알 수 없는 에러 + alert(fallbackMessage); + } + }, + [router] + ); + + /** + * 이미지 업로드 에러 처리 + */ + const handleImageUploadError = useCallback( + (error: unknown) => { + console.error('Image upload error:', error); + + if (error instanceof ApiError) { + if (error.status === 413) { + alert('이미지 파일 크기가 너무 큽니다. (최대 4MB)'); + return; + } + + if (error.status === 415) { + alert('지원하지 않는 이미지 형식입니다. (JPG, PNG, GIF만 가능)'); + return; + } + } + + handleError(error, '이미지 업로드에 실패했습니다.'); + }, + [handleError] + ); + + /** + * 프로필 업데이트 에러 처리 + */ + const handleProfileUpdateError = useCallback( + (error: unknown) => { + console.error('Profile update error:', error); + + if (error instanceof ApiError) { + // 닉네임 중복 + if (error.status === 409) { + alert('이미 사용 중인 닉네임입니다.'); + return; + } + + // 비밀번호 형식 오류 + if (error.status === 400 && error.message?.includes('password')) { + alert('비밀번호는 8자 이상이어야 합니다.'); + return; + } + } + + handleError(error, '프로필 수정에 실패했습니다.'); + }, + [handleError] + ); + + return { + handleError, + handleImageUploadError, + handleProfileUpdateError, + }; +} diff --git a/src/app/(common)/(mypage)/mypage/hooks/useFormState.ts b/src/app/(common)/(mypage)/mypage/hooks/useFormState.ts new file mode 100644 index 0000000..80a2f28 --- /dev/null +++ b/src/app/(common)/(mypage)/mypage/hooks/useFormState.ts @@ -0,0 +1,67 @@ +import { useCallback, useState } from 'react'; + +import { MyPageFormData, MyPageFormErrors } from './useMyPageFormTypes'; +import { validateForm } from './useMyPageFormValidators'; + +const INITIAL_FORM_DATA: MyPageFormData = { + nickname: '', + email: '', + password: '', + passwordConfirm: '', +}; + +const INITIAL_ERRORS: MyPageFormErrors = { + nickname: '', + email: '', + password: '', + passwordConfirm: '', +}; + +/** + * 폼 데이터 및 에러 상태 관리 Hook + * - 폼 입력 관리 + * - 유효성 검사 + * - 에러 상태 관리 + */ +export function useFormState() { + const [formData, setFormData] = useState(INITIAL_FORM_DATA); + const [errors, setErrors] = useState(INITIAL_ERRORS); + + const handleChange = useCallback( + (field: keyof MyPageFormData) => (value: string) => { + setFormData((prev) => ({ ...prev, [field]: value })); + + if (errors[field]) { + setErrors((prev) => ({ ...prev, [field]: '' })); + } + }, + [errors] + ); + + const updateFormData = useCallback((data: Partial) => { + setFormData((prev) => ({ ...prev, ...data })); + }, []); + + const resetPasswordFields = useCallback(() => { + setFormData((prev) => ({ + ...prev, + password: '', + passwordConfirm: '', + })); + }, []); + + const validate = useCallback(() => { + const { errors: newErrors, isValid } = validateForm(formData); + setErrors(newErrors); + return isValid; + }, [formData]); + + return { + formData, + errors, + handleChange, + updateFormData, + resetPasswordFields, + validate, + }; +} diff --git a/src/app/(common)/(mypage)/mypage/hooks/useMyPageForm.ts b/src/app/(common)/(mypage)/mypage/hooks/useMyPageForm.ts index 516d57d..49199ba 100644 --- a/src/app/(common)/(mypage)/mypage/hooks/useMyPageForm.ts +++ b/src/app/(common)/(mypage)/mypage/hooks/useMyPageForm.ts @@ -1,117 +1,143 @@ +import { useQueryClient } from '@tanstack/react-query'; import { useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect } from 'react'; -import { FormData, FormErrors } from './useMyPageFormTypes'; -import { createUpdatePayload, isUnauthorizedError } from './useMypageFormUtils'; -import { validateForm } from './useMyPageFormValidators'; + +import { useProfileImageContext } from '../context/ProfileImageContext'; + +import { useErrorHandler } from './useErrorHandler'; +import { useFormState } from './useFormState'; +import { createUpdatePayload } from './useMypageFormUtils'; import { useGetMyInfo, useUpdateMyInfo } from './useUser'; +import { uploadProfileImage } from '@/api/users'; import { useToast } from '@/components/toast/useToast'; import { getApiErrorMessage } from '@/util/error'; +// 에러 타입 체크 함수 +function isUnauthorizedError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false; + } + + if (!('response' in error)) { + return false; + } + + const errorWithResponse = error as { response?: { status?: number } }; + return errorWithResponse.response?.status === 401; +} + /** * 마이페이지 폼 관리 Hook * - 사용자 정보 조회 및 수정 * - 폼 유효성 검사 * - 비밀번호 선택적 업데이트 + * - 프로필 이미지 업로드 */ export function useMyPageForm() { + const queryClient = useQueryClient(); + const { handleError, handleImageUploadError, handleProfileUpdateError } = + useErrorHandler(); const router = useRouter(); const toast = useToast(); - // React Query: 사용자 정보 조회 const { data: userData, isLoading: isInitialLoading, error: fetchError, } = useGetMyInfo(); - - // React Query: 사용자 정보 수정 const { mutateAsync: updateProfile, isPending: isLoading } = useUpdateMyInfo(); - // 폼 상태 관리 - const [formData, setFormData] = useState({ - nickname: '', - email: '', - password: '', - passwordConfirm: '', - }); - - const [errors, setErrors] = useState({ - nickname: '', - email: '', - password: '', - passwordConfirm: '', - }); + const { + formData, + errors, + handleChange, + updateFormData, + resetPasswordFields, + validate, + } = useFormState(); + + // Context에서 프로필 이미지 상태 가져오기 + const { + profileImage, + profileImagePreview, + handleImageChange, + resetImage, + updatePreview, + } = useProfileImageContext(); // 사용자 정보 동기화 useEffect(() => { - if (userData) { - setFormData((prev) => ({ - ...prev, - nickname: userData.nickname, - email: userData.email, - })); - } - }, [userData]); + if (!userData) return; - // 에러 처리 + updateFormData({ + nickname: userData.nickname, + email: userData.email, + }); + }, [userData, updateFormData]); + + // 초기 로딩 에러 처리 useEffect(() => { - if (fetchError) { - console.error('사용자 정보 로딩 실패:', fetchError); - if (isUnauthorizedError(fetchError)) { - toast.warning('로그인이 필요합니다.'); - router.push('/login'); - } + if (!fetchError) return; + + console.error('사용자 정보 로딩 실패:', fetchError); + + if (isUnauthorizedError(fetchError)) { + toast.warning('로그인이 필요합니다.'); + router.push('/login'); + } else { + handleError(fetchError); } - }, [fetchError, router]); - - /** - * 입력 필드 변경 핸들러 - */ - const handleChange = (field: keyof FormData) => { - return (value: string) => { - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - // 입력 시 해당 필드 에러 초기화 - if (errors[field]) { - setErrors((prev) => ({ - ...prev, - [field]: '', - })); - } - }; - }; + }, [fetchError, handleError, toast, router]); - /** - * 폼 유효성 검사 - */ - const validate = () => { - const { errors: newErrors, isValid } = validateForm(formData); - setErrors(newErrors); - return isValid; - }; + const invalidateUserQueries = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['user', 'me'] }), + queryClient.invalidateQueries({ queryKey: ['user'] }), + ]); + }, [queryClient]); - /** - * 폼 제출 핸들러 - */ - const handleSubmit = async () => { + const handleSubmit = useCallback(async () => { if (!validate()) return; try { + let uploadedImageUrl: string | undefined; + + // 이미지 업로드 처리 + if (profileImage) { + try { + const imageResult = await uploadProfileImage(profileImage); + uploadedImageUrl = imageResult.profileImageUrl; + } catch (error) { + handleImageUploadError(error); + return; + } + } + const payload = createUpdatePayload(formData); - await updateProfile(payload); - toast.success('저장되었습니다!'); - - // 비밀번호 필드 초기화 - setFormData((prev) => ({ - ...prev, - password: '', - passwordConfirm: '', - })); + + if (uploadedImageUrl) { + payload.profileImageUrl = uploadedImageUrl; + } + + // 프로필 업데이트 처리 + try { + await updateProfile(payload); + await invalidateUserQueries(); + + if (uploadedImageUrl) { + updatePreview(uploadedImageUrl); + } + + toast.success('저장되었습니다!'); + + resetPasswordFields(); + resetImage(); + } catch (error) { + handleProfileUpdateError(error); + } } catch (error: unknown) { console.error('저장 실패:', error); if (isUnauthorizedError(error)) { @@ -122,14 +148,29 @@ export function useMyPageForm() { const errorMessage = getApiErrorMessage(error, '저장에 실패했습니다.'); toast.error(errorMessage); } - }; + }, [ + validate, + profileImage, + formData, + updateProfile, + invalidateUserQueries, + updatePreview, + resetPasswordFields, + resetImage, + handleImageUploadError, + handleProfileUpdateError, + toast, + router, + ]); return { formData, errors, isLoading, isInitialLoading, + profileImagePreview, handleChange, + handleImageChange, handleSubmit, }; } diff --git a/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormTypes.ts b/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormTypes.ts index 516781d..b570ffb 100644 --- a/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormTypes.ts +++ b/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormTypes.ts @@ -1,5 +1,5 @@ //마이페이지 폼 데이터 타입 -export interface FormData { +export interface MyPageFormData { nickname: string; email: string; password: string; @@ -7,7 +7,7 @@ export interface FormData { } //마이페이지 폼 에러 메시지 타입 -export interface FormErrors { +export interface MyPageFormErrors { nickname: string; email: string; password: string; diff --git a/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormValidators.ts b/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormValidators.ts index 08d344f..60a41e4 100644 --- a/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormValidators.ts +++ b/src/app/(common)/(mypage)/mypage/hooks/useMyPageFormValidators.ts @@ -1,4 +1,4 @@ -import { FormData, FormErrors } from './useMyPageFormTypes'; +import { MyPageFormData, MyPageFormErrors } from './useMyPageFormTypes'; import { validateEmail, @@ -12,12 +12,12 @@ import { * - 닉네임, 이메일은 필수 검증 * - 비밀번호는 입력 시에만 검증 (선택적) */ -export function validateForm(formData: FormData): { - errors: FormErrors; +export function validateForm(formData: MyPageFormData): { + errors: MyPageFormErrors; isValid: boolean; } { // 기본 필드 검증 (닉네임, 이메일) - const errors: FormErrors = { + const errors: MyPageFormErrors = { nickname: validateNickname(formData.nickname), email: validateEmail(formData.email), password: '', diff --git a/src/app/(common)/(mypage)/mypage/hooks/useMypageFormUtils.ts b/src/app/(common)/(mypage)/mypage/hooks/useMypageFormUtils.ts index e9b8bbd..56c2d03 100644 --- a/src/app/(common)/(mypage)/mypage/hooks/useMypageFormUtils.ts +++ b/src/app/(common)/(mypage)/mypage/hooks/useMypageFormUtils.ts @@ -1,4 +1,4 @@ -import { FormData } from './useMyPageFormTypes'; +import { MyPageFormData } from './useMyPageFormTypes'; import { UpdateUserRequest } from '@/api/users'; import { ApiError } from '@/config/client'; @@ -16,7 +16,9 @@ export function isUnauthorizedError(error: unknown): boolean { * 폼 데이터를 API 요청 형식으로 변환 * 비밀번호는 입력했을 때만 포함 */ -export function createUpdatePayload(formData: FormData): UpdateUserRequest { +export function createUpdatePayload( + formData: MyPageFormData +): UpdateUserRequest { const payload: UpdateUserRequest = { nickname: formData.nickname, }; diff --git a/src/app/(common)/(mypage)/mypage/hooks/useProfileImage.ts b/src/app/(common)/(mypage)/mypage/hooks/useProfileImage.ts new file mode 100644 index 0000000..308a001 --- /dev/null +++ b/src/app/(common)/(mypage)/mypage/hooks/useProfileImage.ts @@ -0,0 +1,99 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useCallback, useEffect, useState } from 'react'; + +import { ResponseGetUsersMe } from '@/types/users'; + +/** + * 프로필 이미지 상태 관리 Hook + * - 이미지 파일 및 미리보기 관리 + * - React Query 업데이트로 실시간 반영 + */ +export function useProfileImage(initialImageUrl?: string | null) { + const queryClient = useQueryClient(); + const [profileImage, setProfileImage] = useState(null); + const [profileImagePreview, setProfileImagePreview] = useState( + initialImageUrl || null + ); + + useEffect(() => { + if (initialImageUrl) { + setProfileImagePreview(initialImageUrl); + } + }, [initialImageUrl]); + + useEffect(() => { + return () => { + if (profileImagePreview?.startsWith('blob:')) { + URL.revokeObjectURL(profileImagePreview); + } + }; + }, [profileImagePreview]); + + const handleImageChange = useCallback( + (file: File) => { + setProfileImage(file); + + const previewUrl = URL.createObjectURL(file); + + setProfileImagePreview((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + return previewUrl; + }); + + queryClient.setQueryData( + ['user', 'me'], + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + profileImageUrl: previewUrl, + }; + } + ); + }, + [queryClient] + ); + + const resetImage = useCallback(() => { + setProfileImagePreview((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + return null; + }); + setProfileImage(null); + }, []); + + const updatePreview = useCallback( + (url: string) => { + setProfileImagePreview((prev) => { + if (prev?.startsWith('blob:')) { + URL.revokeObjectURL(prev); + } + return url; + }); + + queryClient.setQueryData( + ['user', 'me'], + (oldData) => { + if (!oldData) return oldData; + return { + ...oldData, + profileImageUrl: url, + }; + } + ); + }, + [queryClient] + ); + + return { + profileImage, + profileImagePreview, + handleImageChange, + resetImage, + updatePreview, + }; +} diff --git a/src/app/(common)/(mypage)/mypage/page.tsx b/src/app/(common)/(mypage)/mypage/page.tsx index 592c147..4faad93 100644 --- a/src/app/(common)/(mypage)/mypage/page.tsx +++ b/src/app/(common)/(mypage)/mypage/page.tsx @@ -10,8 +10,7 @@ import LoadingSpinner from '@/components/LoadingSpinner'; /** * 사용자 정보 수정 페이지 - * - 닉네임, 비밀번호 수정 기능 제공 - * - 이메일은 수정 불가 (조회만 가능) + * 프로필 이미지는 사이드메뉴에서 수정 */ export default function MyPage() { const { @@ -23,7 +22,6 @@ export default function MyPage() { handleSubmit, } = useMyPageForm(); - // 폼 제출 핸들러 const handleFormSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!isLoading) { @@ -31,7 +29,6 @@ export default function MyPage() { } }; - // 초기 데이터 로딩 중 if (isInitialLoading) { return (
@@ -42,17 +39,14 @@ export default function MyPage() { return (
- {/* 페이지 헤더 */}
- {/* 폼 입력 영역 */}
- {/* 닉네임 입력 */} - {/* 이메일 (수정 불가) */} - {/* 비밀번호 입력 */} - {/* 비밀번호 확인 입력 */} - {/* 저장 버튼 */}