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 ? (
)}
+ setDeleteModalOpen(false)}
+ onConfirm={handleDeleteAvatar}
+ />
Date: Thu, 21 May 2026 10:12:45 +0900
Subject: [PATCH 03/15] =?UTF-8?q?fix:=20=EC=97=94=EB=93=9C=ED=8F=AC?=
=?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EC=98=A4=EB=A5=98=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/user/me/hooks/useUserMe.ts | 11 ++-
.../user/me/hooks/useUserMeMutations.ts | 85 +++++++++++++------
2 files changed, 69 insertions(+), 27 deletions(-)
diff --git a/src/features/user/me/hooks/useUserMe.ts b/src/features/user/me/hooks/useUserMe.ts
index e560317..bbec182 100644
--- a/src/features/user/me/hooks/useUserMe.ts
+++ b/src/features/user/me/hooks/useUserMe.ts
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import { useQuery } from '@tanstack/react-query'
import { getUserMe } from '@/features/user/me/api/user'
-import type { UserMeDto } from '@/features/user/me/types/user'
+import type { UserMeDto, UserMeScope } from '@/features/user/me/types/user'
import useAuthStore from '@/shared/stores/useAuthStore'
import { queryKeys } from '@/shared/lib/queryKeys'
@@ -19,6 +19,13 @@ export interface UserMeViewModel {
const FALLBACK_JOINED_AT = '-'
+function requireUserScope(scope: UserMeScope | null): UserMeScope {
+ if (!scope) {
+ throw new Error('사용자 권한 정보가 준비되지 않았습니다.')
+ }
+ return scope
+}
+
function formatJoinedAt(iso?: string | null): {
date?: Date
formatted: string
@@ -40,7 +47,7 @@ export function useUserMe() {
const query = useQuery({
queryKey: queryKeys.user.me(scope),
- queryFn: () => getUserMe(scope ?? 'USER'),
+ queryFn: () => getUserMe(requireUserScope(scope)),
enabled: isLoggedIn && Boolean(scope),
staleTime: 60_000,
})
diff --git a/src/features/user/me/hooks/useUserMeMutations.ts b/src/features/user/me/hooks/useUserMeMutations.ts
index fdcebcb..de4e853 100644
--- a/src/features/user/me/hooks/useUserMeMutations.ts
+++ b/src/features/user/me/hooks/useUserMeMutations.ts
@@ -21,8 +21,15 @@ import type {
import useAuthStore from '@/shared/stores/useAuthStore'
import { queryKeys } from '@/shared/lib/queryKeys'
-function useCurrentUserScope(): UserMeScope {
- return useAuthStore(state => state.scope) ?? 'USER'
+function requireUserScope(scope: UserMeScope | null): UserMeScope {
+ if (!scope) {
+ throw new Error('사용자 권한 정보가 준비되지 않았습니다.')
+ }
+ return scope
+}
+
+function useCurrentUserScope(): UserMeScope | null {
+ return useAuthStore(state => state.scope)
}
export function useUpdateNicknameMutation() {
@@ -30,9 +37,14 @@ export function useUpdateNicknameMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: (nickname: string) => updateUserNickname({ scope, nickname }),
+ mutationFn: (nickname: string) =>
+ updateUserNickname({ scope: requireUserScope(scope), nickname }),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.me(scope),
+ })
+ }
},
})
}
@@ -42,9 +54,14 @@ export function useUpdateProfileImageMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: (fileId: string) => updateUserProfileImage({ scope, fileId }),
+ mutationFn: (fileId: string) =>
+ updateUserProfileImage({ scope: requireUserScope(scope), fileId }),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.me(scope),
+ })
+ }
},
})
}
@@ -54,9 +71,13 @@ export function useDeleteProfileImageMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: () => deleteUserProfileImage(scope),
+ mutationFn: () => deleteUserProfileImage(requireUserScope(scope)),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.me(scope),
+ })
+ }
},
})
}
@@ -66,7 +87,7 @@ export function useUpdatePasswordMutation() {
return useMutation({
mutationFn: (payload: { currentPassword?: string; newPassword: string }) =>
- updateUserPassword({ scope, ...payload }),
+ updateUserPassword({ scope: requireUserScope(scope), ...payload }),
})
}
@@ -74,7 +95,8 @@ export function useSendEmailVerificationMutation() {
const scope = useCurrentUserScope()
return useMutation({
- mutationFn: (email: string) => sendUserEmailVerification({ scope, email }),
+ mutationFn: (email: string) =>
+ sendUserEmailVerification({ scope: requireUserScope(scope), email }),
})
}
@@ -83,7 +105,7 @@ export function useVerifyEmailCodeMutation() {
return useMutation({
mutationFn: (payload: { email: string; code: string }) =>
- verifyUserEmailCode({ scope, ...payload }),
+ verifyUserEmailCode({ scope: requireUserScope(scope), ...payload }),
})
}
@@ -92,9 +114,14 @@ export function useUpdateEmailMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: (sessionId: string) => updateUserEmail({ scope, sessionId }),
+ mutationFn: (sessionId: string) =>
+ updateUserEmail({ scope: requireUserScope(scope), sessionId }),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.me(scope),
+ })
+ }
},
})
}
@@ -104,9 +131,13 @@ export function useDeleteEmailMutation() {
const queryClient = useQueryClient()
return useMutation({
- mutationFn: () => deleteUserEmail(scope),
+ mutationFn: () => deleteUserEmail(requireUserScope(scope)),
onSuccess: () => {
- void queryClient.invalidateQueries({ queryKey: queryKeys.user.me(scope) })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.me(scope),
+ })
+ }
},
})
}
@@ -115,7 +146,7 @@ export function useWithdrawUserMutation() {
const scope = useCurrentUserScope()
return useMutation({
- mutationFn: () => withdrawUser(scope),
+ mutationFn: () => withdrawUser(requireUserScope(scope)),
})
}
@@ -125,7 +156,7 @@ export function useUserSocialStatus() {
return useQuery({
queryKey: queryKeys.user.socialStatus(scope),
- queryFn: () => getUserSocialStatus(scope ?? 'USER'),
+ queryFn: () => getUserSocialStatus(requireUserScope(scope)),
enabled: isLoggedIn && Boolean(scope),
staleTime: 60_000,
})
@@ -137,11 +168,13 @@ export function useLinkSocialAccountMutation() {
return useMutation({
mutationFn: (request: LinkSocialAccountRequest) =>
- linkUserSocialAccount({ scope, request }),
+ linkUserSocialAccount({ scope: requireUserScope(scope), request }),
onSuccess: () => {
- void queryClient.invalidateQueries({
- queryKey: queryKeys.user.socialStatus(scope),
- })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.socialStatus(scope),
+ })
+ }
},
})
}
@@ -152,11 +185,13 @@ export function useUnlinkSocialAccountMutation() {
return useMutation({
mutationFn: (provider: SocialProvider) =>
- unlinkUserSocialAccount({ scope, provider }),
+ unlinkUserSocialAccount({ scope: requireUserScope(scope), provider }),
onSuccess: () => {
- void queryClient.invalidateQueries({
- queryKey: queryKeys.user.socialStatus(scope),
- })
+ if (scope) {
+ void queryClient.invalidateQueries({
+ queryKey: queryKeys.user.socialStatus(scope),
+ })
+ }
},
})
}
From 02bc1de9bc05b2b8cd897b6913680c035d8fcf38 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 10:16:10 +0900
Subject: [PATCH 04/15] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?=
=?UTF-8?q?=20=EC=83=81=ED=83=9C=EA=B4=80=EB=A0=A8=20=EC=BD=94=EB=93=9C=20?=
=?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useEmailVerificationFlow.ts | 128 ++++++++++++++++
src/features/user/me/index.ts | 1 +
src/pages/my/profile/email/index.tsx | 141 +++---------------
3 files changed, 153 insertions(+), 117 deletions(-)
create mode 100644 src/features/user/me/hooks/useEmailVerificationFlow.ts
diff --git a/src/features/user/me/hooks/useEmailVerificationFlow.ts b/src/features/user/me/hooks/useEmailVerificationFlow.ts
new file mode 100644
index 0000000..f8a6381
--- /dev/null
+++ b/src/features/user/me/hooks/useEmailVerificationFlow.ts
@@ -0,0 +1,128 @@
+import { useState } from 'react'
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import {
+ useDeleteEmailMutation,
+ useSendEmailVerificationMutation,
+ useUpdateEmailMutation,
+ useVerifyEmailCodeMutation,
+} from './useUserMeMutations'
+
+function isEmailValid(value: string): boolean {
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim())
+}
+
+export function useEmailVerificationFlow(currentEmail: string) {
+ const sendEmailVerificationMutation = useSendEmailVerificationMutation()
+ const verifyEmailCodeMutation = useVerifyEmailCodeMutation()
+ const updateEmailMutation = useUpdateEmailMutation()
+ const deleteEmailMutation = useDeleteEmailMutation()
+
+ const [email, setEmailState] = useState(currentEmail)
+ const [code, setCodeState] = 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 setEmail = (value: string) => {
+ setEmailState(value)
+ setSessionId('')
+ }
+
+ const setCode = (value: string) => {
+ setCodeState(value)
+ setSessionId('')
+ }
+
+ const send = async () => {
+ clearMessages()
+ if (!isEmailValid(trimmedEmail)) {
+ setMessage('올바른 이메일을 입력해 주세요.')
+ return
+ }
+
+ try {
+ await sendEmailVerificationMutation.mutateAsync(trimmedEmail)
+ setSessionId('')
+ setSuccessMessage('인증 코드가 발송되었습니다.')
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '인증 코드 발송에 실패했습니다.'))
+ }
+ }
+
+ const verify = async () => {
+ clearMessages()
+ try {
+ const nextSessionId = await verifyEmailCodeMutation.mutateAsync({
+ email: trimmedEmail,
+ code: code.trim(),
+ })
+ setSessionId(nextSessionId)
+ setSuccessMessage('이메일 인증이 완료되었습니다.')
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '인증 코드 확인에 실패했습니다.'))
+ }
+ }
+
+ const update = async () => {
+ clearMessages()
+ try {
+ await updateEmailMutation.mutateAsync(sessionId)
+ setSuccessMessage('이메일이 등록/변경되었습니다.')
+ } catch (error) {
+ setMessage(
+ getAxiosErrorMessage(error, '이메일 등록/변경에 실패했습니다.')
+ )
+ }
+ }
+
+ const deleteEmail = async () => {
+ clearMessages()
+ try {
+ await deleteEmailMutation.mutateAsync()
+ setEmailState('')
+ setCodeState('')
+ setSessionId('')
+ setSuccessMessage('이메일이 삭제되었습니다.')
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '이메일 삭제에 실패했습니다.'))
+ }
+ }
+
+ return {
+ email,
+ code,
+ sessionId,
+ message,
+ successMessage,
+ isPending,
+ trimmedEmail,
+ canSend,
+ canVerify,
+ canUpdate,
+ canDelete,
+ setEmail,
+ setCode,
+ clearMessages,
+ send,
+ verify,
+ update,
+ deleteEmail,
+ }
+}
diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts
index 474c25f..03b217c 100644
--- a/src/features/user/me/index.ts
+++ b/src/features/user/me/index.ts
@@ -28,6 +28,7 @@ export {
useVerifyEmailCodeMutation,
useWithdrawUserMutation,
} from './hooks/useUserMeMutations'
+export { useEmailVerificationFlow } from './hooks/useEmailVerificationFlow'
export type { UserMeViewModel } from './hooks/useUserMe'
export type {
LinkSocialAccountRequest,
diff --git a/src/pages/my/profile/email/index.tsx b/src/pages/my/profile/email/index.tsx
index ddd15c3..55388fc 100644
--- a/src/pages/my/profile/email/index.tsx
+++ b/src/pages/my/profile/email/index.tsx
@@ -1,108 +1,14 @@
-import { useState } from 'react'
-import {
- useDeleteEmailMutation,
- useSendEmailVerificationMutation,
- useUpdateEmailMutation,
- useUserMe,
- useVerifyEmailCodeMutation,
-} from '@/features/user/me'
-import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { useEmailVerificationFlow, useUserMe } from '@/features/user/me'
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, '이메일 삭제에 실패했습니다.'))
- }
- }
+ const emailFlow = useEmailVerificationFlow(currentEmail)
return (
@@ -111,10 +17,14 @@ export function EmailEditPage() {
variant="detail"
title="이메일 관리"
rightAction={
- canDelete ? (
+ emailFlow.canDelete ? (
setDeleteModalOpen(false)}
- onConfirm={handleDeleteAvatar}
+ pending={isDeletePending}
+ onClose={closeDeleteModal}
+ onConfirm={confirmDelete}
/>
From f710af045520c67c2430a914d79e25cf4fb7bbdc Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 10:19:37 +0900
Subject: [PATCH 06/15] =?UTF-8?q?refactor:=20=EB=8B=89=EB=84=A4=EC=9E=84?=
=?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?=
=?UTF-8?q?=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EB=8B=A8=EC=88=9C=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useChangeNickname.ts | 48 +++++++++++++++++++
src/features/user/me/index.ts | 1 +
src/pages/my/profile/nickname/index.tsx | 44 +++++------------
3 files changed, 61 insertions(+), 32 deletions(-)
create mode 100644 src/features/user/me/hooks/useChangeNickname.ts
diff --git a/src/features/user/me/hooks/useChangeNickname.ts b/src/features/user/me/hooks/useChangeNickname.ts
new file mode 100644
index 0000000..6e88488
--- /dev/null
+++ b/src/features/user/me/hooks/useChangeNickname.ts
@@ -0,0 +1,48 @@
+import { useState } from 'react'
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { useUpdateNicknameMutation } from './useUserMeMutations'
+
+export function useChangeNickname(currentNickname: string) {
+ const updateNicknameMutation = useUpdateNicknameMutation()
+ const [nickname, setNickname] = useState(currentNickname)
+ const [message, setMessage] = useState('')
+
+ const trimmedNickname = nickname.trim()
+ const canSubmit =
+ trimmedNickname.length > 0 &&
+ trimmedNickname.length <= 64 &&
+ trimmedNickname !== currentNickname &&
+ !updateNicknameMutation.isPending
+
+ const submit = async (): Promise => {
+ setMessage('')
+ if (!trimmedNickname) {
+ setMessage('닉네임을 입력해 주세요.')
+ return false
+ }
+ if (trimmedNickname.length > 64) {
+ setMessage('닉네임은 64자 이하로 입력해 주세요.')
+ return false
+ }
+ if (trimmedNickname === currentNickname) {
+ setMessage('현재 닉네임과 다른 닉네임을 입력해 주세요.')
+ return false
+ }
+
+ try {
+ await updateNicknameMutation.mutateAsync(trimmedNickname)
+ return true
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '닉네임 변경에 실패했습니다.'))
+ return false
+ }
+ }
+
+ return {
+ nickname,
+ setNickname,
+ message,
+ canSubmit,
+ submit,
+ }
+}
diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts
index 1cc536e..6848531 100644
--- a/src/features/user/me/index.ts
+++ b/src/features/user/me/index.ts
@@ -28,6 +28,7 @@ export {
useVerifyEmailCodeMutation,
useWithdrawUserMutation,
} from './hooks/useUserMeMutations'
+export { useChangeNickname } from './hooks/useChangeNickname'
export { useEmailVerificationFlow } from './hooks/useEmailVerificationFlow'
export { useProfileImageEditor } from './hooks/useProfileImageEditor'
export type { UserMeViewModel } from './hooks/useUserMe'
diff --git a/src/pages/my/profile/nickname/index.tsx b/src/pages/my/profile/nickname/index.tsx
index cd1e941..65855bb 100644
--- a/src/pages/my/profile/nickname/index.tsx
+++ b/src/pages/my/profile/nickname/index.tsx
@@ -1,7 +1,5 @@
-import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
-import { useUpdateNicknameMutation, useUserMe } from '@/features/user/me'
-import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { useChangeNickname, useUserMe } from '@/features/user/me'
import { AuthButton } from '@/shared/ui/common/AuthButton'
import { AuthInput } from '@/shared/ui/common/AuthInput'
import { Navbar } from '@/shared/ui/common/Navbar'
@@ -10,33 +8,12 @@ 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 changeNickname = useChangeNickname(user.nickname)
const handleSubmit = async () => {
- setMessage('')
- if (!trimmedNickname) {
- setMessage('닉네임을 입력해 주세요.')
- return
- }
- if (trimmedNickname.length > 64) {
- setMessage('닉네임은 64자 이하로 입력해 주세요.')
- return
- }
-
- try {
- await updateNicknameMutation.mutateAsync(trimmedNickname)
+ const success = await changeNickname.submit()
+ if (success) {
navigate(ROUTES.MY.PROFILE, { replace: true })
- } catch (error) {
- setMessage(getAxiosErrorMessage(error, '닉네임 변경에 실패했습니다.'))
}
}
@@ -51,22 +28,25 @@ export function NicknameEditPage() {
새 닉네임
setNickname(event.target.value)}
+ onChange={event => changeNickname.setNickname(event.target.value)}
/>
현재 닉네임과 다른 64자 이하의 닉네임을 입력해 주세요.
- {message && (
+ {changeNickname.message && (
- {message}
+ {changeNickname.message}
)}
From a9ae2b8d83292f32a845ca56bbfa651066075b59 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 10:21:16 +0900
Subject: [PATCH 07/15] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?=
=?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?=
=?UTF-8?q?=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/usePasswordUpdateFeature.ts | 62 +++++++++++++++++++
src/features/user/me/index.ts | 1 +
src/pages/my/profile/password/index.tsx | 60 ++++--------------
3 files changed, 75 insertions(+), 48 deletions(-)
create mode 100644 src/features/user/me/hooks/usePasswordUpdateFeature.ts
diff --git a/src/features/user/me/hooks/usePasswordUpdateFeature.ts b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
new file mode 100644
index 0000000..0965724
--- /dev/null
+++ b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
@@ -0,0 +1,62 @@
+import { useNavigate } from 'react-router-dom'
+import { ROUTES } from '@/shared/constants/routes'
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { useUpdatePasswordMutation } from './useUserMeMutations'
+import { useState } from 'react'
+
+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 usePasswordUpdateFeature(options: {
+ currentPassword: string
+ newPassword: string
+ confirmPassword: string
+}) {
+ const navigate = useNavigate()
+ const updatePasswordMutation = useUpdatePasswordMutation()
+ const [message, setMessage] = useState('')
+
+ const validate = (): string => {
+ if (!isPasswordFormatValid(options.newPassword)) {
+ return '새 비밀번호는 8~16자, 영문·숫자·특수문자를 모두 포함해야 합니다.'
+ }
+ if (options.newPassword !== options.confirmPassword) {
+ return '새 비밀번호가 일치하지 않습니다.'
+ }
+ return ''
+ }
+
+ const canSubmit = !validate() && !updatePasswordMutation.isPending
+
+ const handleSubmit = async () => {
+ setMessage('')
+ const validationMessage = validate()
+ if (validationMessage) {
+ setMessage(validationMessage)
+ return
+ }
+
+ try {
+ await updatePasswordMutation.mutateAsync({
+ currentPassword: options.currentPassword.trim() || undefined,
+ newPassword: options.newPassword,
+ })
+ navigate(ROUTES.MY.PROFILE, { replace: true })
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '비밀번호 변경에 실패했습니다.'))
+ }
+ }
+
+ return {
+ message,
+ canSubmit,
+ validate,
+ handleSubmit,
+ }
+}
diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts
index 6848531..50e283a 100644
--- a/src/features/user/me/index.ts
+++ b/src/features/user/me/index.ts
@@ -30,6 +30,7 @@ export {
} from './hooks/useUserMeMutations'
export { useChangeNickname } from './hooks/useChangeNickname'
export { useEmailVerificationFlow } from './hooks/useEmailVerificationFlow'
+export { usePasswordUpdateFeature } from './hooks/usePasswordUpdateFeature'
export { useProfileImageEditor } from './hooks/useProfileImageEditor'
export type { UserMeViewModel } from './hooks/useUserMe'
export type {
diff --git a/src/pages/my/profile/password/index.tsx b/src/pages/my/profile/password/index.tsx
index d20d3af..088680a 100644
--- a/src/pages/my/profile/password/index.tsx
+++ b/src/pages/my/profile/password/index.tsx
@@ -1,57 +1,18 @@
import { useState } from 'react'
-import { useNavigate } from 'react-router-dom'
-import { useUpdatePasswordMutation } from '@/features/user/me'
-import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { usePasswordUpdateFeature } from '@/features/user/me'
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, '비밀번호 변경에 실패했습니다.'))
- }
- }
+ const passwordUpdate = usePasswordUpdateFeature({
+ currentPassword,
+ newPassword,
+ confirmPassword,
+ })
return (
@@ -99,14 +60,17 @@ export function PasswordEditPage() {
/>
- {message && (
+ {passwordUpdate.message && (
- {message}
+ {passwordUpdate.message}
)}
From b6749de577a1e5223a03e0865a12a23b88f1e270 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 10:22:57 +0900
Subject: [PATCH 08/15] =?UTF-8?q?refactor:=20=EC=86=8C=EC=85=9C=20?=
=?UTF-8?q?=EC=97=B0=EB=8F=99=20=EB=A1=9C=EC=A7=81=20=EC=BA=A1=EC=8A=90?=
=?UTF-8?q?=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useSocialAccountLinking.ts | 101 ++++++++++++++++++
src/features/user/me/index.ts | 1 +
src/pages/my/profile/social/index.tsx | 94 ++--------------
3 files changed, 111 insertions(+), 85 deletions(-)
create mode 100644 src/features/user/me/hooks/useSocialAccountLinking.ts
diff --git a/src/features/user/me/hooks/useSocialAccountLinking.ts b/src/features/user/me/hooks/useSocialAccountLinking.ts
new file mode 100644
index 0000000..bfabcf5
--- /dev/null
+++ b/src/features/user/me/hooks/useSocialAccountLinking.ts
@@ -0,0 +1,101 @@
+import { useState } from 'react'
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import {
+ getKakaoOAuthRedirectUri,
+ loginWithApple,
+ requestFreshKakaoAuthorizationCode,
+} from '@/shared/lib/socialLogin'
+import type {
+ LinkSocialAccountRequest,
+ SocialProvider,
+} from '@/features/user/me/types/user'
+import {
+ useLinkSocialAccountMutation,
+ useUnlinkSocialAccountMutation,
+} from './useUserMeMutations'
+
+const PROVIDER_LABELS: Record = {
+ KAKAO: '카카오',
+ APPLE: 'Apple',
+}
+
+export function useSocialAccountLinking() {
+ const linkSocialAccountMutation = useLinkSocialAccountMutation()
+ const unlinkSocialAccountMutation = useUnlinkSocialAccountMutation()
+ const [message, setMessage] = useState('')
+ const [pendingProvider, setPendingProvider] = useState(
+ null
+ )
+
+ const isPending =
+ linkSocialAccountMutation.isPending ||
+ unlinkSocialAccountMutation.isPending ||
+ pendingProvider !== null
+
+ const link = 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(`${PROVIDER_LABELS[provider]} 계정이 연동되었습니다.`)
+ } catch (error) {
+ setMessage(getAxiosErrorMessage(error, '소셜 계정 연동에 실패했습니다.'))
+ } finally {
+ setPendingProvider(null)
+ }
+ }
+
+ const unlink = async (provider: SocialProvider) => {
+ if (
+ !window.confirm(`${PROVIDER_LABELS[provider]} 계정 연동을 해제할까요?`)
+ ) {
+ return
+ }
+ setMessage('')
+ setPendingProvider(provider)
+ try {
+ await unlinkSocialAccountMutation.mutateAsync(provider)
+ setMessage(`${PROVIDER_LABELS[provider]} 계정 연동이 해제되었습니다.`)
+ } catch (error) {
+ setMessage(
+ getAxiosErrorMessage(error, '소셜 계정 연동 해제에 실패했습니다.')
+ )
+ } finally {
+ setPendingProvider(null)
+ }
+ }
+
+ return {
+ pendingProvider,
+ message,
+ isPending,
+ link,
+ unlink,
+ }
+}
diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts
index 50e283a..1c37e8d 100644
--- a/src/features/user/me/index.ts
+++ b/src/features/user/me/index.ts
@@ -32,6 +32,7 @@ export { useChangeNickname } from './hooks/useChangeNickname'
export { useEmailVerificationFlow } from './hooks/useEmailVerificationFlow'
export { usePasswordUpdateFeature } from './hooks/usePasswordUpdateFeature'
export { useProfileImageEditor } from './hooks/useProfileImageEditor'
+export { useSocialAccountLinking } from './hooks/useSocialAccountLinking'
export type { UserMeViewModel } from './hooks/useUserMe'
export type {
LinkSocialAccountRequest,
diff --git a/src/pages/my/profile/social/index.tsx b/src/pages/my/profile/social/index.tsx
index e2aa139..ce0fb1d 100644
--- a/src/pages/my/profile/social/index.tsx
+++ b/src/pages/my/profile/social/index.tsx
@@ -1,17 +1,9 @@
-import { useMemo, useState } from 'react'
+import { useMemo } from 'react'
import {
- type LinkSocialAccountRequest,
type SocialProvider,
- useLinkSocialAccountMutation,
- useUnlinkSocialAccountMutation,
+ useSocialAccountLinking,
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 }> = [
@@ -31,81 +23,13 @@ function formatLinkedAt(value?: string | null): string {
export function SocialAccountPage() {
const { data = [], isLoading, isError } = useUserSocialStatus()
- const linkSocialAccountMutation = useLinkSocialAccountMutation()
- const unlinkSocialAccountMutation = useUnlinkSocialAccountMutation()
- const [message, setMessage] = useState('')
- const [pendingProvider, setPendingProvider] = useState(
- null
- )
+ const socialLinking = useSocialAccountLinking()
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 (
@@ -121,7 +45,7 @@ export function SocialAccountPage() {
{PROVIDERS.map(item => {
const status = statusMap.get(item.provider)
const linked = Boolean(status?.linked)
- const pending = pendingProvider === item.provider
+ const pending = socialLinking.pendingProvider === item.provider
return (
linked
- ? handleUnlink(item.provider)
- : handleLink(item.provider)
+ ? void socialLinking.unlink(item.provider)
+ : void socialLinking.link(item.provider)
}
className="h-10 rounded-xl bg-main px-4 text-white disabled:bg-text-50 typography-body02-regular"
>
@@ -165,12 +89,12 @@ export function SocialAccountPage() {
연동 상태를 불러오지 못했습니다.
)}
- {message && (
+ {socialLinking.message && (
- {message}
+ {socialLinking.message}
)}
From 4b1cc784e13c4289355cacfae50a00615f57d2d6 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 10:24:18 +0900
Subject: [PATCH 09/15] =?UTF-8?q?refactor:=20=ED=9A=8C=EC=9B=90=20?=
=?UTF-8?q?=ED=83=88=ED=87=B4=20=EB=A1=9C=EC=A7=81=20=EC=BA=A1=EC=8A=90?=
=?UTF-8?q?=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useWithdrawUserFlow.ts | 40 +++++++++++++++++++
src/features/user/me/index.ts | 1 +
src/pages/my/withdraw/index.tsx | 36 ++++-------------
3 files changed, 49 insertions(+), 28 deletions(-)
create mode 100644 src/features/user/me/hooks/useWithdrawUserFlow.ts
diff --git a/src/features/user/me/hooks/useWithdrawUserFlow.ts b/src/features/user/me/hooks/useWithdrawUserFlow.ts
new file mode 100644
index 0000000..49f58f9
--- /dev/null
+++ b/src/features/user/me/hooks/useWithdrawUserFlow.ts
@@ -0,0 +1,40 @@
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ROUTES } from '@/shared/constants/routes'
+import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import useAuthStore from '@/shared/stores/useAuthStore'
+import { useWithdrawUserMutation } from './useUserMeMutations'
+
+export function useWithdrawUserFlow() {
+ const navigate = useNavigate()
+ const logout = useAuthStore(state => state.logout)
+ const scope = useAuthStore(state => state.scope)
+ const withdrawUserMutation = useWithdrawUserMutation()
+ const [message, setMessage] = useState('')
+
+ const performWithdraw = async () => {
+ setMessage('')
+ 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 {
+ message,
+ setMessage,
+ isPending: withdrawUserMutation.isPending,
+ performWithdraw,
+ }
+}
diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts
index 1c37e8d..dcc8c4b 100644
--- a/src/features/user/me/index.ts
+++ b/src/features/user/me/index.ts
@@ -33,6 +33,7 @@ export { useEmailVerificationFlow } from './hooks/useEmailVerificationFlow'
export { usePasswordUpdateFeature } from './hooks/usePasswordUpdateFeature'
export { useProfileImageEditor } from './hooks/useProfileImageEditor'
export { useSocialAccountLinking } from './hooks/useSocialAccountLinking'
+export { useWithdrawUserFlow } from './hooks/useWithdrawUserFlow'
export type { UserMeViewModel } from './hooks/useUserMe'
export type {
LinkSocialAccountRequest,
diff --git a/src/pages/my/withdraw/index.tsx b/src/pages/my/withdraw/index.tsx
index c68a7b4..6f6e2a7 100644
--- a/src/pages/my/withdraw/index.tsx
+++ b/src/pages/my/withdraw/index.tsx
@@ -1,41 +1,21 @@
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 { useWithdrawUserFlow } from '@/features/user/me'
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 withdrawFlow = useWithdrawUserFlow()
const handleWithdraw = async () => {
- setMessage('')
+ withdrawFlow.setMessage('')
if (!checked) {
- setMessage('탈퇴 안내를 확인해 주세요.')
+ withdrawFlow.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))
- }
+ await withdrawFlow.performWithdraw()
}
return (
@@ -70,15 +50,15 @@ export function WithdrawPage() {
안내 사항을 확인했습니다.
- {message && (
+ {withdrawFlow.message && (
- {message}
+ {withdrawFlow.message}
)}
From bc9fe196cf87952df0fff61b773949611297228a Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 11:17:57 +0900
Subject: [PATCH 10/15] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC?=
=?UTF-8?q?=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=83=81=ED=83=9C?=
=?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useEmailVerificationFlow.ts | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/src/features/user/me/hooks/useEmailVerificationFlow.ts b/src/features/user/me/hooks/useEmailVerificationFlow.ts
index f8a6381..0c1415a 100644
--- a/src/features/user/me/hooks/useEmailVerificationFlow.ts
+++ b/src/features/user/me/hooks/useEmailVerificationFlow.ts
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
import {
useDeleteEmailMutation,
@@ -23,6 +23,18 @@ export function useEmailVerificationFlow(currentEmail: string) {
const [message, setMessage] = useState('')
const [successMessage, setSuccessMessage] = useState('')
+ useEffect(() => {
+ let cancelled = false
+ queueMicrotask(() => {
+ if (!cancelled) {
+ setEmailState(currentEmail || '')
+ }
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [currentEmail])
+
const isPending =
sendEmailVerificationMutation.isPending ||
verifyEmailCodeMutation.isPending ||
From 7ecb2a988372160f63687111442e832e115b563a Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 11:18:55 +0900
Subject: [PATCH 11/15] =?UTF-8?q?fix:=20newPassword=20trim=20=EA=B2=80?=
=?UTF-8?q?=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/usePasswordUpdateFeature.ts | 11 +++++++----
1 file changed, 7 insertions(+), 4 deletions(-)
diff --git a/src/features/user/me/hooks/usePasswordUpdateFeature.ts b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
index 0965724..90c1923 100644
--- a/src/features/user/me/hooks/usePasswordUpdateFeature.ts
+++ b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
@@ -21,12 +21,15 @@ export function usePasswordUpdateFeature(options: {
const navigate = useNavigate()
const updatePasswordMutation = useUpdatePasswordMutation()
const [message, setMessage] = useState('')
+ const currentPassword = options.currentPassword.trim()
+ const newPassword = options.newPassword.trim()
+ const confirmPassword = options.confirmPassword.trim()
const validate = (): string => {
- if (!isPasswordFormatValid(options.newPassword)) {
+ if (!isPasswordFormatValid(newPassword)) {
return '새 비밀번호는 8~16자, 영문·숫자·특수문자를 모두 포함해야 합니다.'
}
- if (options.newPassword !== options.confirmPassword) {
+ if (newPassword !== confirmPassword) {
return '새 비밀번호가 일치하지 않습니다.'
}
return ''
@@ -44,8 +47,8 @@ export function usePasswordUpdateFeature(options: {
try {
await updatePasswordMutation.mutateAsync({
- currentPassword: options.currentPassword.trim() || undefined,
- newPassword: options.newPassword,
+ currentPassword: currentPassword || undefined,
+ newPassword,
})
navigate(ROUTES.MY.PROFILE, { replace: true })
} catch (error) {
From b41955d8d15bee9315657d6ba2957a295a075b07 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 11:20:28 +0900
Subject: [PATCH 12/15] =?UTF-8?q?refactor:=20=EA=B3=B5=EC=9A=A9=20?=
=?UTF-8?q?=ED=8C=8C=EC=9D=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20API=20?=
=?UTF-8?q?=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../store-register/api/workspaceFileUpload.ts | 63 +++----------------
.../user/me/hooks/useProfileImageEditor.ts | 2 +-
src/shared/api/appFileUpload.ts | 57 +++++++++++++++++
3 files changed, 65 insertions(+), 57 deletions(-)
create mode 100644 src/shared/api/appFileUpload.ts
diff --git a/src/features/store-register/api/workspaceFileUpload.ts b/src/features/store-register/api/workspaceFileUpload.ts
index 370d5af..b7c88be 100644
--- a/src/features/store-register/api/workspaceFileUpload.ts
+++ b/src/features/store-register/api/workspaceFileUpload.ts
@@ -1,64 +1,15 @@
-import axiosInstance from '@/shared/lib/axiosInstance'
-import type { CommonApiResponse } from '@/shared/types/common'
+import {
+ uploadAppFile,
+ type AppFileBucketType,
+ type AppFileUploadTargetType,
+} from '@/shared/api/appFileUpload'
-/** POST /app/files — query `targetType` (Swagger) */
-export type AppFileUploadTargetType =
- | 'USER_PROFILE'
- | 'USER_CERTIFICATE'
- | 'POSTING'
- | 'WORKSPACE'
- | 'WORKSPACE_CERTIFICATE'
- | 'WORKSPACE_OWN_IDENTITY'
- | 'WORKSPACE_WARRANT'
- | 'WORKSPACE_REASON_COMMENT'
- | 'CHAT_MESSAGE'
-
-export type AppFileBucketType = 'PUBLIC' | 'PRIVATE'
-
-const APP_FILES_PATH = '/app/files'
+export { uploadAppFile }
+export type { AppFileBucketType, AppFileUploadTargetType }
/** 증빙 서류는 비공개 버킷 권장(백엔드 요구 시 PUBLIC 로 바꿀 수 있음) */
export const WORKSPACE_REGISTRATION_BUCKET: AppFileBucketType = 'PRIVATE'
-function extractUploadedFileId(data: unknown): string {
- if (typeof data !== 'object' || data === null) {
- throw new Error('파일 업로드 응답이 올바르지 않습니다.')
- }
- const envelope = data as { data?: unknown }
- const inner = envelope.data
- if (typeof inner === 'object' && inner !== null) {
- const o = inner as Record
- if (typeof o.fileId === 'string' && o.fileId.length > 0) return o.fileId
- if (typeof o.id === 'string' && o.id.length > 0) return o.id
- }
- throw new Error('파일 업로드 응답에 파일 ID가 없습니다.')
-}
-
-/**
- * POST /app/files
- * multipart 파트 이름 `file` + query targetType, bucketType
- * (Swagger 의 application/json 예시는 실제 업로드와 다를 수 있음)
- */
-export async function uploadAppFile(options: {
- file: File
- targetType: AppFileUploadTargetType
- bucketType: AppFileBucketType
-}): Promise {
- const formData = new FormData()
- formData.append('file', options.file)
-
- const response = await axiosInstance.post<
- CommonApiResponse<{ fileId?: string; id?: string } | unknown>
- >(APP_FILES_PATH, formData, {
- params: {
- targetType: options.targetType,
- bucketType: options.bucketType,
- },
- })
-
- return extractUploadedFileId(response.data)
-}
-
export type WorkspaceRegistrationAttachmentKind =
| 'CERTIFICATE'
| 'OWN_IDENTITY'
diff --git a/src/features/user/me/hooks/useProfileImageEditor.ts b/src/features/user/me/hooks/useProfileImageEditor.ts
index 61e19be..a4751d9 100644
--- a/src/features/user/me/hooks/useProfileImageEditor.ts
+++ b/src/features/user/me/hooks/useProfileImageEditor.ts
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
-import { uploadAppFile } from '@/features/store-register/api/workspaceFileUpload'
+import { uploadAppFile } from '@/shared/api/appFileUpload'
import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
import {
useDeleteProfileImageMutation,
diff --git a/src/shared/api/appFileUpload.ts b/src/shared/api/appFileUpload.ts
new file mode 100644
index 0000000..be9701e
--- /dev/null
+++ b/src/shared/api/appFileUpload.ts
@@ -0,0 +1,57 @@
+import axiosInstance from '@/shared/lib/axiosInstance'
+import type { CommonApiResponse } from '@/shared/types/common'
+
+/** POST /app/files — query `targetType` (Swagger) */
+export type AppFileUploadTargetType =
+ | 'USER_PROFILE'
+ | 'USER_CERTIFICATE'
+ | 'POSTING'
+ | 'WORKSPACE'
+ | 'WORKSPACE_CERTIFICATE'
+ | 'WORKSPACE_OWN_IDENTITY'
+ | 'WORKSPACE_WARRANT'
+ | 'WORKSPACE_REASON_COMMENT'
+ | 'CHAT_MESSAGE'
+
+export type AppFileBucketType = 'PUBLIC' | 'PRIVATE'
+
+const APP_FILES_PATH = '/app/files'
+
+function extractUploadedFileId(data: unknown): string {
+ if (typeof data !== 'object' || data === null) {
+ throw new Error('파일 업로드 응답이 올바르지 않습니다.')
+ }
+ const envelope = data as { data?: unknown }
+ const inner = envelope.data
+ if (typeof inner === 'object' && inner !== null) {
+ const o = inner as Record
+ if (typeof o.fileId === 'string' && o.fileId.length > 0) return o.fileId
+ if (typeof o.id === 'string' && o.id.length > 0) return o.id
+ }
+ throw new Error('파일 업로드 응답에 파일 ID가 없습니다.')
+}
+
+/**
+ * POST /app/files
+ * multipart 파트 이름 `file` + query targetType, bucketType
+ * (Swagger 의 application/json 예시는 실제 업로드와 다를 수 있음)
+ */
+export async function uploadAppFile(options: {
+ file: File
+ targetType: AppFileUploadTargetType
+ bucketType: AppFileBucketType
+}): Promise {
+ const formData = new FormData()
+ formData.append('file', options.file)
+
+ const response = await axiosInstance.post<
+ CommonApiResponse<{ fileId?: string; id?: string } | unknown>
+ >(APP_FILES_PATH, formData, {
+ params: {
+ targetType: options.targetType,
+ bucketType: options.bucketType,
+ },
+ })
+
+ return extractUploadedFileId(response.data)
+}
From 071900d0c7485f60292dbb158bc3519711b71101 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Thu, 21 May 2026 23:04:12 +0900
Subject: [PATCH 13/15] =?UTF-8?q?refactor:=20=EC=97=85=EB=A1=9C=EB=93=9C?=
=?UTF-8?q?=20=EC=A4=91=20=EC=83=81=ED=83=9C=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/features/user/me/hooks/useProfileImageEditor.ts | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/src/features/user/me/hooks/useProfileImageEditor.ts b/src/features/user/me/hooks/useProfileImageEditor.ts
index a4751d9..60c8e68 100644
--- a/src/features/user/me/hooks/useProfileImageEditor.ts
+++ b/src/features/user/me/hooks/useProfileImageEditor.ts
@@ -10,11 +10,14 @@ export function useProfileImageEditor(avatarUrl?: string) {
const fileInputRef = useRef(null)
const [imageError, setImageError] = useState('')
const [deleteModalOpen, setDeleteModalOpen] = useState(false)
+ const [isUploadPending, setIsUploadPending] = useState(false)
const updateProfileImageMutation = useUpdateProfileImageMutation()
const deleteProfileImageMutation = useDeleteProfileImageMutation()
const isImagePending =
- updateProfileImageMutation.isPending || deleteProfileImageMutation.isPending
+ isUploadPending ||
+ updateProfileImageMutation.isPending ||
+ deleteProfileImageMutation.isPending
const triggerFileInput = () => {
setImageError('')
@@ -27,6 +30,7 @@ export function useProfileImageEditor(avatarUrl?: string) {
if (!file) return
try {
+ setIsUploadPending(true)
const fileId = await uploadAppFile({
file,
targetType: 'USER_PROFILE',
@@ -37,6 +41,8 @@ export function useProfileImageEditor(avatarUrl?: string) {
setImageError(
getAxiosErrorMessage(error, '프로필 이미지 변경에 실패했습니다.')
)
+ } finally {
+ setIsUploadPending(false)
}
}
From 9515f532c4a13473ee8f4d3fc28e9acaa92370af Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Fri, 22 May 2026 09:26:19 +0900
Subject: [PATCH 14/15] =?UTF-8?q?refactor:=20=ED=83=88=ED=87=B4=20?=
=?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EB=B0=A9=EC=96=B4=20=EB=A1=9C=EC=A7=81=20?=
=?UTF-8?q?=EA=B0=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/useWithdrawUserFlow.ts | 17 ++++++++++++++---
1 file changed, 14 insertions(+), 3 deletions(-)
diff --git a/src/features/user/me/hooks/useWithdrawUserFlow.ts b/src/features/user/me/hooks/useWithdrawUserFlow.ts
index 49f58f9..ad8273a 100644
--- a/src/features/user/me/hooks/useWithdrawUserFlow.ts
+++ b/src/features/user/me/hooks/useWithdrawUserFlow.ts
@@ -1,4 +1,4 @@
-import { useState } from 'react'
+import { useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ROUTES } from '@/shared/constants/routes'
import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
@@ -10,15 +10,24 @@ export function useWithdrawUserFlow() {
const logout = useAuthStore(state => state.logout)
const scope = useAuthStore(state => state.scope)
const withdrawUserMutation = useWithdrawUserMutation()
+ const isPerformingWithdrawRef = useRef(false)
const [message, setMessage] = useState('')
const performWithdraw = async () => {
- setMessage('')
- if (!window.confirm('정말 탈퇴하시겠어요? 이 작업은 되돌릴 수 없습니다.')) {
+ if (isPerformingWithdrawRef.current || withdrawUserMutation.isPending) {
return
}
+ isPerformingWithdrawRef.current = true
+ setMessage('')
+
try {
+ if (
+ !window.confirm('정말 탈퇴하시겠어요? 이 작업은 되돌릴 수 없습니다.')
+ ) {
+ return
+ }
+
await withdrawUserMutation.mutateAsync()
logout()
navigate(ROUTES.AUTH.LOGIN, { replace: true })
@@ -28,6 +37,8 @@ export function useWithdrawUserFlow() {
? '운영 중인 업장이 있으면 탈퇴할 수 없습니다.'
: '회원 탈퇴에 실패했습니다.'
setMessage(getAxiosErrorMessage(error, fallback))
+ } finally {
+ isPerformingWithdrawRef.current = false
}
}
From 5d2fdcc7855b2aaed13b64d7454a3ffecdaad269 Mon Sep 17 00:00:00 2001
From: Dohyeon
Date: Mon, 25 May 2026 00:11:31 +0900
Subject: [PATCH 15/15] =?UTF-8?q?refactor:=20=EB=B9=84=EB=B0=80=EB=B2=88?=
=?UTF-8?q?=ED=98=B8=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=9C=A0?=
=?UTF-8?q?=ED=8B=B8=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../user/me/hooks/usePasswordUpdateFeature.ts | 21 ++--------
src/pages/find-password/index.tsx | 14 ++++---
.../signup/components/Step2AccountInfo.tsx | 2 +-
src/pages/signup/hooks/useSignupForm.ts | 18 +++++----
src/shared/lib/utils/passwordValidation.ts | 40 +++++++++++++++++++
src/shared/lib/utils/signupValidation.ts | 10 -----
6 files changed, 62 insertions(+), 43 deletions(-)
create mode 100644 src/shared/lib/utils/passwordValidation.ts
diff --git a/src/features/user/me/hooks/usePasswordUpdateFeature.ts b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
index 90c1923..bc02d1b 100644
--- a/src/features/user/me/hooks/usePasswordUpdateFeature.ts
+++ b/src/features/user/me/hooks/usePasswordUpdateFeature.ts
@@ -1,18 +1,10 @@
import { useNavigate } from 'react-router-dom'
import { ROUTES } from '@/shared/constants/routes'
import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage'
+import { validatePasswordWithConfirm } from '@/shared/lib/utils/passwordValidation'
import { useUpdatePasswordMutation } from './useUserMeMutations'
import { useState } from 'react'
-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 usePasswordUpdateFeature(options: {
currentPassword: string
newPassword: string
@@ -25,15 +17,8 @@ export function usePasswordUpdateFeature(options: {
const newPassword = options.newPassword.trim()
const confirmPassword = options.confirmPassword.trim()
- const validate = (): string => {
- if (!isPasswordFormatValid(newPassword)) {
- return '새 비밀번호는 8~16자, 영문·숫자·특수문자를 모두 포함해야 합니다.'
- }
- if (newPassword !== confirmPassword) {
- return '새 비밀번호가 일치하지 않습니다.'
- }
- return ''
- }
+ const validate = (): string =>
+ validatePasswordWithConfirm(newPassword, confirmPassword)
const canSubmit = !validate() && !updatePasswordMutation.isPending
diff --git a/src/pages/find-password/index.tsx b/src/pages/find-password/index.tsx
index c3a73d3..d68e221 100644
--- a/src/pages/find-password/index.tsx
+++ b/src/pages/find-password/index.tsx
@@ -5,7 +5,11 @@ import { AuthInput } from '@/shared/ui/common/AuthInput'
import { AuthButton } from '@/shared/ui/common/AuthButton'
import { resetPassword } from '@/shared/api/auth'
import { ROUTES } from '@/shared/constants/routes'
-import { isPasswordValid } from '@/shared/lib/utils/signupValidation'
+import {
+ isPasswordFormatValid,
+ PASSWORD_FORMAT_ERROR_MESSAGE,
+ PASSWORD_MISMATCH_ERROR_MESSAGE,
+} from '@/shared/lib/utils/passwordValidation'
import { isValidEmailFormat } from '@/shared/lib/utils/emailValidation'
import { PhoneVerification } from '@/features/auth'
import {
@@ -68,14 +72,12 @@ export function FindPasswordPage() {
setPasswordError('새 비밀번호를 입력해주세요.')
return
}
- if (!isPasswordValid(newPassword)) {
- setPasswordError(
- '비밀번호는 최소 8자이며, 영문/숫자/특수문자 중 2가지 이상을 포함해야 합니다.'
- )
+ if (!isPasswordFormatValid(newPassword)) {
+ setPasswordError(PASSWORD_FORMAT_ERROR_MESSAGE)
return
}
if (newPassword !== confirmPassword) {
- setConfirmPasswordError('비밀번호가 서로 일치하지 않습니다.')
+ setConfirmPasswordError(PASSWORD_MISMATCH_ERROR_MESSAGE)
return
}
if (!phoneVerification.sessionId) {
diff --git a/src/pages/signup/components/Step2AccountInfo.tsx b/src/pages/signup/components/Step2AccountInfo.tsx
index 4175da9..208b3b2 100644
--- a/src/pages/signup/components/Step2AccountInfo.tsx
+++ b/src/pages/signup/components/Step2AccountInfo.tsx
@@ -163,7 +163,7 @@ export function Step2AccountInfo({
handlePasswordChange(e.target.value)}
/>
diff --git a/src/pages/signup/hooks/useSignupForm.ts b/src/pages/signup/hooks/useSignupForm.ts
index 609fcd0..558f2e7 100644
--- a/src/pages/signup/hooks/useSignupForm.ts
+++ b/src/pages/signup/hooks/useSignupForm.ts
@@ -14,7 +14,11 @@ import {
} from '@/shared/api/auth'
import useAuthStore from '@/shared/stores/useAuthStore'
import {
- isPasswordValid,
+ isPasswordFormatValid,
+ PASSWORD_FORMAT_ERROR_MESSAGE,
+ PASSWORD_MISMATCH_ERROR_MESSAGE,
+} from '@/shared/lib/utils/passwordValidation'
+import {
normalizePhone,
normalizeBirthday,
getGenderCode,
@@ -119,15 +123,13 @@ export function useSignupForm(options?: UseSignupFormOptions) {
setPassword(value)
if (!value.trim()) {
setPasswordError('비밀번호를 입력해주세요.')
- } else if (!isPasswordValid(value)) {
- setPasswordError(
- '비밀번호는 최소 8자이며, 영문/숫자/특수문자 중 2가지 이상을 포함해야 합니다.'
- )
+ } else if (!isPasswordFormatValid(value)) {
+ setPasswordError(PASSWORD_FORMAT_ERROR_MESSAGE)
} else {
setPasswordError('')
}
if (passwordCheck && value !== passwordCheck) {
- setPasswordCheckError('비밀번호가 서로 일치하지 않습니다.')
+ setPasswordCheckError(PASSWORD_MISMATCH_ERROR_MESSAGE)
} else {
setPasswordCheckError('')
}
@@ -138,7 +140,7 @@ export function useSignupForm(options?: UseSignupFormOptions) {
if (!value.trim()) {
setPasswordCheckError('비밀번호 확인을 입력해주세요.')
} else if (value !== password) {
- setPasswordCheckError('비밀번호가 서로 일치하지 않습니다.')
+ setPasswordCheckError(PASSWORD_MISMATCH_ERROR_MESSAGE)
} else {
setPasswordCheckError('')
}
@@ -277,7 +279,7 @@ export function useSignupForm(options?: UseSignupFormOptions) {
nicknameChecked &&
(!emailValue.trim() || emailVerified) &&
agreed &&
- isPasswordValid(password) &&
+ isPasswordFormatValid(password) &&
password === passwordCheck &&
!passwordError &&
!passwordCheckError
diff --git a/src/shared/lib/utils/passwordValidation.ts b/src/shared/lib/utils/passwordValidation.ts
new file mode 100644
index 0000000..d3a9112
--- /dev/null
+++ b/src/shared/lib/utils/passwordValidation.ts
@@ -0,0 +1,40 @@
+const PASSWORD_MIN_LENGTH = 8
+const PASSWORD_MAX_LENGTH = 16
+const HAS_LETTER = /[A-Za-z]/
+const HAS_NUMBER = /\d/
+const HAS_SPECIAL = /[!@#$%^&*(),.?":{}|<>]/
+
+export function isPasswordFormatValid(value: string): boolean {
+ const trimmed = value.trim()
+ if (
+ trimmed.length < PASSWORD_MIN_LENGTH ||
+ trimmed.length > PASSWORD_MAX_LENGTH
+ ) {
+ return false
+ }
+ return (
+ HAS_LETTER.test(trimmed) &&
+ HAS_NUMBER.test(trimmed) &&
+ HAS_SPECIAL.test(trimmed)
+ )
+}
+
+export const PASSWORD_FORMAT_ERROR_MESSAGE =
+ '비밀번호는 8~16자, 영문·숫자·특수문자를 모두 포함해야 합니다.'
+
+export const PASSWORD_MISMATCH_ERROR_MESSAGE = '비밀번호가 일치하지 않습니다.'
+
+export function validatePasswordWithConfirm(
+ newPassword: string,
+ confirmPassword: string
+): string {
+ const password = newPassword.trim()
+ const confirm = confirmPassword.trim()
+ if (!isPasswordFormatValid(password)) {
+ return PASSWORD_FORMAT_ERROR_MESSAGE
+ }
+ if (password !== confirm) {
+ return PASSWORD_MISMATCH_ERROR_MESSAGE
+ }
+ return ''
+}
diff --git a/src/shared/lib/utils/signupValidation.ts b/src/shared/lib/utils/signupValidation.ts
index 8d7d259..3fb4bb2 100644
--- a/src/shared/lib/utils/signupValidation.ts
+++ b/src/shared/lib/utils/signupValidation.ts
@@ -1,13 +1,3 @@
-// ─── 비밀번호 검증 ─────────────────────────────────────────────────────────────
-export const isPasswordValid = (value: string): boolean => {
- const trimmed = value.trim()
- if (trimmed.length < 8) return false
- const hasLetter = /[A-Za-z]/.test(trimmed)
- const hasNumber = /\d/.test(trimmed)
- const hasSpecial = /[!@#$%^&*(),.?":{}|<>]/.test(trimmed)
- return [hasLetter, hasNumber, hasSpecial].filter(Boolean).length >= 2
-}
-
// ─── 전화번호 포맷 헬퍼 ────────────────────────────────────────────────────────
export const normalizePhone = (value: string): string =>
value.replace(/\D/g, '').slice(0, 11)