Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
19 changes: 19 additions & 0 deletions public/assets/svg/my-activities-dashboard.tsx
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;
19 changes: 19 additions & 0 deletions public/assets/svg/my-activities.tsx
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;
19 changes: 19 additions & 0 deletions public/assets/svg/my-reservation.tsx
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;
19 changes: 19 additions & 0 deletions public/assets/svg/my-user.tsx
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;
22 changes: 22 additions & 0 deletions public/assets/svg/pen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import React from 'react';

const PenIcon = ({ size = 24, ...props }) => (
Copy link

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 타입을 명시하면 컴파일 단계에서 오타·누락을 예방할 수 있습니다.

-const PenIcon = ({ size = 24, ...props }) => (
+interface PenIconProps extends React.SVGProps<SVGSVGElement> {
+  size?: number;
+}
+
+const PenIcon: React.FC<PenIconProps> = ({ size = 24, ...props }) => (
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const PenIcon = ({ size = 24, ...props }) => (
interface PenIconProps extends React.SVGProps<SVGSVGElement> {
size?: number;
}
const PenIcon: React.FC<PenIconProps> = ({ size = 24, ...props }) => (
🤖 Prompt for AI Agents
In public/assets/svg/pen.tsx at line 3, the PenIcon component lacks explicit
TypeScript type declarations for its props. Define a proper interface or type
for the props, including the size property and any other props, and annotate the
PenIcon function parameter with this type to ensure type safety and prevent
typos or omissions during compilation.

<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;
19 changes: 19 additions & 0 deletions public/assets/svg/profile-default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';

const ProfileDefaultIcon = ({ size = 24, ...props }) => (
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

Props 타입 선언이 빠져 있습니다

TSX 파일인데 props가 암시적 any 입니다. SVGProps<SVGSVGElement>를 활용해 명시적으로 선언하면 IDE 지원과 타입 안전성이 향상됩니다.

-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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const ProfileDefaultIcon = ({ size = 24, ...props }) => (
import React, { type SVGProps } from 'react';
interface ProfileDefaultIconProps extends SVGProps<SVGSVGElement> {
size?: number;
}
const ProfileDefaultIcon = ({ size = 24, ...props }: ProfileDefaultIconProps) => (
🤖 Prompt for AI Agents
In public/assets/svg/profile-default.tsx at line 3, the props parameter lacks an
explicit type declaration, causing it to be implicitly any. To fix this, import
SVGProps from React and declare the component props as ({ size = 24, ...props }:
SVGProps<SVGSVGElement>) to ensure proper type checking and IDE support.

<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;
44 changes: 44 additions & 0 deletions src/apis/mypage.ts
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;
};
18 changes: 18 additions & 0 deletions src/app/(with-header)/mypage/activities/page.tsx
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
Copy link

Choose a reason for hiding this comment

The 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
In src/app/(with-header)/mypage/activities/page.tsx lines 1 to 18, the component
structure and responsive design are well implemented. To improve consistency and
maintainability in future content additions, extract repeated or complex
className strings like container styles into style constants or shared
components. Define a constant for the container styles and replace the inline
className string with this constant to ensure uniform styling and easier
updates.

87 changes: 87 additions & 0 deletions src/app/(with-header)/mypage/components/ProfileImage.tsx
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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick (assertive)

프로필 이미지 컴포넌트가 잘 구현되었습니다!

이미지 에러 처리, URL 유효성 검사, 접근성 고려가 모두 적절합니다. 다만, 고정 크기(160x160)를 사용하고 있어 반응형 디자인을 위해 크기를 props로 받는 것을 고려해보세요.

🤖 Prompt for AI Agents
In src/app/(with-header)/mypage/components/ProfileImage.tsx around lines 35 to
87, the ProfileImage component uses a fixed size of 160x160 for the image and
container. To improve responsiveness, add a size prop to the component to allow
dynamic sizing. Replace all hardcoded 160 values with this size prop, providing
a default value if none is passed, so the component can adapt to different
layouts.

91 changes: 91 additions & 0 deletions src/app/(with-header)/mypage/components/ProfileNavigation.tsx
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>
);
}
2 changes: 2 additions & 0 deletions src/app/(with-header)/mypage/components/index.ts
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';
Loading