Skip to content

Commit c0f7c99

Browse files
authored
Merge pull request #76 from codeit-2team/feat/61
Feat/61 마이페이지 구현(UI 및 API 연동)
2 parents f89d52a + b8e1857 commit c0f7c99

File tree

25 files changed

+1319
-0
lines changed

25 files changed

+1319
-0
lines changed

next.config.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ const nextConfig: NextConfig = {
44
// Docker 배포를 위한 standalone 모드 활성화
55
// 해당 설정은 프로덕션 빌드 시 필요한 파일만 .next/standalone 폴더에 복사됨.
66
output: 'standalone',
7+
8+
// 외부 이미지 도메인 허용
9+
images: {
10+
remotePatterns: [
11+
{
12+
protocol: 'https',
13+
hostname: 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com',
14+
port: '',
15+
pathname: '/globalnomad/**',
16+
},
17+
],
18+
},
719
};
820

921
export default nextConfig;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
const MyActivitiesDashboardIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 24 24'
10+
{...props}
11+
>
12+
<path
13+
fill='#000'
14+
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'
15+
/>
16+
</svg>
17+
);
18+
19+
export default MyActivitiesDashboardIcon;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
const MyActivitiesIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 24 24'
10+
{...props}
11+
>
12+
<path
13+
fill='#000'
14+
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'
15+
/>
16+
</svg>
17+
);
18+
19+
export default MyActivitiesIcon;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
const MyReservationIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 24 24'
10+
{...props}
11+
>
12+
<path
13+
fill='#000'
14+
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'
15+
/>
16+
</svg>
17+
);
18+
19+
export default MyReservationIcon;

public/assets/svg/my-user.tsx

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
const MyUsersIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 24 24'
10+
{...props}
11+
>
12+
<path
13+
fill='#000'
14+
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'
15+
/>
16+
</svg>
17+
);
18+
19+
export default MyUsersIcon;

public/assets/svg/pen.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import React from 'react';
2+
3+
const PenIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 25 24'
10+
{...props}
11+
>
12+
<path
13+
stroke='#fff'
14+
strokeLinecap='round'
15+
strokeLinejoin='round'
16+
strokeWidth='2.063'
17+
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'
18+
/>
19+
</svg>
20+
);
21+
22+
export default PenIcon;
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import React from 'react';
2+
3+
const ProfileDefaultIcon = ({ size = 24, ...props }) => (
4+
<svg
5+
xmlns='http://www.w3.org/2000/svg'
6+
width={size}
7+
height={size}
8+
fill='none'
9+
viewBox='0 0 160 160'
10+
{...props}
11+
>
12+
<path
13+
fill='#E3E5E8'
14+
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'
15+
/>
16+
</svg>
17+
);
18+
19+
export default ProfileDefaultIcon;

src/apis/mypage.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import { privateInstance } from './privateInstance';
2+
import { User } from '@/types/user';
3+
import {
4+
ProfileImageResponse,
5+
UpdateProfileRequest,
6+
} from '@/types/mypageTypes';
7+
8+
/**
9+
* 내 정보 조회
10+
* GET /api/users/me
11+
*/
12+
export const getMyProfile = async (): Promise<User> => {
13+
const response = await privateInstance.get('/users/me');
14+
return response.data;
15+
};
16+
17+
/**
18+
* 내 정보 수정
19+
* PATCH /api/users/me
20+
*/
21+
export const updateMyProfile = async (
22+
data: UpdateProfileRequest,
23+
): Promise<User> => {
24+
const response = await privateInstance.patch('/users/me', data);
25+
return response.data;
26+
};
27+
28+
/**
29+
* 프로필 이미지 업로드
30+
* POST /api/users/me/image
31+
*/
32+
export const uploadProfileImage = async (
33+
file: File,
34+
): Promise<ProfileImageResponse> => {
35+
const formData = new FormData();
36+
formData.append('image', file);
37+
38+
const response = await privateInstance.post('/users/me/image', formData, {
39+
headers: {
40+
'Content-Type': 'multipart/form-data',
41+
},
42+
});
43+
return response.data;
44+
};
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export default function MyActivitiesPage() {
2+
return (
3+
<>
4+
{/* 제목 */}
5+
<div className='mb-48'>
6+
<h1 className='text-nomad text-[32px] leading-[42px] font-bold'>
7+
내 체험 관리
8+
</h1>
9+
</div>
10+
11+
{/* 내 체험 관리 컨텐츠 */}
12+
<div className='mx-auto w-full max-w-[343px] md:max-w-[429px] lg:mx-0 lg:max-w-[792px]'>
13+
<p className='text-lg text-gray-600'>내 체험 관리 페이지입니다.</p>
14+
{/* TODO: 내 체험 관리 컴포넌트 구현 */}
15+
</div>
16+
</>
17+
);
18+
}
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import Image from 'next/image';
5+
import cn from '@/lib/cn';
6+
import { ProfileImageProps } from '@/types/mypageTypes';
7+
import PenIcon from '@assets/svg/pen';
8+
import ProfileDefaultIcon from '@assets/svg/profile-default';
9+
10+
/**
11+
* @component ProfileImage
12+
* @description
13+
* 마이페이지 전용 프로필 이미지 컴포넌트입니다.
14+
*
15+
* @param {ProfileImageProps} props - ProfileImage 컴포넌트의 props
16+
* @param {string} [props.src] - 프로필 이미지 URL
17+
* @param {string} [props.alt] - 이미지 alt 텍스트
18+
* @param {string} [props.nickname='사용자'] - 사용자 닉네임
19+
* @param {boolean} [props.showEditButton=false] - 편집 버튼 표시 여부
20+
* @param {() => void} [props.onEdit] - 편집 버튼 클릭 핸들러
21+
* @param {string} [props.className] - 추가 CSS 클래스
22+
*/
23+
24+
function isValidUrl(url: string): boolean {
25+
if (!url || url.trim() === '') return false;
26+
27+
try {
28+
new URL(url);
29+
return true;
30+
} catch {
31+
return false;
32+
}
33+
}
34+
35+
export default function ProfileImage({
36+
src,
37+
alt,
38+
nickname = '사용자',
39+
showEditButton = false,
40+
onEdit,
41+
className,
42+
}: ProfileImageProps) {
43+
const [imageError, setImageError] = useState(false);
44+
45+
// 이미지 로딩 에러 핸들러
46+
const handleImageError = () => {
47+
setImageError(true);
48+
};
49+
50+
// URL 유효성 검사
51+
const hasValidImage = src && isValidUrl(src) && !imageError;
52+
53+
return (
54+
<div className={cn('relative inline-block', className)}>
55+
{/* 프로필 이미지 컨테이너 */}
56+
<div className='relative h-160 w-160 overflow-hidden rounded-full bg-gray-200 shadow-lg'>
57+
{hasValidImage ? (
58+
<Image
59+
src={src}
60+
alt={alt || `${nickname}의 프로필 이미지`}
61+
fill
62+
className='object-cover'
63+
onError={handleImageError}
64+
sizes='160px'
65+
/>
66+
) : (
67+
// 기본 프로필 아이콘
68+
<div className='flex h-full w-full items-center justify-center'>
69+
<ProfileDefaultIcon size={160} />
70+
</div>
71+
)}
72+
</div>
73+
74+
{/* 편집 버튼 */}
75+
{showEditButton && (
76+
<button
77+
onClick={onEdit}
78+
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'
79+
aria-label='프로필 이미지 편집'
80+
type='button'
81+
>
82+
<PenIcon size={20} />
83+
</button>
84+
)}
85+
</div>
86+
);
87+
}

0 commit comments

Comments
 (0)