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/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/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/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/hooks/useEmailVerificationFlow.ts b/src/features/user/me/hooks/useEmailVerificationFlow.ts new file mode 100644 index 0000000..0c1415a --- /dev/null +++ b/src/features/user/me/hooks/useEmailVerificationFlow.ts @@ -0,0 +1,140 @@ +import { useEffect, 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('') + + useEffect(() => { + let cancelled = false + queueMicrotask(() => { + if (!cancelled) { + setEmailState(currentEmail || '') + } + }) + return () => { + cancelled = true + } + }, [currentEmail]) + + 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/hooks/usePasswordUpdateFeature.ts b/src/features/user/me/hooks/usePasswordUpdateFeature.ts new file mode 100644 index 0000000..bc02d1b --- /dev/null +++ b/src/features/user/me/hooks/usePasswordUpdateFeature.ts @@ -0,0 +1,50 @@ +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' + +export function usePasswordUpdateFeature(options: { + currentPassword: string + newPassword: string + confirmPassword: string +}) { + 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 => + validatePasswordWithConfirm(newPassword, confirmPassword) + + const canSubmit = !validate() && !updatePasswordMutation.isPending + + const handleSubmit = async () => { + setMessage('') + const validationMessage = validate() + if (validationMessage) { + setMessage(validationMessage) + return + } + + try { + await updatePasswordMutation.mutateAsync({ + currentPassword: currentPassword || undefined, + newPassword, + }) + navigate(ROUTES.MY.PROFILE, { replace: true }) + } catch (error) { + setMessage(getAxiosErrorMessage(error, '비밀번호 변경에 실패했습니다.')) + } + } + + return { + message, + canSubmit, + validate, + handleSubmit, + } +} diff --git a/src/features/user/me/hooks/useProfileImageEditor.ts b/src/features/user/me/hooks/useProfileImageEditor.ts new file mode 100644 index 0000000..60c8e68 --- /dev/null +++ b/src/features/user/me/hooks/useProfileImageEditor.ts @@ -0,0 +1,84 @@ +import { useRef, useState } from 'react' +import { uploadAppFile } from '@/shared/api/appFileUpload' +import { getAxiosErrorMessage } from '@/shared/lib/getAxiosErrorMessage' +import { + useDeleteProfileImageMutation, + useUpdateProfileImageMutation, +} from './useUserMeMutations' + +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 = + isUploadPending || + updateProfileImageMutation.isPending || + deleteProfileImageMutation.isPending + + const triggerFileInput = () => { + setImageError('') + fileInputRef.current?.click() + } + + const onFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0] + event.target.value = '' + if (!file) return + + try { + setIsUploadPending(true) + const fileId = await uploadAppFile({ + file, + targetType: 'USER_PROFILE', + bucketType: 'PUBLIC', + }) + await updateProfileImageMutation.mutateAsync(fileId) + } catch (error) { + setImageError( + getAxiosErrorMessage(error, '프로필 이미지 변경에 실패했습니다.') + ) + } finally { + setIsUploadPending(false) + } + } + + const openDeleteModal = () => { + if (!avatarUrl) return + setImageError('') + setDeleteModalOpen(true) + } + + const closeDeleteModal = () => { + setDeleteModalOpen(false) + } + + const confirmDelete = async () => { + if (!avatarUrl) return + setImageError('') + try { + await deleteProfileImageMutation.mutateAsync() + setDeleteModalOpen(false) + } catch (error) { + setImageError( + getAxiosErrorMessage(error, '프로필 이미지 삭제에 실패했습니다.') + ) + } + } + + return { + fileInputRef, + imageError, + isImagePending, + deleteModalOpen, + isDeletePending: deleteProfileImageMutation.isPending, + triggerFileInput, + onFileChange, + openDeleteModal, + closeDeleteModal, + confirmDelete, + } +} 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/hooks/useUserMe.ts b/src/features/user/me/hooks/useUserMe.ts index eff035c..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' @@ -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 @@ -16,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 @@ -33,11 +43,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(requireUserScope(scope)), + enabled: isLoggedIn && Boolean(scope), staleTime: 60_000, }) @@ -48,6 +59,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..de4e853 --- /dev/null +++ b/src/features/user/me/hooks/useUserMeMutations.ts @@ -0,0 +1,197 @@ +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 requireUserScope(scope: UserMeScope | null): UserMeScope { + if (!scope) { + throw new Error('사용자 권한 정보가 준비되지 않았습니다.') + } + return scope +} + +function useCurrentUserScope(): UserMeScope | null { + return useAuthStore(state => state.scope) +} + +export function useUpdateNicknameMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (nickname: string) => + updateUserNickname({ scope: requireUserScope(scope), nickname }), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(scope), + }) + } + }, + }) +} + +export function useUpdateProfileImageMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (fileId: string) => + updateUserProfileImage({ scope: requireUserScope(scope), fileId }), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(scope), + }) + } + }, + }) +} + +export function useDeleteProfileImageMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteUserProfileImage(requireUserScope(scope)), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(scope), + }) + } + }, + }) +} + +export function useUpdatePasswordMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (payload: { currentPassword?: string; newPassword: string }) => + updateUserPassword({ scope: requireUserScope(scope), ...payload }), + }) +} + +export function useSendEmailVerificationMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (email: string) => + sendUserEmailVerification({ scope: requireUserScope(scope), email }), + }) +} + +export function useVerifyEmailCodeMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: (payload: { email: string; code: string }) => + verifyUserEmailCode({ scope: requireUserScope(scope), ...payload }), + }) +} + +export function useUpdateEmailMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (sessionId: string) => + updateUserEmail({ scope: requireUserScope(scope), sessionId }), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(scope), + }) + } + }, + }) +} + +export function useDeleteEmailMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: () => deleteUserEmail(requireUserScope(scope)), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.me(scope), + }) + } + }, + }) +} + +export function useWithdrawUserMutation() { + const scope = useCurrentUserScope() + + return useMutation({ + mutationFn: () => withdrawUser(requireUserScope(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(requireUserScope(scope)), + enabled: isLoggedIn && Boolean(scope), + staleTime: 60_000, + }) +} + +export function useLinkSocialAccountMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (request: LinkSocialAccountRequest) => + linkUserSocialAccount({ scope: requireUserScope(scope), request }), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.socialStatus(scope), + }) + } + }, + }) +} + +export function useUnlinkSocialAccountMutation() { + const scope = useCurrentUserScope() + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: (provider: SocialProvider) => + unlinkUserSocialAccount({ scope: requireUserScope(scope), provider }), + onSuccess: () => { + if (scope) { + void queryClient.invalidateQueries({ + queryKey: queryKeys.user.socialStatus(scope), + }) + } + }, + }) +} diff --git a/src/features/user/me/hooks/useWithdrawUserFlow.ts b/src/features/user/me/hooks/useWithdrawUserFlow.ts new file mode 100644 index 0000000..ad8273a --- /dev/null +++ b/src/features/user/me/hooks/useWithdrawUserFlow.ts @@ -0,0 +1,51 @@ +import { useRef, 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 isPerformingWithdrawRef = useRef(false) + const [message, setMessage] = useState('') + + const performWithdraw = async () => { + 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 }) + } catch (error) { + const fallback = + scope === 'MANAGER' + ? '운영 중인 업장이 있으면 탈퇴할 수 없습니다.' + : '회원 탈퇴에 실패했습니다.' + setMessage(getAxiosErrorMessage(error, fallback)) + } finally { + isPerformingWithdrawRef.current = false + } + } + + return { + message, + setMessage, + isPending: withdrawUserMutation.isPending, + performWithdraw, + } +} diff --git a/src/features/user/me/index.ts b/src/features/user/me/index.ts index 3c7bc63..dcc8c4b 100644 --- a/src/features/user/me/index.ts +++ b/src/features/user/me/index.ts @@ -1,9 +1,48 @@ -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 { 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 { useWithdrawUserFlow } from './hooks/useWithdrawUserFlow' 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/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/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..55388fc --- /dev/null +++ b/src/pages/my/profile/email/index.tsx @@ -0,0 +1,112 @@ +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' + +export function EmailEditPage() { + const { user } = useUserMe() + const authUser = useAuthStore(state => state.user) + const currentEmail = user.email || authUser?.email || '' + const emailFlow = useEmailVerificationFlow(currentEmail) + + return ( +
+
+ { + if (window.confirm('등록된 이메일을 삭제할까요?')) { + void emailFlow.deleteEmail() + } + }} + className="text-error typography-body02-regular" + > + 삭제 + + ) : null + } + /> +
+ +
+
+

현재 이메일

+

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

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

+ {emailFlow.message} +

+ )} + {emailFlow.successMessage && ( +

+ {emailFlow.successMessage} +

+ )} + +
+ void emailFlow.update()} + > + 이메일 등록/변경 + +
+
+
+ ) +} diff --git a/src/pages/my/profile/index.tsx b/src/pages/my/profile/index.tsx index e698275..ef75c63 100644 --- a/src/pages/my/profile/index.tsx +++ b/src/pages/my/profile/index.tsx @@ -1,15 +1,94 @@ +import { useEffect } from 'react' import { useNavigate } from 'react-router-dom' import useAuthStore from '@/shared/stores/useAuthStore' -import { useUserMe } from '@/features/user/me' +import { useProfileImageEditor, useUserMe } from '@/features/user/me' +import { ROUTES } from '@/shared/constants/routes' import { Navbar } from '@/shared/ui/common/Navbar' 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', +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() { @@ -17,18 +96,43 @@ export function ProfileEditPage() { const { user: authUser } = useAuthStore() const { user, isError } = useUserMe() - 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 handleAvatarUpload = () => {} + const avatarUrl = user.profileImageUrl + const { + fileInputRef, + imageError, + isImagePending, + deleteModalOpen, + isDeletePending, + triggerFileInput, + onFileChange, + openDeleteModal, + closeDeleteModal, + confirmDelete, + } = useProfileImageEditor(avatarUrl) return (
- + + 삭제 + + ) : null + } + />
@@ -45,22 +149,52 @@ export function ProfileEditPage() { +
+ {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..65855bb --- /dev/null +++ b/src/pages/my/profile/nickname/index.tsx @@ -0,0 +1,56 @@ +import { useNavigate } from 'react-router-dom' +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' +import { ROUTES } from '@/shared/constants/routes' + +export function NicknameEditPage() { + const navigate = useNavigate() + const { user } = useUserMe() + const changeNickname = useChangeNickname(user.nickname) + + const handleSubmit = async () => { + const success = await changeNickname.submit() + if (success) { + navigate(ROUTES.MY.PROFILE, { replace: true }) + } + } + + return ( +
+
+ +
+ +
+ + changeNickname.setNickname(event.target.value)} + /> +

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

+ {changeNickname.message && ( +

+ {changeNickname.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..088680a --- /dev/null +++ b/src/pages/my/profile/password/index.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react' +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' + +export function PasswordEditPage() { + const [currentPassword, setCurrentPassword] = useState('') + const [newPassword, setNewPassword] = useState('') + const [confirmPassword, setConfirmPassword] = useState('') + const passwordUpdate = usePasswordUpdateFeature({ + currentPassword, + newPassword, + confirmPassword, + }) + + return ( +
+
+ +
+ +
+
+ + setCurrentPassword(event.target.value)} + /> +
+ +
+ + setNewPassword(event.target.value)} + /> +
+ +
+ + setConfirmPassword(event.target.value)} + /> +
+ + {passwordUpdate.message && ( +

+ {passwordUpdate.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..ce0fb1d --- /dev/null +++ b/src/pages/my/profile/social/index.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' +import { + type SocialProvider, + useSocialAccountLinking, + useUserSocialStatus, +} from '@/features/user/me' +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 socialLinking = useSocialAccountLinking() + + const statusMap = useMemo( + () => new Map(data.map(item => [item.provider, item])), + [data] + ) + + return ( +
+
+ +
+ +
+

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

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

+ {item.label} +

+

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

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

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

+ )} + {isError && ( +

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

+ )} + {socialLinking.message && ( +

+ {socialLinking.message} +

+ )} +
+
+ ) +} diff --git a/src/pages/my/withdraw/index.tsx b/src/pages/my/withdraw/index.tsx new file mode 100644 index 0000000..6f6e2a7 --- /dev/null +++ b/src/pages/my/withdraw/index.tsx @@ -0,0 +1,71 @@ +import { useState } from 'react' +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 scope = useAuthStore(state => state.scope) + const [checked, setChecked] = useState(false) + const withdrawFlow = useWithdrawUserFlow() + + const handleWithdraw = async () => { + withdrawFlow.setMessage('') + if (!checked) { + withdrawFlow.setMessage('탈퇴 안내를 확인해 주세요.') + return + } + await withdrawFlow.performWithdraw() + } + + return ( +
+
+ +
+ +
+

+ 탈퇴 전 확인해 주세요 +

+
+

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

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

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

+ ) : ( +

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

+ )} +
+ + + + {withdrawFlow.message && ( +

+ {withdrawFlow.message} +

+ )} + +
+ + 회원 탈퇴 + +
+
+
+ ) +} 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/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) +} 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) => 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)