From 77318de155d7c784439673c34d7dfbd45327d637 Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Thu, 21 May 2026 09:45:35 +0900 Subject: [PATCH 01/15] =?UTF-8?q?feat:=20mypage=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/App.tsx | 19 ++ src/features/user/me/api/user.ts | 135 +++++++++++- src/features/user/me/hooks/useUserMe.ts | 13 +- .../user/me/hooks/useUserMeMutations.ts | 162 ++++++++++++++ src/features/user/me/index.ts | 35 ++- src/features/user/me/types/user.ts | 57 +++++ src/pages/my/index.tsx | 3 +- src/pages/my/profile/email/index.tsx | 205 ++++++++++++++++++ src/pages/my/profile/index.tsx | 111 ++++++++-- src/pages/my/profile/nickname/index.tsx | 76 +++++++ src/pages/my/profile/password/index.tsx | 116 ++++++++++ src/pages/my/profile/social/index.tsx | 179 +++++++++++++++ src/pages/my/withdraw/index.tsx | 91 ++++++++ src/shared/constants/routes.ts | 5 + src/shared/lib/queryKeys.ts | 4 +- 15 files changed, 1189 insertions(+), 22 deletions(-) create mode 100644 src/features/user/me/hooks/useUserMeMutations.ts create mode 100644 src/pages/my/profile/email/index.tsx create mode 100644 src/pages/my/profile/nickname/index.tsx create mode 100644 src/pages/my/profile/password/index.tsx create mode 100644 src/pages/my/profile/social/index.tsx create mode 100644 src/pages/my/withdraw/index.tsx diff --git a/src/app/App.tsx b/src/app/App.tsx index 6064ef6..483edf8 100644 --- a/src/app/App.tsx +++ b/src/app/App.tsx @@ -30,6 +30,11 @@ import { ManagerWorkerInvitePage } from '@/pages/manager/worker-invite' import { WorkspaceJoinPage } from '@/pages/user/workspace-join' import { MyPage } from '@/pages/my' import { ProfileEditPage } from '@/pages/my/profile' +import { EmailEditPage } from '@/pages/my/profile/email' +import { NicknameEditPage } from '@/pages/my/profile/nickname' +import { PasswordEditPage } from '@/pages/my/profile/password' +import { SocialAccountPage } from '@/pages/my/profile/social' +import { WithdrawPage } from '@/pages/my/withdraw' import { ErrorPageRoute } from '@/pages/error' import { MobileLayout } from '@/shared/ui/MobileLayout' import { MobileLayoutWithDocbar } from '@/shared/ui/MobileLayoutWithDocbar' @@ -104,6 +109,20 @@ export function App() { element={} /> } /> + } + /> + } + /> + } /> + } + /> + } /> } diff --git a/src/features/user/me/api/user.ts b/src/features/user/me/api/user.ts index d562d12..e63bc3d 100644 --- a/src/features/user/me/api/user.ts +++ b/src/features/user/me/api/user.ts @@ -1,10 +1,141 @@ import axiosInstance from '@/shared/lib/axiosInstance' +import type { CommonApiResponse } from '@/shared/types/common' import type { + LinkSocialAccountRequest, + SendEmailVerificationRequest, + SocialAccountStatusDto, + SocialProvider, + UpdateEmailRequest, + UpdateNicknameRequest, + UpdatePasswordRequest, + UpdateProfileImageRequest, UserMeApiResponse, UserMeDto, + UserMeScope, + VerifyEmailCodeRequest, + VerifyEmailCodeResponseDto, } from '@/features/user/me/types/user' -export async function getUserMe(): Promise { - const response = await axiosInstance.get('/app/users/me') +function getSelfBasePath(scope: UserMeScope): string { + return scope === 'MANAGER' ? '/manager/me' : '/app/users/me' +} + +function getSocialBasePath(scope: UserMeScope): string { + return scope === 'MANAGER' ? '/manager/me/social' : '/app/users/social' +} + +export async function getUserMe(scope: UserMeScope): Promise { + const response = await axiosInstance.get( + getSelfBasePath(scope) + ) + return response.data.data +} + +export async function updateUserNickname(options: { + scope: UserMeScope + nickname: string +}): Promise { + const body: UpdateNicknameRequest = { nickname: options.nickname } + await axiosInstance.put(`${getSelfBasePath(options.scope)}/nickname`, body) +} + +export async function updateUserProfileImage(options: { + scope: UserMeScope + fileId: string +}): Promise { + const body: UpdateProfileImageRequest = { fileId: options.fileId } + await axiosInstance.put( + `${getSelfBasePath(options.scope)}/profile-image`, + body + ) +} + +export async function deleteUserProfileImage( + scope: UserMeScope +): Promise { + await axiosInstance.delete(`${getSelfBasePath(scope)}/profile-image`) +} + +export async function updateUserPassword(options: { + scope: UserMeScope + currentPassword?: string + newPassword: string +}): Promise { + const body: UpdatePasswordRequest = { + newPassword: options.newPassword, + ...(options.currentPassword?.trim() + ? { currentPassword: options.currentPassword } + : {}), + } + await axiosInstance.put(`${getSelfBasePath(options.scope)}/password`, body) +} + +export async function sendUserEmailVerification(options: { + scope: UserMeScope + email: string +}): Promise { + const body: SendEmailVerificationRequest = { email: options.email } + await axiosInstance.post( + `${getSelfBasePath(options.scope)}/email/verification/send`, + body + ) +} + +export async function verifyUserEmailCode(options: { + scope: UserMeScope + email: string + code: string +}): Promise { + const body: VerifyEmailCodeRequest = { + email: options.email, + code: options.code, + } + const response = await axiosInstance.post< + CommonApiResponse + >(`${getSelfBasePath(options.scope)}/email/verification`, body) + return response.data.data.sessionId +} + +export async function updateUserEmail(options: { + scope: UserMeScope + sessionId: string +}): Promise { + const body: UpdateEmailRequest = { sessionId: options.sessionId } + await axiosInstance.post(`${getSelfBasePath(options.scope)}/email`, body) +} + +export async function deleteUserEmail(scope: UserMeScope): Promise { + await axiosInstance.delete(`${getSelfBasePath(scope)}/email`) +} + +export async function withdrawUser(scope: UserMeScope): Promise { + await axiosInstance.delete(getSelfBasePath(scope)) +} + +export async function linkUserSocialAccount(options: { + scope: UserMeScope + request: LinkSocialAccountRequest +}): Promise { + await axiosInstance.post( + `${getSocialBasePath(options.scope)}/link`, + options.request + ) +} + +export async function unlinkUserSocialAccount(options: { + scope: UserMeScope + provider: SocialProvider +}): Promise { + await axiosInstance.delete( + `${getSocialBasePath(options.scope)}/unlink/${options.provider}` + ) +} + +export async function getUserSocialStatus( + scope: UserMeScope +): Promise { + const response = await axiosInstance.get< + CommonApiResponse + >(`${getSocialBasePath(scope)}/status`) return response.data.data } diff --git a/src/features/user/me/hooks/useUserMe.ts b/src/features/user/me/hooks/useUserMe.ts index eff035c..e560317 100644 --- a/src/features/user/me/hooks/useUserMe.ts +++ b/src/features/user/me/hooks/useUserMe.ts @@ -9,6 +9,9 @@ export interface UserMeViewModel { id?: number name: string nickname: string + email: string + phone: string + profileImageUrl?: string joinedAt?: Date joinedAtFormatted: string raw: UserMeDto | null @@ -33,11 +36,12 @@ function formatJoinedAt(iso?: string | null): { export function useUserMe() { const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) const query = useQuery({ - queryKey: queryKeys.user.me(), - queryFn: getUserMe, - enabled: isLoggedIn, + queryKey: queryKeys.user.me(scope), + queryFn: () => getUserMe(scope ?? 'USER'), + enabled: isLoggedIn && Boolean(scope), staleTime: 60_000, }) @@ -48,6 +52,9 @@ export function useUserMe() { id: dto?.id, name: dto?.name ?? '', nickname: dto?.nickname ?? '', + email: dto?.email ?? '', + phone: dto?.contact ?? dto?.phone ?? '', + profileImageUrl: dto?.profileImageUrl ?? undefined, joinedAt: date, joinedAtFormatted: formatted, raw: dto, diff --git a/src/features/user/me/hooks/useUserMeMutations.ts b/src/features/user/me/hooks/useUserMeMutations.ts new file mode 100644 index 0000000..fdcebcb --- /dev/null +++ b/src/features/user/me/hooks/useUserMeMutations.ts @@ -0,0 +1,162 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + deleteUserEmail, + deleteUserProfileImage, + getUserSocialStatus, + linkUserSocialAccount, + sendUserEmailVerification, + unlinkUserSocialAccount, + updateUserEmail, + updateUserNickname, + updateUserPassword, + updateUserProfileImage, + verifyUserEmailCode, + withdrawUser, +} from '@/features/user/me/api/user' +import type { + LinkSocialAccountRequest, + SocialProvider, + UserMeScope, +} from '@/features/user/me/types/user' +import useAuthStore from '@/shared/stores/useAuthStore' +import { queryKeys } from '@/shared/lib/queryKeys' + +function useCurrentUserScope(): UserMeScope { + return useAuthStore(state => state.scope) ?? 'USER' +} + +export function useUpdateNicknameMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (nickname: string) => updateUserNickname({ scope, nickname }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) }) + }, + }) +} + +export function useUpdateProfileImageMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (fileId: string) => updateUserProfileImage({ scope, fileId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) }) + }, + }) +} + +export function useDeleteProfileImageMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteUserProfileImage(scope), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) }) + }, + }) +} + +export function useUpdatePasswordMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (payload: { currentPassword?: string; newPassword: string }) => + updateUserPassword({ scope, ...payload }), + }) +} + +export function useSendEmailVerificationMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (email: string) => sendUserEmailVerification({ scope, email }), + }) +} + +export function useVerifyEmailCodeMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (payload: { email: string; code: string }) => + verifyUserEmailCode({ scope, ...payload }), + }) +} + +export function useUpdateEmailMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (sessionId: string) => updateUserEmail({ scope, sessionId }), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) }) + }, + }) +} + +export function useDeleteEmailMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteUserEmail(scope), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) }) + }, + }) +} + +export function useWithdrawUserMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: () => withdrawUser(scope), + }) +} + +export function useUserSocialStatus() { + const isLoggedIn = useAuthStore(state => state.isLoggedIn) + const scope = useAuthStore(state => state.scope) + + return useQuery({ + queryKey: queryKeys.user.socialStatus(scope), + queryFn: () => getUserSocialStatus(scope ?? 'USER'), + enabled: isLoggedIn && Boolean(scope), + staleTime: 60_000, + }) +} + +export function useLinkSocialAccountMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: LinkSocialAccountRequest) => + linkUserSocialAccount({ scope, request }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.socialStatus(scope), + }) + }, + }) +} + +export function useUnlinkSocialAccountMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (provider: SocialProvider) => + unlinkUserSocialAccount({ scope, provider }), + onSuccess: () => { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.socialStatus(scope), + }) + }, + }) +} diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts index 3c7bc63..474c25f 100644 --- a/src/features/user/me/index.ts +++ b/src/features/user/me/index.ts @@ -1,9 +1,42 @@ -export { getUserMe } from './api/user' +export { + deleteUserEmail, + deleteUserProfileImage, + getUserMe, + getUserSocialStatus, + linkUserSocialAccount, + sendUserEmailVerification, + unlinkUserSocialAccount, + updateUserEmail, + updateUserNickname, + updateUserPassword, + updateUserProfileImage, + verifyUserEmailCode, + withdrawUser, +} from './api/user' export { useUserMe } from './hooks/useUserMe' +export { + useDeleteEmailMutation, + useDeleteProfileImageMutation, + useLinkSocialAccountMutation, + useSendEmailVerificationMutation, + useUnlinkSocialAccountMutation, + useUpdateEmailMutation, + useUpdateNicknameMutation, + useUpdatePasswordMutation, + useUpdateProfileImageMutation, + useUserSocialStatus, + useVerifyEmailCodeMutation, + useWithdrawUserMutation, +} from './hooks/useUserMeMutations' export type { UserMeViewModel } from './hooks/useUserMe' export type { + LinkSocialAccountRequest, UserMeApiResponse, UserMeDto, + SocialAccountStatusDto, + SocialPlatformType, + SocialProvider, ReputationKeyword, ReputationSummary, + UserMeScope, } from './types/user' diff --git a/src/features/user/me/types/user.ts b/src/features/user/me/types/user.ts index 31b53b6..1a7b576 100644 --- a/src/features/user/me/types/user.ts +++ b/src/features/user/me/types/user.ts @@ -14,6 +14,10 @@ export interface UserMeDto { name: string nickname: string createdAt: string + email?: string | null + contact?: string | null + phone?: string | null + profileImageUrl?: string | null reputationSummary?: ReputationSummary | null } @@ -21,3 +25,56 @@ export interface UserMeApiResponse { timestamp: string data: UserMeDto } + +export type UserMeScope = 'USER' | 'MANAGER' + +export type SocialProvider = 'KAKAO' | 'APPLE' + +export type SocialPlatformType = 'WEB' | 'NATIVE' + +export interface UpdateNicknameRequest { + nickname: string +} + +export interface UpdateProfileImageRequest { + fileId: string +} + +export interface UpdatePasswordRequest { + currentPassword?: string + newPassword: string +} + +export interface SendEmailVerificationRequest { + email: string +} + +export interface VerifyEmailCodeRequest { + email: string + code: string +} + +export interface VerifyEmailCodeResponseDto { + sessionId: string +} + +export interface UpdateEmailRequest { + sessionId: string +} + +export interface LinkSocialAccountRequest { + provider: SocialProvider + platformType: SocialPlatformType + oauthToken?: { + accessToken: string + refreshToken?: string + } + authorizationCode?: string + redirectUri?: string +} + +export interface SocialAccountStatusDto { + provider: SocialProvider + linked: boolean + linkedAt?: string | null +} diff --git a/src/pages/my/index.tsx b/src/pages/my/index.tsx index cdbd9b6..b46f5db 100644 --- a/src/pages/my/index.tsx +++ b/src/pages/my/index.tsx @@ -86,7 +86,7 @@ export function MyPage() { } const handleWithdraw = () => { - navigate('/my/withdraw') + navigate(ROUTES.MY.WITHDRAW) } return ( @@ -100,6 +100,7 @@ export function MyPage() { nickname={nickname} realName={realName} isManager={isManager} + avatarUrl={user.profileImageUrl} onEditClick={handleEditProfile} /> diff --git a/src/pages/my/profile/email/index.tsx b/src/pages/my/profile/email/index.tsx new file mode 100644 index 0000000..ddd15c3 --- /dev/null +++ b/src/pages/my/profile/email/index.tsx @@ -0,0 +1,205 @@ +import { useState } from 'react' +import { + useDeleteEmailMutation, + useSendEmailVerificationMutation, + useUpdateEmailMutation, + useUserMe, + useVerifyEmailCodeMutation, +} from '@/features/user/me' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import useAuthStore from '@/shared/stores/useAuthStore' +import { AuthButton } from '@/shared/ui/common/AuthButton' +import { AuthInput } from '@/shared/ui/common/AuthInput' +import { Navbar } from '@/shared/ui/common/Navbar' + +function isEmailValid(value: string): boolean { + return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim()) +} + +export function EmailEditPage() { + const { user } = useUserMe() + const authUser = useAuthStore(state => state.user) + const sendEmailVerificationMutation = useSendEmailVerificationMutation() + const verifyEmailCodeMutation = useVerifyEmailCodeMutation() + const updateEmailMutation = useUpdateEmailMutation() + const deleteEmailMutation = useDeleteEmailMutation() + + const currentEmail = user.email || authUser?.email || '' + const [email, setEmail] = useState(currentEmail) + const [code, setCode] = useState('') + const [sessionId, setSessionId] = useState('') + const [message, setMessage] = useState('') + const [successMessage, setSuccessMessage] = useState('') + + const isPending = + sendEmailVerificationMutation.isPending || + verifyEmailCodeMutation.isPending || + updateEmailMutation.isPending || + deleteEmailMutation.isPending + const trimmedEmail = email.trim() + const canSend = isEmailValid(trimmedEmail) && !isPending + const canVerify = + isEmailValid(trimmedEmail) && code.trim().length > 0 && !isPending + const canUpdate = Boolean(sessionId) && !isPending + const canDelete = Boolean(currentEmail) && !isPending + + const clearMessages = () => { + setMessage('') + setSuccessMessage('') + } + + const handleSend = async () => { + clearMessages() + if (!isEmailValid(trimmedEmail)) { + setMessage('올바른 이메일을 입력해 주세요.') + return + } + + try { + await sendEmailVerificationMutation.mutateAsync(trimmedEmail) + setSessionId('') + setSuccessMessage('인증 코드가 발송되었습니다.') + } catch (error) { + setMessage(getAxiosErrorMessage(error, '인증 코드 발송에 실패했습니다.')) + } + } + + const handleVerify = async () => { + clearMessages() + try { + const nextSessionId = await verifyEmailCodeMutation.mutateAsync({ + email: trimmedEmail, + code: code.trim(), + }) + setSessionId(nextSessionId) + setSuccessMessage('이메일 인증이 완료되었습니다.') + } catch (error) { + setMessage(getAxiosErrorMessage(error, '인증 코드 확인에 실패했습니다.')) + } + } + + const handleUpdate = async () => { + clearMessages() + try { + await updateEmailMutation.mutateAsync(sessionId) + setSuccessMessage('이메일이 등록/변경되었습니다.') + } catch (error) { + setMessage( + getAxiosErrorMessage(error, '이메일 등록/변경에 실패했습니다.') + ) + } + } + + const handleDelete = async () => { + if (!window.confirm('등록된 이메일을 삭제할까요?')) return + clearMessages() + try { + await deleteEmailMutation.mutateAsync() + setEmail('') + setCode('') + setSessionId('') + setSuccessMessage('이메일이 삭제되었습니다.') + } catch (error) { + setMessage(getAxiosErrorMessage(error, '이메일 삭제에 실패했습니다.')) + } + } + + return ( +
+
+ + 삭제 + + ) : null + } + /> +
+ +
+
+

현재 이메일

+

+ {currentEmail || '등록된 이메일이 없습니다.'} +

+
+ +
+ +
+ { + setEmail(event.target.value) + setSessionId('') + }} + /> + +
+
+ +
+ +
+ { + setCode(event.target.value) + setSessionId('') + }} + /> + +
+
+ + {message && ( +

+ {message} +

+ )} + {successMessage && ( +

+ {successMessage} +

+ )} + +
+ + 이메일 등록/변경 + +
+
+
+ ) +} diff --git a/src/pages/my/profile/index.tsx b/src/pages/my/profile/index.tsx index e698275..02bc7d7 100644 --- a/src/pages/my/profile/index.tsx +++ b/src/pages/my/profile/index.tsx @@ -1,34 +1,93 @@ +import { useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import useAuthStore from '@/shared/stores/useAuthStore' -import { useUserMe } from '@/features/user/me' +import { + useDeleteProfileImageMutation, + useUpdateProfileImageMutation, + useUserMe, +} from '@/features/user/me' +import { ROUTES } from '@/shared/constants/routes' import { Navbar } from '@/shared/ui/common/Navbar' +import { uploadAppFile } from '@/features/store-register/api/workspaceFileUpload' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' import { MenuListItem } from '../components/MenuListItem' import { ReadOnlyField } from './components/ReadOnlyField' import CameraCircleIcon from '@/assets/icons/my/camera-circle.svg?react' -// TODO: /app/users/me API에 email/phone이 추가되면 fallback 제거 -const MOCK_PROFILE = { - email: '123456789@gmail.com', - phone: '010-1234-5678', -} - export function ProfileEditPage() { const navigate = useNavigate() const { user: authUser } = useAuthStore() const { user, isError } = useUserMe() + const fileInputRef = useRef(null) + const [imageError, setImageError] = useState('') + const updateProfileImageMutation = useUpdateProfileImageMutation() + const deleteProfileImageMutation = useDeleteProfileImageMutation() - const email = authUser?.email || MOCK_PROFILE.email - const phone = MOCK_PROFILE.phone + const email = user.email || authUser?.email || '등록된 이메일이 없습니다.' + const phone = user.phone || '제공되지 않음' const joinedAt = user.joinedAtFormatted const nickname = user.nickname || authUser?.name || '알터' - const avatarUrl: string | undefined = undefined + const avatarUrl = user.profileImageUrl + const isImagePending = + updateProfileImageMutation.isPending || deleteProfileImageMutation.isPending + + const handleAvatarUpload = () => { + setImageError('') + fileInputRef.current?.click() + } + + const handleAvatarFileChange = async ( + event: React.ChangeEvent + ) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return - const handleAvatarUpload = () => {} + try { + const fileId = await uploadAppFile({ + file, + targetType: 'USER_PROFILE', + bucketType: 'PUBLIC', + }) + await updateProfileImageMutation.mutateAsync(fileId) + } catch (error) { + setImageError( + getAxiosErrorMessage(error, '프로필 이미지 변경에 실패했습니다.') + ) + } + } + + const handleDeleteAvatar = async () => { + if (!avatarUrl || !window.confirm('프로필 이미지를 삭제할까요?')) return + setImageError('') + try { + await deleteProfileImageMutation.mutateAsync() + } catch (error) { + setImageError( + getAxiosErrorMessage(error, '프로필 이미지 삭제에 실패했습니다.') + ) + } + } return (
- + + 삭제 + + ) : null + } + />
@@ -46,21 +105,45 @@ export function ProfileEditPage() { type="button" aria-label="프로필 이미지 변경" onClick={handleAvatarUpload} + disabled={isImagePending} className="absolute -bottom-1 -right-1 flex size-10 items-center justify-center" >
+ {imageError && ( +

+ {imageError} +

+ )}
navigate('/my/profile/nickname')} + onClick={() => navigate(ROUTES.MY.PROFILE_NICKNAME)} /> navigate('/my/profile/password')} + onClick={() => navigate(ROUTES.MY.PROFILE_PASSWORD)} + /> + navigate(ROUTES.MY.PROFILE_EMAIL)} + /> + navigate(ROUTES.MY.PROFILE_SOCIAL)} isLast />
diff --git a/src/pages/my/profile/nickname/index.tsx b/src/pages/my/profile/nickname/index.tsx new file mode 100644 index 0000000..cd1e941 --- /dev/null +++ b/src/pages/my/profile/nickname/index.tsx @@ -0,0 +1,76 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useUpdateNicknameMutation, useUserMe } from '@/features/user/me' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { AuthButton } from '@/shared/ui/common/AuthButton' +import { AuthInput } from '@/shared/ui/common/AuthInput' +import { Navbar } from '@/shared/ui/common/Navbar' +import { ROUTES } from '@/shared/constants/routes' + +export function NicknameEditPage() { + const navigate = useNavigate() + const { user } = useUserMe() + const updateNicknameMutation = useUpdateNicknameMutation() + const [nickname, setNickname] = useState(user.nickname) + const [message, setMessage] = useState('') + + const trimmedNickname = nickname.trim() + const canSubmit = + trimmedNickname.length > 0 && + trimmedNickname.length <= 64 && + trimmedNickname !== user.nickname && + !updateNicknameMutation.isPending + + const handleSubmit = async () => { + setMessage('') + if (!trimmedNickname) { + setMessage('닉네임을 입력해 주세요.') + return + } + if (trimmedNickname.length > 64) { + setMessage('닉네임은 64자 이하로 입력해 주세요.') + return + } + + try { + await updateNicknameMutation.mutateAsync(trimmedNickname) + navigate(ROUTES.MY.PROFILE, { replace: true }) + } catch (error) { + setMessage(getAxiosErrorMessage(error, '닉네임 변경에 실패했습니다.')) + } + } + + return ( +
+
+ +
+ +
+ + setNickname(event.target.value)} + /> +

+ 현재 닉네임과 다른 64자 이하의 닉네임을 입력해 주세요. +

+ {message && ( +

+ {message} +

+ )} + +
+ + 변경하기 + +
+
+
+ ) +} diff --git a/src/pages/my/profile/password/index.tsx b/src/pages/my/profile/password/index.tsx new file mode 100644 index 0000000..d20d3af --- /dev/null +++ b/src/pages/my/profile/password/index.tsx @@ -0,0 +1,116 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useUpdatePasswordMutation } from '@/features/user/me' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { AuthButton } from '@/shared/ui/common/AuthButton' +import { AuthInput } from '@/shared/ui/common/AuthInput' +import { Navbar } from '@/shared/ui/common/Navbar' +import { ROUTES } from '@/shared/constants/routes' + +function isPasswordFormatValid(value: string): boolean { + const trimmed = value.trim() + if (trimmed.length < 8 || trimmed.length > 16) return false + const hasLetter = /[A-Za-z]/.test(trimmed) + const hasNumber = /\d/.test(trimmed) + const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(trimmed) + return hasLetter && hasNumber && hasSpecial +} + +export function PasswordEditPage() { + const navigate = useNavigate() + const updatePasswordMutation = useUpdatePasswordMutation() + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const [message, setMessage] = useState('') + + const canSubmit = + isPasswordFormatValid(newPassword) && + newPassword === confirmPassword && + !updatePasswordMutation.isPending + + const handleSubmit = async () => { + setMessage('') + if (!isPasswordFormatValid(newPassword)) { + setMessage( + '새 비밀번호는 8~16자, 영문·숫자·특수문자를 모두 포함해야 합니다.' + ) + return + } + if (newPassword !== confirmPassword) { + setMessage('새 비밀번호가 일치하지 않습니다.') + return + } + + try { + await updatePasswordMutation.mutateAsync({ + currentPassword: currentPassword.trim() || undefined, + newPassword, + }) + navigate(ROUTES.MY.PROFILE, { replace: true }) + } catch (error) { + setMessage(getAxiosErrorMessage(error, '비밀번호 변경에 실패했습니다.')) + } + } + + return ( +
+
+ +
+ +
+
+ + setCurrentPassword(event.target.value)} + /> +
+ +
+ + setNewPassword(event.target.value)} + /> +
+ +
+ + setConfirmPassword(event.target.value)} + /> +
+ + {message && ( +

+ {message} +

+ )} + +
+ + 변경하기 + +
+
+
+ ) +} diff --git a/src/pages/my/profile/social/index.tsx b/src/pages/my/profile/social/index.tsx new file mode 100644 index 0000000..e2aa139 --- /dev/null +++ b/src/pages/my/profile/social/index.tsx @@ -0,0 +1,179 @@ +import { useMemo, useState } from 'react' +import { + type LinkSocialAccountRequest, + type SocialProvider, + useLinkSocialAccountMutation, + useUnlinkSocialAccountMutation, + useUserSocialStatus, +} from '@/features/user/me' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { + getKakaoOAuthRedirectUri, + loginWithApple, + requestFreshKakaoAuthorizationCode, +} from '@/shared/lib/socialLogin' +import { Navbar } from '@/shared/ui/common/Navbar' + +const PROVIDERS: Array<{ provider: SocialProvider; label: string }> = [ + { provider: 'KAKAO', label: '카카오' }, + { provider: 'APPLE', label: 'Apple' }, +] + +function formatLinkedAt(value?: string | null): string { + if (!value) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return value + const yyyy = date.getFullYear() + const mm = String(date.getMonth() + 1).padStart(2, '0') + const dd = String(date.getDate()).padStart(2, '0') + return `${yyyy}.${mm}.${dd}` +} + +export function SocialAccountPage() { + const { data = [], isLoading, isError } = useUserSocialStatus() + const linkSocialAccountMutation = useLinkSocialAccountMutation() + const unlinkSocialAccountMutation = useUnlinkSocialAccountMutation() + const [message, setMessage] = useState('') + const [pendingProvider, setPendingProvider] = useState( + null + ) + + const statusMap = useMemo( + () => new Map(data.map(item => [item.provider, item])), + [data] + ) + + const isPending = + linkSocialAccountMutation.isPending || + unlinkSocialAccountMutation.isPending || + pendingProvider !== null + + const handleLink = async (provider: SocialProvider) => { + setMessage('') + setPendingProvider(provider) + try { + let request: LinkSocialAccountRequest + + if (provider === 'KAKAO') { + const { authorizationCode, redirectUri } = + await requestFreshKakaoAuthorizationCode(getKakaoOAuthRedirectUri()) + request = { + provider, + platformType: 'WEB', + authorizationCode, + redirectUri, + } + } else { + const appleResult = await loginWithApple() + request = { + provider, + platformType: 'WEB', + authorizationCode: appleResult.authorizationCode, + oauthToken: appleResult.accessToken + ? { + accessToken: appleResult.accessToken, + refreshToken: appleResult.refreshToken, + } + : undefined, + } + } + + await linkSocialAccountMutation.mutateAsync(request) + setMessage( + `${PROVIDERS.find(item => item.provider === provider)?.label} 계정이 연동되었습니다.` + ) + } catch (error) { + setMessage(getAxiosErrorMessage(error, '소셜 계정 연동에 실패했습니다.')) + } finally { + setPendingProvider(null) + } + } + + const handleUnlink = async (provider: SocialProvider) => { + const label = PROVIDERS.find(item => item.provider === provider)?.label + if (!window.confirm(`${label} 계정 연동을 해제할까요?`)) return + setMessage('') + setPendingProvider(provider) + try { + await unlinkSocialAccountMutation.mutateAsync(provider) + setMessage(`${label} 계정 연동이 해제되었습니다.`) + } catch (error) { + setMessage( + getAxiosErrorMessage(error, '소셜 계정 연동 해제에 실패했습니다.') + ) + } finally { + setPendingProvider(null) + } + } + + return ( +
+
+ +
+ +
+

+ 소셜 계정을 연동하면 해당 계정으로 로그인할 수 있습니다. +

+ +
+ {PROVIDERS.map(item => { + const status = statusMap.get(item.provider) + const linked = Boolean(status?.linked) + const pending = pendingProvider === item.provider + + return ( +
+
+

+ {item.label} +

+

+ {linked + ? `연동됨 ${formatLinkedAt(status?.linkedAt)}` + : '연동되지 않음'} +

+
+ +
+ ) + })} +
+ + {isLoading && ( +

+ 연동 상태를 불러오는 중입니다. +

+ )} + {isError && ( +

+ 연동 상태를 불러오지 못했습니다. +

+ )} + {message && ( +

+ {message} +

+ )} +
+
+ ) +} diff --git a/src/pages/my/withdraw/index.tsx b/src/pages/my/withdraw/index.tsx new file mode 100644 index 0000000..c68a7b4 --- /dev/null +++ b/src/pages/my/withdraw/index.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router-dom' +import { useWithdrawUserMutation } from '@/features/user/me' +import { ROUTES } from '@/shared/constants/routes' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import useAuthStore from '@/shared/stores/useAuthStore' +import { AuthButton } from '@/shared/ui/common/AuthButton' +import { Navbar } from '@/shared/ui/common/Navbar' + +export function WithdrawPage() { + const navigate = useNavigate() + const logout = useAuthStore(state => state.logout) + const scope = useAuthStore(state => state.scope) + const withdrawUserMutation = useWithdrawUserMutation() + const [checked, setChecked] = useState(false) + const [message, setMessage] = useState('') + + const handleWithdraw = async () => { + setMessage('') + if (!checked) { + setMessage('탈퇴 안내를 확인해 주세요.') + return + } + if (!window.confirm('정말 탈퇴하시겠어요? 이 작업은 되돌릴 수 없습니다.')) { + return + } + + try { + await withdrawUserMutation.mutateAsync() + logout() + navigate(ROUTES.AUTH.LOGIN, { replace: true }) + } catch (error) { + const fallback = + scope === 'MANAGER' + ? '운영 중인 업장이 있으면 탈퇴할 수 없습니다.' + : '회원 탈퇴에 실패했습니다.' + setMessage(getAxiosErrorMessage(error, fallback)) + } + } + + return ( +
+
+ +
+ +
+

+ 탈퇴 전 확인해 주세요 +

+
+

탈퇴하면 현재 계정으로 서비스를 이용할 수 없습니다.

+ {scope === 'MANAGER' ? ( +

+ 운영 중인 업장이 있다면 먼저 업장 운영을 종료해야 탈퇴할 수 + 있습니다. +

+ ) : ( +

활성 근무지는 탈퇴 시 자동 퇴직 처리됩니다.

+ )} +
+ + + + {message && ( +

+ {message} +

+ )} + +
+ + 회원 탈퇴 + +
+
+
+ ) +} diff --git a/src/shared/constants/routes.ts b/src/shared/constants/routes.ts index c8c39ac..00edf1a 100644 --- a/src/shared/constants/routes.ts +++ b/src/shared/constants/routes.ts @@ -39,6 +39,11 @@ export const ROUTES = { MY: { ROOT: '/my', PROFILE: '/my/profile', + PROFILE_NICKNAME: '/my/profile/nickname', + PROFILE_PASSWORD: '/my/profile/password', + PROFILE_EMAIL: '/my/profile/email', + PROFILE_SOCIAL: '/my/profile/social', + WITHDRAW: '/my/withdraw', }, } as const diff --git a/src/shared/lib/queryKeys.ts b/src/shared/lib/queryKeys.ts index 01322e7..dcfa2f4 100644 --- a/src/shared/lib/queryKeys.ts +++ b/src/shared/lib/queryKeys.ts @@ -80,7 +80,9 @@ export const queryKeys = { }) => ['workspaceMembership', 'invitations', params] as const, }, user: { - me: () => ['user', 'me'] as const, + me: (scope?: string | null) => ['user', 'me', scope] as const, + socialStatus: (scope?: string | null) => + ['user', 'socialStatus', scope] as const, }, fixedWorkerSchedule: { list: (workspaceId: number) => From b2bb69597fe87e0f341181f0d378ab65ec264978 Mon Sep 17 00:00:00 2001 From: Dohyeon Date: Thu, 21 May 2026 09:53:57 +0900 Subject: [PATCH 02/15] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=82=AD=EC=A0=9C=EC=9A=A9=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/my/profile/index.tsx | 103 ++++++++++++++++++++++++++++++++- 1 file changed, 100 insertions(+), 3 deletions(-) diff --git a/src/pages/my/profile/index.tsx b/src/pages/my/profile/index.tsx index 02bc7d7..31ea493 100644 --- a/src/pages/my/profile/index.tsx +++ b/src/pages/my/profile/index.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import useAuthStore from '@/shared/stores/useAuthStore' import { @@ -14,12 +14,96 @@ import { MenuListItem } from '../components/MenuListItem' import { ReadOnlyField } from './components/ReadOnlyField' import CameraCircleIcon from '@/assets/icons/my/camera-circle.svg?react' +interface DeleteProfileImageModalProps { + open: boolean + pending: boolean + onClose: () => void + onConfirm: () => void +} + +function DeleteProfileImageModal({ + open, + pending, + onClose, + onConfirm, +}: DeleteProfileImageModalProps) { + useEffect(() => { + if (!open) return + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') onClose() + } + window.addEventListener('keydown', onKeyDown) + return () => window.removeEventListener('keydown', onKeyDown) + }, [open, onClose]) + + useEffect(() => { + const prev = document.body.style.overflow + if (open) document.body.style.overflow = 'hidden' + return () => { + document.body.style.overflow = prev + } + }, [open]) + + if (!open) return null + + return ( +
+ + +
+ + + ) +} + export function ProfileEditPage() { const navigate = useNavigate() const { user: authUser } = useAuthStore() const { user, isError } = useUserMe() const fileInputRef = useRef(null) const [imageError, setImageError] = useState('') + const [deleteModalOpen, setDeleteModalOpen] = useState(false) const updateProfileImageMutation = useUpdateProfileImageMutation() const deleteProfileImageMutation = useDeleteProfileImageMutation() @@ -57,11 +141,18 @@ export function ProfileEditPage() { } } + const handleOpenDeleteModal = () => { + if (!avatarUrl) return + setImageError('') + setDeleteModalOpen(true) + } + const handleDeleteAvatar = async () => { - if (!avatarUrl || !window.confirm('프로필 이미지를 삭제할까요?')) return + if (!avatarUrl) return setImageError('') try { await deleteProfileImageMutation.mutateAsync() + setDeleteModalOpen(false) } catch (error) { setImageError( getAxiosErrorMessage(error, '프로필 이미지 삭제에 실패했습니다.') @@ -79,7 +170,7 @@ export function ProfileEditPage() { avatarUrl ? (