From 50bad88bbe6051629d5dd4b0d8f622ead79c9414 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Thu, 19 Feb 2026 07:26:37 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20UI,?= =?UTF-8?q?=20=EB=B0=8F=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/mypage/hooks/index.ts | 1 + src/app/(root)/mypage/hooks/useUser.ts | 128 +++++++++ src/app/(root)/mypage/page.module.css | 226 ++++++++++++++++ src/app/(root)/mypage/page.tsx | 357 +++++++++++++++++++++++++ src/shared/apis/user/index.ts | 2 + src/shared/apis/user/types.ts | 59 ++++ src/shared/apis/user/userApi.ts | 77 ++++++ 7 files changed, 850 insertions(+) create mode 100644 src/app/(root)/mypage/hooks/index.ts create mode 100644 src/app/(root)/mypage/hooks/useUser.ts create mode 100644 src/app/(root)/mypage/page.module.css create mode 100644 src/app/(root)/mypage/page.tsx create mode 100644 src/shared/apis/user/index.ts create mode 100644 src/shared/apis/user/types.ts create mode 100644 src/shared/apis/user/userApi.ts diff --git a/src/app/(root)/mypage/hooks/index.ts b/src/app/(root)/mypage/hooks/index.ts new file mode 100644 index 0000000..7fadb7f --- /dev/null +++ b/src/app/(root)/mypage/hooks/index.ts @@ -0,0 +1 @@ +export { useUser } from './useUser'; diff --git a/src/app/(root)/mypage/hooks/useUser.ts b/src/app/(root)/mypage/hooks/useUser.ts new file mode 100644 index 0000000..590fbd3 --- /dev/null +++ b/src/app/(root)/mypage/hooks/useUser.ts @@ -0,0 +1,128 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; + +import { getUser, updateUser, deleteUser, changePassword, uploadImage } from '@/shared/apis/user'; +import type { UserResponse } from '@/shared/apis/user'; + +export function useUser() { + const router = useRouter(); + + const [user, setUser] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + // 폼 상태 + const [name, setName] = useState(''); + const [originalName, setOriginalName] = useState(''); + const [profileImage, setProfileImage] = useState(null); + + const hasChanges = name !== originalName || profileImage !== user?.image; + + // 유저 정보 조회 + useEffect(() => { + async function fetchUser() { + try { + setError(null); + const data = await getUser(); + setUser(data); + // 폼 초기화 + setName(data.nickname); + setOriginalName(data.nickname); + setProfileImage(data.image); + } catch (err) { + console.error('유저 정보 조회 실패:', err); + setError('유저 정보를 불러오는데 실패했습니다.'); + } finally { + setIsLoading(false); + } + } + + fetchUser(); + }, []); + + // 프로필 수정 + const updateProfile = useCallback(async () => { + try { + setError(null); + await updateUser({ + nickname: name, + image: profileImage ?? undefined, + }); + // 수정 성공 후 유저 정보 다시 조회 + const refreshedUser = await getUser(); + setUser(refreshedUser); + setOriginalName(refreshedUser.nickname); + return { success: true }; + } catch (err) { + console.error('프로필 수정 실패:', err); + setError('프로필 수정에 실패했습니다.'); + return { success: false }; + } + }, [name, profileImage]); + + // 회원 탈퇴 + const deleteAccount = useCallback(async () => { + try { + setError(null); + await deleteUser(); + router.push('/'); + return { success: true }; + } catch (err) { + console.error('회원 탈퇴 실패:', err); + setError('회원 탈퇴에 실패했습니다.'); + return { success: false }; + } + }, [router]); + + // 비밀번호 변경 + const updatePassword = useCallback( + async (data: { password: string; passwordConfirmation: string }) => { + try { + setError(null); + await changePassword(data); + return { success: true }; + } catch (err) { + console.error('비밀번호 변경 실패:', err); + setError('비밀번호 변경에 실패했습니다.'); + return { success: false }; + } + }, + [], + ); + + // 프로필 이미지 업로드 + const uploadProfileImage = useCallback(async (file: File) => { + try { + setError(null); + const { url } = await uploadImage(file); + return { success: true, url }; + } catch (err) { + console.error('이미지 업로드 실패:', err); + setError('이미지 업로드에 실패했습니다.'); + return { success: false, url: null }; + } + }, []); + + // 팀 목록 + const teams = user?.memberships.map((m) => m.group.name) ?? []; + + return { + user, + teams, + isLoading, + error, + // 폼 상태 + name, + setName, + profileImage, + setProfileImage, + hasChanges, + // 액션 + updateProfile, + deleteAccount, + updatePassword, + uploadProfileImage, + }; +} diff --git a/src/app/(root)/mypage/page.module.css b/src/app/(root)/mypage/page.module.css new file mode 100644 index 0000000..fff15e5 --- /dev/null +++ b/src/app/(root)/mypage/page.module.css @@ -0,0 +1,226 @@ +.layout { + display: flex; + flex-direction: column; + min-height: 100vh; + background-color: var(--color-background-secondary); +} + +/* 모바일 헤더 - 모바일에서만 표시 */ +.mobileHeader { + display: none; +} + +/* 사이드바 - 태블릿/데스크탑에서 표시 */ +.sidebar { + display: block; +} + +/* 모바일 타이틀 - 모바일에서만 표시 */ +.mobileTitle { + display: none; +} + +.main { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + padding: 40px 24px; +} + +.cardWrapper { + position: relative; + width: 100%; + max-width: 792px; +} + +.card { + width: 100%; + background-color: var(--color-background-inverse); + border-radius: 16px; + padding: 32px; +} + +.title { + font-size: 20px; + font-weight: 700; + color: var(--color-text-primary); + margin: 0 0 32px; +} + +.profileImageWrapper { + display: flex; + justify-content: center; + margin-bottom: 32px; +} + +.form { + display: flex; + flex-direction: column; + gap: 24px; +} + +.field { + display: flex; + flex-direction: column; + gap: 8px; +} + +.label { + font-size: 14px; + font-weight: 500; + color: var(--color-text-primary); +} + +.passwordField { + position: relative; +} + +.passwordInput { + padding-right: 100px; +} + +.changeButton { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); +} + +.withdrawButton { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 0; + border: none; + background: none; + font-size: 14px; + font-weight: 500; + color: var(--color-status-danger); + cursor: pointer; + margin-top: 8px; +} + +.withdrawButton:hover { + text-decoration: underline; +} + +.toastWrapper { + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + width: 90%; + margin-top: 24px; +} + +.toast { + position: static; + width: 100%; +} + +/* 태블릿/데스크탑 레이아웃 */ +@media (min-width: 768px) { + .layout { + flex-direction: row; + } +} + +/* 태블릿 (767px 이하) */ +@media (max-width: 767px) { + .layout { + flex-direction: row; + } + + .main { + padding: 32px 24px; + } + + .card { + max-width: 100%; + } +} + +/* 모바일 (375px 이하) */ +@media (max-width: 375px) { + .layout { + flex-direction: column; + } + + .mobileHeader { + display: block; + } + + .sidebar { + display: none; + } + + .mobileTitle { + display: block; + font-size: 16px; + font-weight: 600; + color: var(--color-text-primary); + margin: 0 0 16px; + align-self: flex-start; + } + + .main { + flex: 1; + justify-content: flex-start; + padding: 24px 16px; + } + + .card { + padding: 24px 16px; + border-radius: 12px; + } + + .title { + display: none; + } + + .profileImageWrapper { + margin-bottom: 24px; + } + + .form { + gap: 16px; + } + + .label { + font-size: 12px; + } + + /* 모바일 모달 - 하단 고정 */ + :global(section[class*='overlay']) { + align-items: flex-end !important; + } + + :global(section[class*='overlay'] > div[class*='contentsBox']) { + width: 100% !important; + max-width: 100% !important; + border-radius: 12px 12px 0 0 !important; + } + + :global([class*='modalContent']) { + width: 100% !important; + max-width: 100% !important; + height: auto !important; + } + + :global([class*='field']) { + width: 100% !important; + } + + :global([class*='actions']) { + width: 100% !important; + } + + :global([class*='closeButton']), + :global([class*='submitButton']), + :global([class*='confirmButton']) { + flex: 1 !important; + width: auto !important; + } +} diff --git a/src/app/(root)/mypage/page.tsx b/src/app/(root)/mypage/page.tsx new file mode 100644 index 0000000..e4c1403 --- /dev/null +++ b/src/app/(root)/mypage/page.tsx @@ -0,0 +1,357 @@ +'use client'; + +import { useState } from 'react'; +import Image from 'next/image'; + +import { useUser } from './hooks'; + +import Sidebar from '@/components/sidebar/Sidebar'; +import SidebarButton from '@/components/sidebar/SidebarButton'; +import SidebarTeamSelect from '@/components/sidebar/SidebarTeamSelect'; +import SidebarAddButton from '@/components/sidebar/SidebarAddButton'; +import MobileHeader from '@/components/sidebar/MobileHeader'; +import MobileDrawer from '@/components/sidebar/MobileDrawer'; +import { ProfileImage } from '@/components/profile-img'; +import Input from '@/components/input/Input'; +import accountInputStyles from '@/components/input/styles/AccountInput.module.css'; +import BaseButton from '@/components/Button/base/BaseButton'; +import Toast from '@/components/toast/Toast'; +import ChangePassword from '@/components/Modal/domain/components/ChangePassword/ChangePassword'; +import WarningModal from '@/components/Modal/domain/components/WarningModal/WarningModal'; + +import outIcon from '@/assets/icons/out/out.svg'; +import chessSmall from '@/assets/icons/chess/chessSmall.svg'; +import chessBig from '@/assets/icons/chess/chessBig.svg'; +import boardSmall from '@/assets/icons/board/boardSmall.svg'; +import boardLarge from '@/assets/icons/board/boardLarge.svg'; + +import styles from './page.module.css'; + +export default function ProfilePage() { + const { + user, + teams, + isLoading, + name, + setName, + profileImage, + setProfileImage, + hasChanges, + updateProfile, + deleteAccount, + updatePassword, + uploadProfileImage, + } = useUser(); + + const [showToast, setShowToast] = useState(false); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + const [isPasswordModalOpen, setIsPasswordModalOpen] = useState(false); + const [isWithdrawModalOpen, setIsWithdrawModalOpen] = useState(false); + const [newPassword, setNewPassword] = useState(''); + const [confirmPassword, setConfirmPassword] = useState(''); + + const handleNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + setShowToast(true); + }; + + const handleSave = async () => { + const result = await updateProfile(); + if (result.success) { + setShowToast(false); + } + }; + + const handleChangePassword = () => { + setIsPasswordModalOpen(true); + }; + + const handlePasswordSubmit = async () => { + const result = await updatePassword({ + password: newPassword, + passwordConfirmation: confirmPassword, + }); + if (result.success) { + setIsPasswordModalOpen(false); + setNewPassword(''); + setConfirmPassword(''); + } + }; + + const handleWithdraw = () => { + setIsWithdrawModalOpen(true); + }; + + const handleWithdrawConfirm = async () => { + const result = await deleteAccount(); + if (result.success) { + setIsWithdrawModalOpen(false); + } + }; + + const drawerContent = ( + <> + } + label="팀 선택" + isSelected={false} + /> + {teams.map((team: string) => ( + } + label={team} + isActive={team === teams[0]} + /> + ))} + + + } + label="자유게시판" + /> + + ); + + if (isLoading) { + return ( +
+
+

로딩 중...

+
+
+ ); + } + + return ( +
+ {/* 모바일 헤더 */} +
+ setIsDrawerOpen(true)} + onProfileClick={() => {}} + profileImage={ + user?.image ? ( + + ) : ( +
+ ) + } + /> +
+ + {/* 모바일 드로어 */} + setIsDrawerOpen(false)}> + {drawerContent} + + + {/* 데스크탑/태블릿 사이드바 */} +
+ + !isCollapsed && ( + } + label="팀 선택" + isSelected={false} + /> + ) + } + addButton={(isCollapsed) => ( + <> + {!isCollapsed && } + + + } + label="자유게시판" + iconOnly={isCollapsed} + /> + + )} + profileImage={ + user?.image ? ( + + ) : ( +
+ ) + } + profileName={user?.nickname ?? ''} + profileTeam={teams[0] ?? ''} + > + {(isCollapsed) => + !isCollapsed ? ( + teams.map((team: string) => ( + } + label={team} + isActive={team === teams[0]} + /> + )) + ) : ( + } + label={teams[0] ?? ''} + isActive + iconOnly + /> + ) + } + +
+ +
+ {/* 모바일 타이틀 - 스크린 리더는 카드 내부 제목 사용 */} + + +
+
+

계정 설정

+ +
+ { + // 로컬 미리보기 + const localUrl = URL.createObjectURL(file); + setProfileImage(localUrl); + + // 서버에 업로드 + const result = await uploadProfileImage(file); + if (result.success && result.url) { + setProfileImage(result.url); + setShowToast(true); + } + }} + /> +
+ +
{ + e.preventDefault(); + handleSave(); + }} + > +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ + 변경하기 + +
+
+
+ + +
+
+ + {hasChanges && ( +
+ setShowToast(false)} + className={styles.toast} + /> +
+ )} +
+
+ + { + setIsPasswordModalOpen(false); + setNewPassword(''); + setConfirmPassword(''); + }} + onSubmit={handlePasswordSubmit} + input={{ + newPassword: { + value: newPassword, + onChange: (e) => setNewPassword(e.target.value), + }, + confirmPassword: { + value: confirmPassword, + onChange: (e) => setConfirmPassword(e.target.value), + }, + }} + /> + + setIsWithdrawModalOpen(false)} + onConfirm={handleWithdrawConfirm} + text={{ + title: '회원 탈퇴를 진행하시겠어요?', + description: '그룹장으로 있는 그룹은 자동으로 삭제되고,\n모든 그룹에서 나가집니다.', + closeLabel: '닫기', + confirmLabel: '회원 탈퇴', + }} + /> +
+ ); +} diff --git a/src/shared/apis/user/index.ts b/src/shared/apis/user/index.ts new file mode 100644 index 0000000..8a5679a --- /dev/null +++ b/src/shared/apis/user/index.ts @@ -0,0 +1,2 @@ +export * from './types'; +export * from './userApi'; diff --git a/src/shared/apis/user/types.ts b/src/shared/apis/user/types.ts new file mode 100644 index 0000000..493ef7f --- /dev/null +++ b/src/shared/apis/user/types.ts @@ -0,0 +1,59 @@ +/** 그룹 정보 */ +export type Group = { + teamId: string; + updatedAt: string; + createdAt: string; + image: string; + name: string; + id: number; +}; + +/** 멤버십 (유저가 속한 그룹) */ +export type Membership = { + group: Group; + role: 'ADMIN' | 'MEMBER'; + userImage: string; + userEmail: string; + userName: string; + groupId: number; + userId: number; +}; + +/** 유저 정보 응답 */ +export type UserResponse = { + teamId: string; + image: string; + nickname: string; + updatedAt: string; + createdAt: string; + email: string; + id: number; + memberships: Membership[]; +}; + +/** 유저 정보 수정 요청 */ +export type UpdateUserRequest = { + nickname?: string; + image?: string; +}; + +/** 유저 정보 수정 응답 */ +export type UpdateUserResponse = { + message: string; +}; + +/** 비밀번호 변경 요청 */ +export type ChangePasswordRequest = { + password: string; + passwordConfirmation: string; +}; + +/** 비밀번호 변경 응답 */ +export type ChangePasswordResponse = { + message: string; +}; + +/** 이미지 업로드 응답 */ +export type ImageUploadResponse = { + url: string; +}; diff --git a/src/shared/apis/user/userApi.ts b/src/shared/apis/user/userApi.ts new file mode 100644 index 0000000..bf43172 --- /dev/null +++ b/src/shared/apis/user/userApi.ts @@ -0,0 +1,77 @@ +import { fetchApi } from '../fetchApi'; +import { BASE_URL, TEAM_ID } from '../config'; +import type { + UserResponse, + UpdateUserRequest, + UpdateUserResponse, + ChangePasswordRequest, + ChangePasswordResponse, + ImageUploadResponse, +} from './types'; + +/** 현재 유저 정보 조회 */ +export async function getUser(): Promise { + const response = await fetchApi('/user'); + + if (!response.ok) { + throw new Error('유저 정보를 불러오는데 실패했습니다.'); + } + + return response.json(); +} + +/** 유저 정보 수정 */ +export async function updateUser(data: UpdateUserRequest): Promise { + const response = await fetchApi('/user', { + method: 'PATCH', + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('유저 정보 수정에 실패했습니다.'); + } + + return response.json(); +} + +/** 회원 탈퇴 */ +export async function deleteUser(): Promise { + const response = await fetchApi('/user', { + method: 'DELETE', + }); + + if (!response.ok) { + throw new Error('회원 탈퇴에 실패했습니다.'); + } +} + +/** 비밀번호 변경 */ +export async function changePassword(data: ChangePasswordRequest): Promise { + const response = await fetchApi('/user/password', { + method: 'PATCH', + body: JSON.stringify(data), + }); + + if (!response.ok) { + throw new Error('비밀번호 변경에 실패했습니다.'); + } + + return response.json(); +} + +/** 이미지 업로드 */ +export async function uploadImage(file: File): Promise { + const formData = new FormData(); + formData.append('image', file); + + const response = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('이미지 업로드에 실패했습니다.'); + } + + return response.json(); +} From 3021d34b4ab25719dd63a7b0f3404d405883daa3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=A0=95=EC=9B=90?= Date: Thu, 19 Feb 2026 07:27:29 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=82=AC=EC=9D=B4=EB=93=9C=EB=B0=94?= =?UTF-8?q?=20=EB=B8=8C=EB=A0=88=EC=9D=B4=ED=81=AC=20=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/sidebar/styles/Sidebar.module.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/sidebar/styles/Sidebar.module.css b/src/components/sidebar/styles/Sidebar.module.css index ea5d6dd..70e464d 100644 --- a/src/components/sidebar/styles/Sidebar.module.css +++ b/src/components/sidebar/styles/Sidebar.module.css @@ -7,7 +7,7 @@ background: var(--color-background-inverse); } -@media (max-width: 767px) { +@media (max-width: 375px) { .sidebar { display: none; }