diff --git a/next.config.ts b/next.config.ts index 45bbc3e..ccc5544 100644 --- a/next.config.ts +++ b/next.config.ts @@ -4,6 +4,18 @@ const nextConfig: NextConfig = { // Docker 배포를 위한 standalone 모드 활성화 // 해당 설정은 프로덕션 빌드 시 필요한 파일만 .next/standalone 폴더에 복사됨. output: 'standalone', + + // 외부 이미지 도메인 허용 + images: { + remotePatterns: [ + { + protocol: 'https', + hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com', + port: '', + pathname: '/globalnomad/**', + }, + ], + }, }; export default nextConfig; diff --git a/public/assets/svg/my-activities-dashboard.tsx b/public/assets/svg/my-activities-dashboard.tsx new file mode 100644 index 0000000..bc58455 --- /dev/null +++ b/public/assets/svg/my-activities-dashboard.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const MyActivitiesDashboardIcon = ({ size = 24, ...props }) => ( + + + +); + +export default MyActivitiesDashboardIcon; diff --git a/public/assets/svg/my-activities.tsx b/public/assets/svg/my-activities.tsx new file mode 100644 index 0000000..dd393bc --- /dev/null +++ b/public/assets/svg/my-activities.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const MyActivitiesIcon = ({ size = 24, ...props }) => ( + + + +); + +export default MyActivitiesIcon; diff --git a/public/assets/svg/my-reservation.tsx b/public/assets/svg/my-reservation.tsx new file mode 100644 index 0000000..e3af05c --- /dev/null +++ b/public/assets/svg/my-reservation.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const MyReservationIcon = ({ size = 24, ...props }) => ( + + + +); + +export default MyReservationIcon; diff --git a/public/assets/svg/my-user.tsx b/public/assets/svg/my-user.tsx new file mode 100644 index 0000000..64045c7 --- /dev/null +++ b/public/assets/svg/my-user.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const MyUsersIcon = ({ size = 24, ...props }) => ( + + + +); + +export default MyUsersIcon; diff --git a/public/assets/svg/pen.tsx b/public/assets/svg/pen.tsx new file mode 100644 index 0000000..feeb4b9 --- /dev/null +++ b/public/assets/svg/pen.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +const PenIcon = ({ size = 24, ...props }) => ( + + + +); + +export default PenIcon; diff --git a/public/assets/svg/profile-default.tsx b/public/assets/svg/profile-default.tsx new file mode 100644 index 0000000..b62db74 --- /dev/null +++ b/public/assets/svg/profile-default.tsx @@ -0,0 +1,19 @@ +import React from 'react'; + +const ProfileDefaultIcon = ({ size = 24, ...props }) => ( + + + +); + +export default ProfileDefaultIcon; diff --git a/src/apis/mypage.ts b/src/apis/mypage.ts new file mode 100644 index 0000000..a94e11c --- /dev/null +++ b/src/apis/mypage.ts @@ -0,0 +1,44 @@ +import { privateInstance } from './privateInstance'; +import { User } from '@/types/user'; +import { + ProfileImageResponse, + UpdateProfileRequest, +} from '@/types/mypageTypes'; + +/** + * 내 정보 조회 + * GET /api/users/me + */ +export const getMyProfile = async (): Promise => { + const response = await privateInstance.get('/users/me'); + return response.data; +}; + +/** + * 내 정보 수정 + * PATCH /api/users/me + */ +export const updateMyProfile = async ( + data: UpdateProfileRequest, +): Promise => { + const response = await privateInstance.patch('/users/me', data); + return response.data; +}; + +/** + * 프로필 이미지 업로드 + * POST /api/users/me/image + */ +export const uploadProfileImage = async ( + file: File, +): Promise => { + const formData = new FormData(); + formData.append('image', file); + + const response = await privateInstance.post('/users/me/image', formData, { + headers: { + 'Content-Type': 'multipart/form-data', + }, + }); + return response.data; +}; diff --git a/src/app/(with-header)/mypage/activities/page.tsx b/src/app/(with-header)/mypage/activities/page.tsx new file mode 100644 index 0000000..4d564d9 --- /dev/null +++ b/src/app/(with-header)/mypage/activities/page.tsx @@ -0,0 +1,18 @@ +export default function MyActivitiesPage() { + return ( + <> + {/* 제목 */} + + + 내 체험 관리 + + + + {/* 내 체험 관리 컨텐츠 */} + + 내 체험 관리 페이지입니다. + {/* TODO: 내 체험 관리 컴포넌트 구현 */} + + > + ); +} diff --git a/src/app/(with-header)/mypage/components/ProfileImage.tsx b/src/app/(with-header)/mypage/components/ProfileImage.tsx new file mode 100644 index 0000000..07fd1dc --- /dev/null +++ b/src/app/(with-header)/mypage/components/ProfileImage.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; +import cn from '@/lib/cn'; +import { ProfileImageProps } from '@/types/mypageTypes'; +import PenIcon from '@assets/svg/pen'; +import ProfileDefaultIcon from '@assets/svg/profile-default'; + +/** + * @component ProfileImage + * @description + * 마이페이지 전용 프로필 이미지 컴포넌트입니다. + * + * @param {ProfileImageProps} props - ProfileImage 컴포넌트의 props + * @param {string} [props.src] - 프로필 이미지 URL + * @param {string} [props.alt] - 이미지 alt 텍스트 + * @param {string} [props.nickname='사용자'] - 사용자 닉네임 + * @param {boolean} [props.showEditButton=false] - 편집 버튼 표시 여부 + * @param {() => void} [props.onEdit] - 편집 버튼 클릭 핸들러 + * @param {string} [props.className] - 추가 CSS 클래스 + */ + +function isValidUrl(url: string): boolean { + if (!url || url.trim() === '') return false; + + try { + new URL(url); + return true; + } catch { + return false; + } +} + +export default function ProfileImage({ + src, + alt, + nickname = '사용자', + showEditButton = false, + onEdit, + className, +}: ProfileImageProps) { + const [imageError, setImageError] = useState(false); + + // 이미지 로딩 에러 핸들러 + const handleImageError = () => { + setImageError(true); + }; + + // URL 유효성 검사 + const hasValidImage = src && isValidUrl(src) && !imageError; + + return ( + + {/* 프로필 이미지 컨테이너 */} + + {hasValidImage ? ( + + ) : ( + // 기본 프로필 아이콘 + + + + )} + + + {/* 편집 버튼 */} + {showEditButton && ( + + + + )} + + ); +} diff --git a/src/app/(with-header)/mypage/components/ProfileNavigation.tsx b/src/app/(with-header)/mypage/components/ProfileNavigation.tsx new file mode 100644 index 0000000..bb58e49 --- /dev/null +++ b/src/app/(with-header)/mypage/components/ProfileNavigation.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { usePathname } from 'next/navigation'; +import Link from 'next/link'; +import ProfileImage from './ProfileImage'; +import useMyPageStore from '@/stores/MyPage/useMyPageStore'; +import { useProfileImageUpload } from '@/hooks/useProfileImageUpload'; +import MyUserIcon from '@assets/svg/my-user'; +import MyReservationIcon from '@assets/svg/my-reservation'; +import MyActivitiesIcon from '@assets/svg/my-activities'; +import MyActivitiesDashboardIcon from '@assets/svg/my-activities-dashboard'; + +/** + * @component ProfileNavigation + * @description + * 마이페이지 전용 프로필 네비게이션 컴포넌트입니다. + * 데스크톱/태블릿에서 좌측에 표시되며, 프로필 이미지와 네비게이션 메뉴를 포함합니다. + */ +export default function ProfileNavigation() { + const { user } = useMyPageStore(); + const pathname = usePathname(); + const { fileInputRef, handleImageEdit, handleFileChange } = + useProfileImageUpload(); + + const menuItems = [ + { href: '/mypage/profile', icon: MyUserIcon, label: '내 정보' }, + { + href: '/mypage/reservations', + icon: MyReservationIcon, + label: '예약 내역', + }, + { + href: '/mypage/activities', + icon: MyActivitiesIcon, + label: '내 체험 관리', + }, + { + href: '/mypage/dashboard', + icon: MyActivitiesDashboardIcon, + label: '예약 현황', + }, + ]; + + // 메뉴 활성화 상태 확인 + const isActive = (href: string) => { + return pathname === href; + }; + + return ( + + + {/* 프로필 이미지 섹션 */} + + + + {/* 숨겨진 파일 입력 */} + + + + {/* 네비게이션 메뉴 섹션 */} + + {menuItems.map(({ href, icon: Icon, label }) => ( + + + {label} + + ))} + + + + ); +} diff --git a/src/app/(with-header)/mypage/components/index.ts b/src/app/(with-header)/mypage/components/index.ts new file mode 100644 index 0000000..a99566c --- /dev/null +++ b/src/app/(with-header)/mypage/components/index.ts @@ -0,0 +1,2 @@ +export { default as ProfileImage } from './ProfileImage'; +export { default as ProfileNavigation } from './ProfileNavigation'; diff --git a/src/app/(with-header)/mypage/dashboard/page.tsx b/src/app/(with-header)/mypage/dashboard/page.tsx new file mode 100644 index 0000000..b33e453 --- /dev/null +++ b/src/app/(with-header)/mypage/dashboard/page.tsx @@ -0,0 +1,18 @@ +export default function MyDashboardPage() { + return ( + <> + {/* 제목 */} + + + 예약 현황 + + + + {/* 예약 현황 컨텐츠 */} + + 예약 현황 페이지입니다. + {/* TODO: 예약 현황 컴포넌트 구현 */} + + > + ); +} diff --git a/src/app/(with-header)/mypage/layout.tsx b/src/app/(with-header)/mypage/layout.tsx new file mode 100644 index 0000000..6a3915b --- /dev/null +++ b/src/app/(with-header)/mypage/layout.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { ProfileNavigation } from './components'; +import useResponsiveRouting from '@/hooks/useResponsiveRouting'; +import { useMyProfile } from '@/hooks/useMyPageQueries'; + +export default function MyPageLayout({ + children, +}: { + children: React.ReactNode; +}) { + const { mounted } = useResponsiveRouting(); + const { isLoading, error } = useMyProfile(); + + // mounted + API 로딩 상태 모두 체크 + if (!mounted || isLoading) { + return ( + + + + {/* 좌측 프로필 네비게이션 스켈레톤 - 데스크톱/태블릿 */} + + + {/* 프로필 이미지 영역 */} + + + + {/* 메뉴 리스트 영역 */} + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + + {/* 메인 스켈레톤 */} + + + + + ); + } + + if (error) { + return ( + + + + 로그인이 필요합니다 + + 다시 로그인해주세요. + + + ); + } + + // API 로딩 완료 + mounted 상태일 때만 실행 + return ( + + + + {/* 좌측 프로필 네비게이션 섹션 - 데스크톱/태블릿에서만 표시 */} + + + {/* 우측 메인 콘텐츠 섹션 */} + {children} + + + + ); +} diff --git a/src/app/(with-header)/mypage/page.tsx b/src/app/(with-header)/mypage/page.tsx new file mode 100644 index 0000000..9d8ef9c --- /dev/null +++ b/src/app/(with-header)/mypage/page.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import ProfileImage from '@/app/(with-header)/mypage/components/ProfileImage'; +import useMyPageStore from '@/stores/MyPage/useMyPageStore'; +import useDeviceSize from '@/hooks/useDeviceSize'; +import MyUserIcon from '@assets/svg/my-user'; +import MyReservationIcon from '@assets/svg/my-reservation'; +import MyActivitiesIcon from '@assets/svg/my-activities'; +import MyActivitiesDashboardIcon from '@assets/svg/my-activities-dashboard'; +import { useProfileImageUpload } from '@/hooks/useProfileImageUpload'; +/** + * 마이페이지 메인 페이지 (/mypage) + * - 모바일: 메뉴 리스트 표시 + * - 데스크톱/태블릿: /mypage/profile로 자동 리다이렉트 + */ +export default function MyPageMainPage() { + const { user } = useMyPageStore(); + const { fileInputRef, handleImageEdit, handleFileChange } = + useProfileImageUpload(); + const [mounted, setMounted] = useState(false); + const deviceType = useDeviceSize(); + const router = useRouter(); + + useEffect(() => { + setMounted(true); + }, []); + + // 데스크톱/태블릿에서는 프로필 페이지로 리다이렉트 + useEffect(() => { + if (mounted && deviceType !== 'mobile') { + router.replace('/mypage/profile'); + } + }, [mounted, deviceType, router]); + + const menuItems = [ + { href: '/mypage/profile', icon: MyUserIcon, label: '내 정보' }, + { + href: '/mypage/reservations', + icon: MyReservationIcon, + label: '예약 내역', + }, + { + href: '/mypage/activities', + icon: MyActivitiesIcon, + label: '내 체험 관리', + }, + { + href: '/mypage/dashboard', + icon: MyActivitiesDashboardIcon, + label: '예약 현황', + }, + ]; + + // 초기 로딩 또는 데스크톱/태블릿에서 리다이렉트 중 + if (!mounted || deviceType !== 'mobile') { + return ( + + + + + + + + {[1, 2, 3, 4].map((i) => ( + + ))} + + + + + ); + } + + // 모바일에서만 메뉴 리스트 표시 + return ( + + + + {/* 프로필 이미지 섹션 */} + + + + + + + {/* 메뉴 리스트 */} + + {menuItems.map(({ href, icon: Icon, label }) => ( + + + {label} + + ))} + + + + + ); +} diff --git a/src/app/(with-header)/mypage/profile/page.tsx b/src/app/(with-header)/mypage/profile/page.tsx new file mode 100644 index 0000000..3f1bab5 --- /dev/null +++ b/src/app/(with-header)/mypage/profile/page.tsx @@ -0,0 +1,183 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import Input from '@/components/Input'; +import Button from '@/components/Button'; +import useMyPageStore from '@/stores/MyPage/useMyPageStore'; +import { + validatePassword, + validatePasswordConfirmation, +} from '@/utils/validateInput'; +import { useUpdateProfile } from '@/hooks/useMyPageQueries'; +import { UpdateProfileRequest } from '@/types/mypageTypes'; + +export default function ProfilePage() { + const { user } = useMyPageStore(); + + const updateProfileMutation = useUpdateProfile(); + + // 폼 상태 관리 + const [formData, setFormData] = useState({ + nickname: '', + email: '', + newPassword: '', + confirmPassword: '', + }); + + // 에러 상태 추가 + const [errors, setErrors] = useState({ + newPassword: '', + confirmPassword: '', + }); + + // user 데이터가 로드되면 폼 업데이트 + useEffect(() => { + if (user) { + setFormData((prev) => ({ + ...prev, + nickname: user.nickname || '', + email: user.email || '', + })); + } + }, [user]); + + // 입력 값 변경 핸들러 + const handleInputChange = + (field: keyof typeof formData) => + (e: React.ChangeEvent) => { + setFormData((prev) => ({ + ...prev, + [field]: e.target.value, + })); + }; + + // 비밀번호 유효성 검사 + const handlePasswordBlur = () => { + setErrors((prev) => ({ + ...prev, + newPassword: validatePassword(formData.newPassword), + })); + }; + + // 비밀번호 확인 유효성 검사 + const handleConfirmPasswordBlur = () => { + setErrors((prev) => ({ + ...prev, + confirmPassword: validatePasswordConfirmation( + formData.confirmPassword, + formData.newPassword, + ), + })); + }; + + // 저장 핸들러 + const handleSave = () => { + // 저장 전 최종 유효성 검사 + const passwordError = formData.newPassword + ? validatePassword(formData.newPassword) + : ''; + const confirmPasswordError = formData.newPassword + ? validatePasswordConfirmation( + formData.confirmPassword, + formData.newPassword, + ) + : ''; + + if (passwordError || confirmPasswordError) { + setErrors({ + newPassword: passwordError, + confirmPassword: confirmPasswordError, + }); + return; + } + + const updateData: UpdateProfileRequest = { + nickname: formData.nickname, + }; + + // 비밀번호가 입력된 경우에만 포함 + if (formData.newPassword && formData.newPassword.trim() !== '') { + updateData.newPassword = formData.newPassword; + } + + updateProfileMutation.mutate(updateData); + }; + + return ( + + {/* 제목과 저장하기 버튼 */} + + + 내 정보 + + + 저장하기 + + + + {/* 폼 섹션 */} + + {/* 닉네임 */} + + + 닉네임 + + + + + {/* 이메일 */} + + + 이메일 + + + + + {/* 비밀번호 */} + + + 비밀번호 + + + + + {/* 비밀번호 재입력 */} + + + 비밀번호 재입력 + + + + + + ); +} diff --git a/src/app/(with-header)/mypage/reservations/page.tsx b/src/app/(with-header)/mypage/reservations/page.tsx new file mode 100644 index 0000000..d02cdd9 --- /dev/null +++ b/src/app/(with-header)/mypage/reservations/page.tsx @@ -0,0 +1,18 @@ +export default function MyReservationsPage() { + return ( + <> + {/* 제목 */} + + + 예약 내역 + + + + {/* 예약 내역 컨텐츠 */} + + 예약 내역 페이지입니다. + {/* TODO: 예약 내역 컴포넌트 구현 */} + + > + ); +} diff --git a/src/app/api/users/me/image/route.ts b/src/app/api/users/me/image/route.ts new file mode 100644 index 0000000..e1aedf0 --- /dev/null +++ b/src/app/api/users/me/image/route.ts @@ -0,0 +1,47 @@ +import axios from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; + +/** + * 프로필 이미지 업로드 + * POST /api/users/me/image + */ +export async function POST(request: NextRequest) { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json( + { message: '인증 토큰이 없습니다.' }, + { status: 401 }, + ); + } + + // FormData 추출 + const formData = await request.formData(); + + const response = await axios.post(`${API_URL}/users/me/image`, formData, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'multipart/form-data', + }, + }); + + return NextResponse.json(response.data); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status || 500; + const message = + error.response?.data?.message || '이미지 업로드에 실패했습니다.'; + return NextResponse.json({ message }, { status }); + } + + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/users/me/route.ts b/src/app/api/users/me/route.ts new file mode 100644 index 0000000..e64c140 --- /dev/null +++ b/src/app/api/users/me/route.ts @@ -0,0 +1,85 @@ +import axios from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +const API_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; + +/** + * 내 정보 조회 + * GET /api/users/me + */ +export async function GET() { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json( + { message: '인증 토큰이 없습니다.' }, + { status: 401 }, + ); + } + + const response = await axios.get(`${API_URL}/users/me`, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + return NextResponse.json(response.data); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status || 500; + const message = + error.response?.data?.message || '내 정보 조회에 실패했습니다.'; + return NextResponse.json({ message }, { status }); + } + + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 }, + ); + } +} + +/** + * 내 정보 수정 + * PUT /api/users/me + */ +export async function PATCH(request: NextRequest) { + try { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + if (!accessToken) { + return NextResponse.json( + { message: '인증 토큰이 없습니다.' }, + { status: 401 }, + ); + } + + const body = await request.json(); + + const response = await axios.patch(`${API_URL}/users/me`, body, { + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + return NextResponse.json(response.data); + } catch (error) { + if (axios.isAxiosError(error)) { + const status = error.response?.status || 500; + const message = + error.response?.data?.message || '내 정보 수정에 실패했습니다.'; + return NextResponse.json({ message }, { status }); + } + + return NextResponse.json( + { message: '서버 오류가 발생했습니다.' }, + { status: 500 }, + ); + } +} diff --git a/src/hooks/useDebounce.ts b/src/hooks/useDebounce.ts new file mode 100644 index 0000000..c8d6789 --- /dev/null +++ b/src/hooks/useDebounce.ts @@ -0,0 +1,20 @@ +'use client'; + +import { useEffect, useState } from 'react'; + +// 디바운싱 훅 +export default function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(timer); + }; + }, [value, delay]); + + return debouncedValue; +} diff --git a/src/hooks/useMyPageQueries.ts b/src/hooks/useMyPageQueries.ts new file mode 100644 index 0000000..55f1340 --- /dev/null +++ b/src/hooks/useMyPageQueries.ts @@ -0,0 +1,146 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { + getMyProfile, + updateMyProfile, + uploadProfileImage, +} from '@/apis/mypage'; +import { UpdateProfileRequest } from '@/types/mypageTypes'; +import useMyPageStore from '@/stores/MyPage/useMyPageStore'; +import { useEffect } from 'react'; + +export const QUERY_KEYS = { + PROFILE: ['mypage', 'profile'] as const, +} as const; + +// 내 정보 조회 +export const useMyProfile = () => { + const { setUser, setLoading, setError } = useMyPageStore(); + + const query = useQuery({ + queryKey: QUERY_KEYS.PROFILE, + queryFn: getMyProfile, + staleTime: 1000 * 60 * 5, + }); + + useEffect(() => { + if (query.data) { + setUser(query.data); + setLoading(false); + setError(null); + } + if (query.error) { + setError(query.error.message); + setLoading(false); + } + if (query.isLoading) { + setLoading(true); + setError(null); + } + }, [query.data, query.error, query.isLoading, setUser, setLoading, setError]); + + return query; +}; + +// 내 정보 수정 +export const useUpdateProfile = () => { + const queryClient = useQueryClient(); + const { setUser, setLoading, setError } = useMyPageStore(); + + const mutation = useMutation({ + mutationFn: (data: UpdateProfileRequest) => updateMyProfile(data), + }); + + useEffect(() => { + if (mutation.isPending) { + setLoading(true); + setError(null); + } + + if (mutation.isSuccess && mutation.data) { + setUser(mutation.data); + setLoading(false); + // 캐시 업데이트 + queryClient.setQueryData(QUERY_KEYS.PROFILE, mutation.data); + alert('프로필이 성공적으로 업데이트되었습니다!'); + } + + if (mutation.isError) { + setError(mutation.error?.message || '프로필 업데이트에 실패했습니다.'); + setLoading(false); + alert(`프로필 업데이트 실패: ${mutation.error?.message}`); + } + }, [ + mutation.isPending, + mutation.isSuccess, + mutation.isError, + mutation.data, + mutation.error, + queryClient, + setUser, + setLoading, + setError, + ]); + + return mutation; +}; + +// 프로필 이미지 업로드 +export const useUploadProfileImage = () => { + const queryClient = useQueryClient(); + const { setUser, setLoading, setError } = useMyPageStore(); + + const mutation = useMutation({ + mutationFn: async (file: File) => { + // 이미지 업로드 + const imageResponse = await uploadProfileImage(file); + + // 사용자 정보 업데이트 (새 이미지 URL 포함) + const userResponse = await updateMyProfile({ + profileImageUrl: imageResponse.profileImageUrl, + }); + + return { + imageResponse, + userResponse, + }; + }, + }); + + useEffect(() => { + if (mutation.isPending) { + setLoading(true); + setError(null); + } + + if (mutation.isSuccess && mutation.data) { + // 서버에서 업데이트된 사용자 정보로 캐시 업데이트 + const updatedUser = mutation.data.userResponse; + + setUser(updatedUser); + queryClient.setQueryData(QUERY_KEYS.PROFILE, updatedUser); + + setLoading(false); + alert('프로필 이미지가 성공적으로 업로드되었습니다!'); + } + + if (mutation.isError) { + setError(mutation.error?.message || '이미지 업로드에 실패했습니다.'); + setLoading(false); + alert(`이미지 업로드 실패: ${mutation.error?.message}`); + } + }, [ + mutation.isPending, + mutation.isSuccess, + mutation.isError, + mutation.data, + mutation.error, + queryClient, + setUser, + setLoading, + setError, + ]); + + return mutation; +}; diff --git a/src/hooks/useProfileImageUpload.ts b/src/hooks/useProfileImageUpload.ts new file mode 100644 index 0000000..44c083e --- /dev/null +++ b/src/hooks/useProfileImageUpload.ts @@ -0,0 +1,50 @@ +import { useRef } from 'react'; +import { useUploadProfileImage } from './useMyPageQueries'; + +/** + * 프로필 이미지 업로드를 위한 커스텀 훅 + * + * @returns {Object} fileInputRef, handleImageEdit, handleFileChange + * @returns {React.RefObject} fileInputRef - 파일 입력 요소 참조 + * @returns {() => void} handleImageEdit - 편집 버튼 클릭 핸들러 + * @returns {(event: React.ChangeEvent) => void} handleFileChange - 파일 선택 핸들러 + */ + +export const useProfileImageUpload = () => { + const { mutate: uploadProfileImage } = useUploadProfileImage(); + const fileInputRef = useRef(null); + + // 프로필 이미지 편집 버튼 + const handleImageEdit = () => { + fileInputRef.current?.click(); + }; + + // 파일 선택 + const handleFileChange = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + // 파일 타입 검증 + if (!file.type.startsWith('image/')) { + alert('이미지 파일만 업로드 가능합니다.'); + return; + } + + // 파일 크기 검증 + if (file.size > 5 * 1024 * 1024) { + alert('파일 크기는 5MB 이하여야 합니다.'); + return; + } + + uploadProfileImage(file); + } + + // 같은 파일을 다시 선택할 수 있도록 input 값 초기화 + event.target.value = ''; + }; + + return { + fileInputRef, + handleImageEdit, + handleFileChange, + }; +}; diff --git a/src/hooks/useResponsiveRouting.ts b/src/hooks/useResponsiveRouting.ts new file mode 100644 index 0000000..2fa8ebb --- /dev/null +++ b/src/hooks/useResponsiveRouting.ts @@ -0,0 +1,83 @@ +'use client'; + +import { useEffect, useState, useRef } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; +import useDeviceSize from './useDeviceSize'; +import useDebounce from './useDebounce'; + +/** + * 반응형 라우팅 훅 + * - 모바일: /mypage (메뉴 네비게이션 리스트) + * - 데스크톱/태블릿: /mypage/profile (내 정보 폼) + */ +export default function useResponsiveRouting() { + const router = useRouter(); + const pathname = usePathname(); + const deviceType = useDeviceSize(); + const [mounted, setMounted] = useState(false); + const redirectingRef = useRef(false); + const lastDeviceTypeRef = useRef(''); + const lastPathChangeRef = useRef(0); + + // 디바운싱 + const debouncedDeviceType = useDebounce(deviceType, 700); + + // Hydration 완료 후 동작 + useEffect(() => { + setMounted(true); + lastDeviceTypeRef.current = deviceType; + }, []); + + // 경로 변경 시 타임스탬프 기록 (사용자 클릭 감지용) + useEffect(() => { + lastPathChangeRef.current = Date.now(); + }, [pathname]); + + useEffect(() => { + if (!mounted) return; + if (redirectingRef.current) return; + + // 디바이스 타입이 실제로 변경되었는지 확인 + const deviceTypeChanged = lastDeviceTypeRef.current !== debouncedDeviceType; + const timeSinceLastPathChange = Date.now() - lastPathChangeRef.current; + + // 사용자가 최근에 클릭했다면 (3초 이내) 자동 리다이렉트 무시 + if (timeSinceLastPathChange < 3000) { + lastDeviceTypeRef.current = debouncedDeviceType; + return; + } + + // 디바이스 타입이 변경된 경우에만 리다이렉트 실행 + if (deviceTypeChanged) { + const currentPath = pathname; + + // 모바일로 변경 시: /mypage/profile -> /mypage + if ( + debouncedDeviceType === 'mobile' && + currentPath === '/mypage/profile' + ) { + redirectingRef.current = true; + router.replace('/mypage'); + + setTimeout(() => { + redirectingRef.current = false; + }, 1500); + } + + // 데스크톱/태블릿으로 변경 시: /mypage -> /mypage/profile + else if (debouncedDeviceType !== 'mobile' && currentPath === '/mypage') { + redirectingRef.current = true; + router.replace('/mypage/profile'); + + setTimeout(() => { + redirectingRef.current = false; + }, 1500); + } + } + + // 현재 디바이스 타입 기록 + lastDeviceTypeRef.current = debouncedDeviceType; + }, [debouncedDeviceType, pathname, router, mounted]); + + return { mounted, deviceType: debouncedDeviceType }; +} diff --git a/src/stores/MyPage/useMyPageStore.ts b/src/stores/MyPage/useMyPageStore.ts new file mode 100644 index 0000000..cf20be5 --- /dev/null +++ b/src/stores/MyPage/useMyPageStore.ts @@ -0,0 +1,47 @@ +import { create } from 'zustand'; +import { MyPageStoreState } from '@/types/mypageTypes'; + +/** + * 마이페이지 Zustand 스토어 + * + * @description + * - UI 상태 관리 (로딩, 에러, 편집 모드) + */ +const useMyPageStore = create((set) => ({ + user: null, + isLoading: false, + error: null, + isEditing: false, + + // 사용자 관련 액션 + setUser: (user) => { + set({ + user, + error: null, + }); + }, + + // 로딩 상태 액션 + setLoading: (isLoading) => { + set({ isLoading }); + }, + + // 에러 관리 액션 + setError: (error) => { + set({ + error, + isLoading: false, + }); + }, + + clearError: () => { + set({ error: null }); + }, + + // UI 상태 액션 + setEditing: (isEditing) => { + set({ isEditing }); + }, +})); + +export default useMyPageStore; diff --git a/src/types/mypageTypes.ts b/src/types/mypageTypes.ts new file mode 100644 index 0000000..b551748 --- /dev/null +++ b/src/types/mypageTypes.ts @@ -0,0 +1,57 @@ +import { User } from '@/types/user'; + +// 프로필 이미지 컴포넌트 Props +export interface ProfileImageProps { + src?: string | null; + alt?: string; + nickname?: string; + showEditButton?: boolean; + onEdit?: () => void; + className?: string; +} + +// 마이페이지 스토어 상태 타입 +export interface MyPageStoreState { + // 사용자 정보 + user: User | null; + isLoading: boolean; + error: string | null; + + // UI 상태 + isEditing: boolean; + + // 액션들 + setUser: (user: User | null) => void; + setLoading: (loading: boolean) => void; + setError: (error: string | null) => void; + setEditing: (editing: boolean) => void; + clearError: () => void; +} + +export interface LoginRequest { + email: string; + password: string; +} + +export interface UpdateProfileRequest { + nickname?: string; + profileImageUrl?: string; + newPassword?: string; +} + +export interface LoginResponse { + user: User; + refreshToken: string; + accessToken: string; +} + +export interface ProfileImageResponse { + profileImageUrl: string; +} + +export interface ProfileFormData { + nickname: string; + email: string; + newPassword: string; + confirmPassword: string; +}
내 체험 관리 페이지입니다.
예약 현황 페이지입니다.
다시 로그인해주세요.
예약 내역 페이지입니다.