diff --git a/public/assets/icons/ic-edit.svg b/public/assets/icons/ic-edit.svg new file mode 100644 index 00000000..8db6e3d9 --- /dev/null +++ b/public/assets/icons/ic-edit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/public/assets/icons/profile-edit.svg b/public/assets/icons/profile-edit.svg deleted file mode 100644 index 9247cbe5..00000000 --- a/public/assets/icons/profile-edit.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/src/_apis/auth/user-apis.ts b/src/_apis/auth/user-apis.ts index d160523b..43b7c765 100644 --- a/src/_apis/auth/user-apis.ts +++ b/src/_apis/auth/user-apis.ts @@ -9,3 +9,44 @@ export function getUser(): Promise { }, }).then((response) => response.data); } + +// 회원정보 수정 +export async function updateUserProfile(file: File): Promise { + const url = `/auths/user`; + + const formData = new FormData(); + formData.append('file', file); + + await fetchApi(url, { + method: 'PUT', + body: formData, + }); +} + +export async function fetchUpdatedUser() { + return fetchApi<{ data: User }>('/auths/user', { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + 'Cache-Control': 'no-cache', + }, + }) + .then((response) => { + return response.data; + }) + .catch((error) => { + throw error; + }); +} + +// 유저 프로필 이미지 초기화 +export async function resetUserProfileImage(): Promise { + const url = `/auths/profile-image/reset`; + + await fetchApi(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/app/(crew)/my-page/_components/profile-card/container.tsx b/src/app/(crew)/my-page/_components/profile-card/container.tsx new file mode 100644 index 00000000..e4747c23 --- /dev/null +++ b/src/app/(crew)/my-page/_components/profile-card/container.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { toast } from 'react-toastify'; +import { useRouter } from 'next/navigation'; +import { + fetchUpdatedUser, + resetUserProfileImage, + updateUserProfile, +} from '@/src/_apis/auth/user-apis'; +import { useAuthStore } from '@/src/store/use-auth-store'; +import { User } from '@/src/types/auth'; +import ProfileCardPresenter from './presenter'; + +export default function ProfileCard() { + const router = useRouter(); + const { isAuth, rehydrated, setUser } = useAuthStore(); + const [user, setLocalUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [profileImageUrl, setProfileImageUrl] = useState(''); + + useEffect(() => { + const checkAuthAndLoadUser = async () => { + if (!rehydrated) return; // 상태 복원이 완료되지 않았으면 대기 + + if (!isAuth) { + router.push('/login'); // 인증되지 않은 경우 리디렉션 + return; + } + + setIsLoading(true); + + try { + const updatedUser = await fetchUpdatedUser(); + setLocalUser(updatedUser); + setUser(updatedUser); + setProfileImageUrl(updatedUser.profileImageUrl); + } catch { + toast.error('유저 정보를 가져오는 데 실패했습니다.'); + } finally { + setIsLoading(false); + } + }; + + checkAuthAndLoadUser(); + }, [isAuth, rehydrated, router, setUser]); + + if (!rehydrated) return null; + if (!isAuth) return null; + if (isLoading) return
로딩 중...
; + if (!user) return
유저 정보를 불러오지 못했습니다.
; + + const handleEdit = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.png,.jpg,.jpeg'; + input.onchange = async (event) => { + const file = (event.target as HTMLInputElement)?.files?.[0]; + if (file) { + if (file.size > 5 * 1024 * 1024) { + alert('5MB 이하의 파일만 업로드 가능합니다.'); + return; + } + + try { + await updateUserProfile(file); + + const tempUrl = URL.createObjectURL(file); + setProfileImageUrl(tempUrl); + + const updatedUser = await fetchUpdatedUser(); + const newProfileImageUrl = `${updatedUser.profileImageUrl}?timestamp=${new Date().getTime()}`; + setProfileImageUrl(newProfileImageUrl); + setUser({ ...updatedUser, profileImageUrl: newProfileImageUrl }); + } catch (error) { + toast.error('파일 업로드에 실패했습니다.'); + } + } + }; + input.click(); + }; + + const handleDeleteProfile = async () => { + try { + await resetUserProfileImage(); + const updatedUser = await fetchUpdatedUser(); + setProfileImageUrl(''); // 초기화된 이미지 반영 + setLocalUser(updatedUser); + setUser(updatedUser); + toast.success('프로필 이미지가 초기화되었습니다.'); + } catch (error) { + toast.error('프로필 이미지 초기화에 실패했습니다.'); + } + }; + + return ( + + ); +} diff --git a/src/app/(crew)/my-page/_components/profile-card/presenter.tsx b/src/app/(crew)/my-page/_components/profile-card/presenter.tsx new file mode 100644 index 00000000..5488c91a --- /dev/null +++ b/src/app/(crew)/my-page/_components/profile-card/presenter.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { Menu } from '@mantine/core'; +import { Profile } from '@/src/components/common/profile'; +import { UserType } from '@/src/types/user'; + +export interface ProfileCardProps { + data: UserType; + onEdit: () => void; + onDelete: () => void; +} + +export default function ProfileCardPresenter({ data, onEdit, onDelete }: ProfileCardProps) { + return ( +
+
+
+ + +
+ +
+
+ + 프로필 이미지 수정하기 + + 기본 프로필로 돌아가기 + + +
+
+
+

+ {data?.nickname} 님, 안녕하세요🙌 +

+
+
Email
+
{data?.email}
+
+
+
+
+ ); +} diff --git a/src/app/(crew)/mypage/page.tsx b/src/app/(crew)/my-page/_components/review-section.tsx similarity index 61% rename from src/app/(crew)/mypage/page.tsx rename to src/app/(crew)/my-page/_components/review-section.tsx index d5ffaaaf..b9365ba0 100644 --- a/src/app/(crew)/mypage/page.tsx +++ b/src/app/(crew)/my-page/_components/review-section.tsx @@ -3,23 +3,15 @@ import { useState } from 'react'; import { Divider } from '@mantine/core'; import { useInfiniteScroll } from '@/src/hooks/use-infinite-scroll'; -import ProfileCardContainer from '@/src/app/(crew)/mypage/_components/profile-card/container'; +import { fetchWritableGatheringData } from '@/src/app/(crew)/api/mock-api/writable-gathering'; +import { fetchMyReviewData } from '@/src/app/api/mock-api/review'; import ReviewCardList from '@/src/components/common/review-list/review-card-list'; import Tabs from '@/src/components/common/tab'; import WritableGatheringCardList from '@/src/components/common/writable-gathering-card/writable-gathering-card-list'; import { ReviewInformResponse } from '@/src/types/review'; import { WritableGatheringCardInformResponse } from '@/src/types/writable-gathering-card'; -import { fetchMyReviewData } from '../../api/mock-api/review'; -import { fetchWritableGatheringData } from '../api/mock-api/writable-gathering'; -const mockData = { - id: 1, - profileImageUrl: '', - nickname: '율율', - email: 'youlyoul@email.com', -}; - -export default function MyPage() { +export default function ReviewSection() { const myPageTabs = [ { label: '작성 가능한 리뷰', id: 'available-review' }, { label: '작성한 리뷰', id: 'my-review' }, @@ -43,15 +35,12 @@ export default function MyPage() { isFetchingNextPage: isFetchingGatheringNextPage, } = useInfiniteScroll({ queryKey: ['crew'], - queryFn: ({ pageParam = 0 }) => { - return fetchWritableGatheringData(pageParam, 3); - }, + queryFn: ({ pageParam = 0 }) => fetchWritableGatheringData(pageParam, 3), getNextPageParam: (lastPage, allPages) => lastPage.hasNextPage ? allPages.length + 1 : undefined, }); const renderTabContent = () => { - // TODO : 리턴 값 컴포넌트로 교체 switch (currentTab) { case 'my-review': return ( @@ -73,27 +62,20 @@ export default function MyPage() { ); } }; + return ( -
-
- -
-
-

나의 리뷰 모아보기

- -
- { - setCurrentTab(id); - }} - /> -
-
{renderTabContent()}
+
+

나의 리뷰 모아보기

+ +
+ setCurrentTab(id)} + />
-
+
{renderTabContent()}
); } diff --git a/src/app/(crew)/my-page/page.tsx b/src/app/(crew)/my-page/page.tsx new file mode 100644 index 00000000..aec59289 --- /dev/null +++ b/src/app/(crew)/my-page/page.tsx @@ -0,0 +1,14 @@ +import ProfileCardContainer from '@/src/app/(crew)/my-page/_components/profile-card/container'; +import ReviewSection from '@/src/app/(crew)/my-page/_components/review-section'; + +export default function MyPage() { + return ( +
+
+ +
+ +
+
+ ); +} diff --git a/src/app/(crew)/mypage/_components/profile-card/container.tsx b/src/app/(crew)/mypage/_components/profile-card/container.tsx deleted file mode 100644 index 471b8943..00000000 --- a/src/app/(crew)/mypage/_components/profile-card/container.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -import { UserType } from '@/src/types/user'; -import ProfileCard from './presenter'; - -export interface ProfileCardContainerTypes { - data: UserType; -} - -export default function ProfileCardContainer({ data }: ProfileCardContainerTypes) { - // TODO : API 연결 - const handleEdit = () => {}; - return ; -} diff --git a/src/app/(crew)/mypage/_components/profile-card/presenter.tsx b/src/app/(crew)/mypage/_components/profile-card/presenter.tsx deleted file mode 100644 index fd1896ac..00000000 --- a/src/app/(crew)/mypage/_components/profile-card/presenter.tsx +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { Button } from '@mantine/core'; -import { Profile } from '@/src/components/common/profile'; -import { UserType } from '@/src/types/user'; - -export interface ProfileCardProps { - data: UserType; - onEdit: () => void; -} - -export default function ProfileCard({ data, onEdit }: ProfileCardProps) { - return ( -
-
-
- -
-
-

{data?.nickname} 님, 안녕하세요🙌

-
-
Email
-
{data?.email}
-
-
-
- -
- ); -} diff --git a/src/components/common/header/container.tsx b/src/components/common/header/container.tsx index 501919a0..00ca1dcd 100644 --- a/src/components/common/header/container.tsx +++ b/src/components/common/header/container.tsx @@ -9,11 +9,10 @@ import HeaderPresenter from '@/src/components/common/header/presenter'; * * @param {boolean} isAuth - 로그인 여부를 나타내는 상태 (true: 로그인됨, false: 비로그인) * @param {function} handleLogout - 로그아웃을 처리하는 함수 - * @param {function} toggleCookie - 테스트용으로 쿠키 상태를 토글하는 함수 (컴포넌트 실험용) */ export default function Header() { - const { isAuth, logout } = useAuthStore(); + const { isAuth, user, logout } = useAuthStore(); // user 정보 가져오기 const router = useRouter(); @@ -24,7 +23,11 @@ export default function Header() { return (
- +
); } diff --git a/src/components/common/header/header.stories.tsx b/src/components/common/header/header.stories.tsx index b72ce618..143c1e5f 100644 --- a/src/components/common/header/header.stories.tsx +++ b/src/components/common/header/header.stories.tsx @@ -10,12 +10,6 @@ const meta: Meta = { nextjs: { appDirectory: true, }, - docs: { - description: { - component: - '헤더 컴포넌트. 경로에 따라 링크의 글씨 색이 변합니다. 쿠키를 계속 넣을 수 없어 해당 탭에서만 확인할 수 있습니다.', - }, - }, }, }; @@ -27,7 +21,7 @@ function Template() { id: 1, nickname: '크루크루', email: 'john@example.com', - profileImageUrl: 'https://image.file', + profileImageUrl: 'https://i.pinimg.com/736x/3f/e4/f4/3fe4f4f3aee36ec57aa072cce2e016b3.jpg', }; const toggleAuth = () => { @@ -97,35 +91,3 @@ MyGathering.parameters = { }, }, }; - -// 4. 토글 버튼으로 로그인/비로그인 상태를 변경 -export const WithToggleCookie: StoryFn = Template.bind({}); -WithToggleCookie.parameters = { - nextjs: { - navigation: { - pathname: '/', - query: {}, - }, - }, - docs: { - description: { - story: '로그인/로그아웃 상태를 토글하여 네비게이션이 동적으로 변경되는지를 확인', - }, - }, -}; - -// 5. 로그인 페이지 (/login) 경로 -export const LoginPage: StoryFn = Template.bind({}); -LoginPage.parameters = { - nextjs: { - navigation: { - pathname: '/login', - query: {}, - }, - }, - docs: { - description: { - story: '로그인 페이지에서는 모든 링크가 하얀색으로 보입니다.', - }, - }, -}; diff --git a/src/components/common/header/presenter.tsx b/src/components/common/header/presenter.tsx index abfa9db5..4212fe81 100644 --- a/src/components/common/header/presenter.tsx +++ b/src/components/common/header/presenter.tsx @@ -33,7 +33,7 @@ export default function HeaderPresenter({ crew logo crew logo -