-
Notifications
You must be signed in to change notification settings - Fork 1
Feat/61 마이페이지 구현(UI 및 API 연동) #76
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
ea04e42
16106db
a984256
cb035f4
8b67696
35c50d2
4fb5086
6993266
9b2810a
7ef79e7
00e2d81
8fc8f08
47b662f
89cc384
b8e1857
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React from 'react'; | ||
|
|
||
| const MyActivitiesDashboardIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 24 24' | ||
| {...props} | ||
| > | ||
| <path | ||
| fill='#000' | ||
| d='M19 3h-1V1h-2v2H8V1H6v2H5c-1.1 0-2 .9-2 2v14a2 2 0 0 0 2 2h14c1.11 0 2-.89 2-2V5a2 2 0 0 0-2-2m0 16H5V9h14zM5 7V5h14v2zm5.56 10.46 5.94-5.93-1.07-1.06-4.87 4.87-2.11-2.11-1.06 1.06z' | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default MyActivitiesDashboardIcon; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React from 'react'; | ||
|
|
||
| const MyActivitiesIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 24 24' | ||
| {...props} | ||
| > | ||
| <path | ||
| fill='#000' | ||
| d='M12 8a4 4 0 1 1 0 8 4 4 0 0 1 0-8m0 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4m-2 12c-.25 0-.46-.18-.5-.42l-.37-2.65c-.63-.25-1.17-.59-1.69-.99l-2.49 1.01c-.22.08-.49 0-.61-.22l-2-3.46a.493.493 0 0 1 .12-.64l2.11-1.66L4.5 12l.07-1-2.11-1.63a.493.493 0 0 1-.12-.64l2-3.46c.12-.22.39-.31.61-.22l2.49 1c.52-.39 1.06-.73 1.69-.98l.37-2.65c.04-.24.25-.42.5-.42h4c.25 0 .46.18.5.42l.37 2.65c.63.25 1.17.59 1.69.98l2.49-1c.22-.09.49 0 .61.22l2 3.46c.13.22.07.49-.12.64L19.43 11l.07 1-.07 1 2.11 1.63c.19.15.25.42.12.64l-2 3.46c-.12.22-.39.31-.61.22l-2.49-1c-.52.39-1.06.73-1.69.98l-.37 2.65c-.04.24-.25.42-.5.42zm1.25-18-.37 2.61c-1.2.25-2.26.89-3.03 1.78L5.44 7.35l-.75 1.3L6.8 10.2a5.55 5.55 0 0 0 0 3.6l-2.12 1.56.75 1.3 2.43-1.04c.77.88 1.82 1.52 3.01 1.76l.37 2.62h1.52l.37-2.61c1.19-.25 2.24-.89 3.01-1.77l2.43 1.04.75-1.3-2.12-1.55c.4-1.17.4-2.44 0-3.61l2.11-1.55-.75-1.3-2.41 1.04a5.42 5.42 0 0 0-3.03-1.77L12.75 4z' | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default MyActivitiesIcon; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React from 'react'; | ||
|
|
||
| const MyReservationIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 24 24' | ||
| {...props} | ||
| > | ||
| <path | ||
| fill='#000' | ||
| d='m17 21-2.75-3 1.16-1.16L17 18.43l3.59-3.59 1.16 1.41M12.8 21H5c-1.11 0-2-.89-2-2V5c0-1.11.89-2 2-2h14c1.11 0 2 .89 2 2v7.8c-.61-.35-1.28-.6-2-.72V5H5v14h7.08c.12.72.37 1.39.72 2m-.8-4H7v-2h5m2.68-2H7v-2h10v1.08c-.85.14-1.63.46-2.32.92M17 9H7V7h10' | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default MyReservationIcon; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,19 @@ | ||
| import React from 'react'; | ||
|
|
||
| const MyUsersIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 24 24' | ||
| {...props} | ||
| > | ||
| <path | ||
| fill='#000' | ||
| d='m21.1 12.5 1.4 1.41-6.53 6.59L12.5 17l1.4-1.41 2.07 2.08zM11 4a4 4 0 1 1 0 8 4 4 0 0 1 0-8m0 2a2 2 0 1 0 0 4 2 2 0 0 0 0-4m0 7c.68 0 1.5.09 2.41.26l-1.67 1.67-.74-.03c-2.97 0-6.1 1.46-6.1 2.1v1.1h6.2L13 20H3v-3c0-2.66 5.33-4 8-4' | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default MyUsersIcon; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import React from 'react'; | ||
|
|
||
| const PenIcon = ({ size = 24, ...props }) => ( | ||
| <svg | ||
| xmlns='http://www.w3.org/2000/svg' | ||
| width={size} | ||
| height={size} | ||
| fill='none' | ||
| viewBox='0 0 25 24' | ||
| {...props} | ||
| > | ||
| <path | ||
| stroke='#fff' | ||
| strokeLinecap='round' | ||
| strokeLinejoin='round' | ||
| strokeWidth='2.063' | ||
| d='M17.31 6.06 4.554 18.848l-.773 1.87 1.871-.772L18.44 7.19zm2.553-2.552-.553.552 1.13 1.13.552-.553a.774.774 0 0 0 0-1.094l-.035-.035a.774.774 0 0 0-1.094 0' | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| export default PenIcon; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,19 @@ | ||||||||||||||||||
| import React from 'react'; | ||||||||||||||||||
|
|
||||||||||||||||||
| const ProfileDefaultIcon = ({ size = 24, ...props }) => ( | ||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) Props 타입 선언이 빠져 있습니다 TSX 파일인데 -import React from 'react';
-
-const ProfileDefaultIcon = ({ size = 24, ...props }) => (
+import React, { type SVGProps } from 'react';
+
+interface ProfileDefaultIconProps extends SVGProps<SVGSVGElement> {
+ size?: number;
+}
+
+const ProfileDefaultIcon = ({ size = 24, ...props }: ProfileDefaultIconProps) => (📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||
| <svg | ||||||||||||||||||
| xmlns='http://www.w3.org/2000/svg' | ||||||||||||||||||
| width={size} | ||||||||||||||||||
| height={size} | ||||||||||||||||||
| fill='none' | ||||||||||||||||||
| viewBox='0 0 160 160' | ||||||||||||||||||
| {...props} | ||||||||||||||||||
| > | ||||||||||||||||||
| <path | ||||||||||||||||||
| fill='#E3E5E8' | ||||||||||||||||||
| d='M80 0C35.813 0 0 35.813 0 80c0 44.188 35.813 80 80 80 44.188 0 80-35.812 80-80 0-44.187-35.812-80-80-80m0 40c12.428 0 22.5 10.075 22.5 22.5S92.438 85 80 85c-12.425 0-22.5-10.075-22.5-22.5S67.563 40 80 40m0 100c-16.54 0-31.531-6.728-42.406-17.591C42.656 109.344 55.156 100 70 100h20c14.856 0 27.356 9.337 32.406 22.409C111.531 133.281 96.531 140 80 140' | ||||||||||||||||||
| /> | ||||||||||||||||||
| </svg> | ||||||||||||||||||
| ); | ||||||||||||||||||
|
|
||||||||||||||||||
| export default ProfileDefaultIcon; | ||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<User> => { | ||
| const response = await privateInstance.get('/users/me'); | ||
| return response.data; | ||
| }; | ||
|
|
||
| /** | ||
| * 내 정보 수정 | ||
| * PATCH /api/users/me | ||
| */ | ||
| export const updateMyProfile = async ( | ||
| data: UpdateProfileRequest, | ||
| ): Promise<User> => { | ||
| const response = await privateInstance.patch('/users/me', data); | ||
| return response.data; | ||
| }; | ||
|
|
||
| /** | ||
| * 프로필 이미지 업로드 | ||
| * POST /api/users/me/image | ||
| */ | ||
| export const uploadProfileImage = async ( | ||
| file: File, | ||
| ): Promise<ProfileImageResponse> => { | ||
| 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; | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| export default function MyActivitiesPage() { | ||
| return ( | ||
| <> | ||
| {/* 제목 */} | ||
| <div className='mb-48'> | ||
| <h1 className='text-nomad text-[32px] leading-[42px] font-bold'> | ||
| 내 체험 관리 | ||
| </h1> | ||
| </div> | ||
|
|
||
| {/* 내 체험 관리 컨텐츠 */} | ||
| <div className='mx-auto w-full max-w-[343px] md:max-w-[429px] lg:mx-0 lg:max-w-[792px]'> | ||
| <p className='text-lg text-gray-600'>내 체험 관리 페이지입니다.</p> | ||
| {/* TODO: 내 체험 관리 컴포넌트 구현 */} | ||
| </div> | ||
| </> | ||
| ); | ||
| } | ||
|
Comment on lines
+1
to
+18
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 컴포넌트 구조가 깔끔하고 반응형 디자인이 잘 구현되었습니다. 기본적인 페이지 구조와 반응형 레이아웃이 올바르게 구현되어 있습니다. 향후 실제 컨텐츠 구현 시 일관성을 위해 몇 가지 제안사항이 있습니다. 향후 구현 시 고려할 개선사항: +interface MyActivitiesPageProps {
+ // 필요시 props 타입 정의
+}
+
-export default function MyActivitiesPage() {
+export default function MyActivitiesPage({}: MyActivitiesPageProps = {}) {또한 일관된 스타일링을 위해 스타일 상수나 공통 컴포넌트 사용을 고려해보세요: const CONTAINER_STYLES = 'mx-auto w-full max-w-[343px] md:max-w-[429px] lg:mx-0 lg:max-w-[792px]';🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className={cn('relative inline-block', className)}> | ||
| {/* 프로필 이미지 컨테이너 */} | ||
| <div className='relative h-160 w-160 overflow-hidden rounded-full bg-gray-200 shadow-lg'> | ||
| {hasValidImage ? ( | ||
| <Image | ||
| src={src} | ||
| alt={alt || `${nickname}의 프로필 이미지`} | ||
| fill | ||
| className='object-cover' | ||
| onError={handleImageError} | ||
| sizes='160px' | ||
| /> | ||
| ) : ( | ||
| // 기본 프로필 아이콘 | ||
| <div className='flex h-full w-full items-center justify-center'> | ||
| <ProfileDefaultIcon size={160} /> | ||
| </div> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* 편집 버튼 */} | ||
| {showEditButton && ( | ||
| <button | ||
| onClick={onEdit} | ||
| className='absolute right-0 bottom-0 flex h-32 w-32 items-center justify-center rounded-full bg-green-300 shadow-lg transition-colors hover:bg-green-200 focus:ring-2 focus:ring-green-300 focus:ring-offset-2 focus:outline-none' | ||
| aria-label='프로필 이미지 편집' | ||
| type='button' | ||
| > | ||
| <PenIcon size={20} /> | ||
| </button> | ||
| )} | ||
| </div> | ||
| ); | ||
| } | ||
|
Comment on lines
+35
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick (assertive) 프로필 이미지 컴포넌트가 잘 구현되었습니다! 이미지 에러 처리, URL 유효성 검사, 접근성 고려가 모두 적절합니다. 다만, 고정 크기(160x160)를 사용하고 있어 반응형 디자인을 위해 크기를 props로 받는 것을 고려해보세요. 🤖 Prompt for AI Agents |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <div className='hidden flex-shrink-0 md:block'> | ||
| <div className='h-432 w-251 rounded border border-gray-300 bg-white p-24 lg:w-384'> | ||
| {/* 프로필 이미지 섹션 */} | ||
| <div className='mb-32 text-center'> | ||
| <ProfileImage | ||
| src={user?.profileImageUrl} | ||
| nickname={user?.nickname} | ||
| showEditButton={true} | ||
| onEdit={handleImageEdit} | ||
| /> | ||
|
|
||
| {/* 숨겨진 파일 입력 */} | ||
| <input | ||
| ref={fileInputRef} | ||
| type='file' | ||
| accept='image/*' | ||
| onChange={handleFileChange} | ||
| style={{ display: 'none' }} | ||
| /> | ||
| </div> | ||
|
|
||
| {/* 네비게이션 메뉴 섹션 */} | ||
| <div className='space-y-2'> | ||
| {menuItems.map(({ href, icon: Icon, label }) => ( | ||
| <Link | ||
| key={href} | ||
| href={href} | ||
| className={`flex h-44 w-203 items-center gap-12 rounded-xl px-16 transition-colors lg:w-336 ${ | ||
| isActive(href) | ||
| ? 'bg-green-200 text-green-300' | ||
| : 'text-gray-800 hover:bg-gray-100' | ||
| }`} | ||
| > | ||
| <Icon size={24} /> | ||
| <span className='font-regular text-lg'>{label}</span> | ||
| </Link> | ||
| ))} | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export { default as ProfileImage } from './ProfileImage'; | ||
| export { default as ProfileNavigation } from './ProfileNavigation'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧹 Nitpick (assertive)
Props 타입 선언 누락
파일 확장자가
.tsx인 만큼 TypeScript 타입을 명시하면 컴파일 단계에서 오타·누락을 예방할 수 있습니다.📝 Committable suggestion
🤖 Prompt for AI Agents