diff --git a/src/app/router.tsx b/src/app/router.tsx index db4919c..93d19e0 100644 --- a/src/app/router.tsx +++ b/src/app/router.tsx @@ -20,6 +20,7 @@ import { NotificationSettingsPage } from '@/pages/my/NotificationSettingsPage'; import { PetRegistrationPage } from '@/pages/my/PetRegistrationPage'; import { PetSpecialNotesPage } from '@/pages/my/PetSpecialNotesPage'; import { PetWeightPage } from '@/pages/my/PetWeightPage'; +import { WithdrawalPage } from '@/pages/my/WithdrawalPage'; import { NotFoundPage } from '@/pages/not-found/NotFoundPage'; import { WalkPage } from '@/pages/walk/WalkPage'; @@ -78,6 +79,14 @@ export const router = createBrowserRouter([ ), }, + { + path: '/my/withdrawal', + element: ( + + + + ), + }, { path: '/my/pets/new', element: ( diff --git a/src/features/auth/api/auth.ts b/src/features/auth/api/auth.ts index 750611c..1825c8d 100644 --- a/src/features/auth/api/auth.ts +++ b/src/features/auth/api/auth.ts @@ -2,6 +2,7 @@ import { apiClient } from '@/shared/api/axios'; import type { + LogoutResponse, NicknameCheckResponse, NotificationUpdateResponse, RegisterProfileRequest, @@ -35,6 +36,16 @@ export async function socialLogin(provider: SocialProvider, code: string): Promi return { kind: 'LOGIN', data: response.data as SocialLoginSuccess }; } +/** + * 로그아웃 (POST /auth/logout) + * - refreshToken 삭제 + accessToken 블랙리스트 처리 + * - accessToken은 apiClient 인터셉터가 Authorization 헤더로 자동 첨부 + */ +export async function logout(refreshToken: string): Promise { + const response = await apiClient.post('/auth/logout', { refreshToken }); + return response.data; +} + /** * 추가 정보 입력 → 가입 완료 (PUT /users/me/profile) * - 202 응답으로 받은 registrationToken을 Authorization 헤더로 전달 diff --git a/src/features/auth/api/users.ts b/src/features/auth/api/users.ts index b807adc..1afac11 100644 --- a/src/features/auth/api/users.ts +++ b/src/features/auth/api/users.ts @@ -1,6 +1,12 @@ import { apiClient } from '@/shared/api/axios'; -import type { UpdateMyProfileRequest, UpdateMyProfileResponse, UserProfile } from '../model/types'; +import type { + UpdateMyProfileRequest, + UpdateMyProfileResponse, + UserProfile, + WithdrawUserResponse, + WithdrawalEmailResponse, +} from '../model/types'; /** * 내 정보 조회 (GET /users/me) @@ -19,3 +25,24 @@ export async function updateMyProfile(body: UpdateMyProfileRequest): Promise('/users/me', body); return response.data; } + +/** + * 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) + * - 현재 로그인한 유저 이메일로 인증번호 발송 + * - 1분 이내 재요청 시 429 응답 + */ +export async function sendWithdrawalEmail(): Promise { + const response = await apiClient.post('/users/me/withdrawal/email'); + return response.data; +} + +/** + * 최종 회원 탈퇴 (DELETE /users/me) + * - 메일로 받은 6자리 인증번호(authCode)로 계정 삭제 + */ +export async function withdrawUser(authCode: string): Promise { + const response = await apiClient.delete('/users/me', { + data: { authCode }, + }); + return response.data; +} diff --git a/src/features/auth/index.ts b/src/features/auth/index.ts index aee7130..c30e743 100644 --- a/src/features/auth/index.ts +++ b/src/features/auth/index.ts @@ -19,8 +19,8 @@ export { export type { AuthErrorPresentation, AuthClientErrorCode, AuthErrorContext } from './lib/authErrorPresentation'; export { AuthLoadingScreen } from './ui/status/AuthLoadingScreen'; export { AuthErrorScreen } from './ui/status/AuthErrorScreen'; -export { socialLogin, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth'; -export { getMyProfile, updateMyProfile } from './api/users'; +export { socialLogin, logout, registerProfile, checkNicknameAvailability, updateNotificationSetting } from './api/auth'; +export { getMyProfile, updateMyProfile, sendWithdrawalEmail, withdrawUser } from './api/users'; export { useCreatePet } from './model/useCreatePet'; export { useCreatePetInvitationCode } from './model/useCreatePetInvitationCode'; export { useCreatePetSpecialNote } from './model/useCreatePetSpecialNote'; @@ -29,6 +29,9 @@ export { useFamilyApplications } from './model/useFamilyApplications'; export { useFamilyBlockedUsers } from './model/useFamilyBlockedUsers'; export { useFamilyPendingUsers } from './model/useFamilyPendingUsers'; export { useCurrentUser } from './model/useCurrentUser'; +export { useLogout } from './model/useLogout'; +export { useSendWithdrawalEmail } from './model/useSendWithdrawalEmail'; +export { useWithdrawUser } from './model/useWithdrawUser'; export { useApprovePetFamilyRequest } from './model/useApprovePetFamilyRequest'; export { useDeletePetWeight } from './model/useDeletePetWeight'; export { useDeletePetSpecialNote } from './model/useDeletePetSpecialNote'; @@ -50,6 +53,8 @@ export type { SocialLoginSuccess, SocialSignupRequired, SocialLoginResult, + LogoutRequest, + LogoutResponse, CreatePetRequest, CreatePetResponse, CreatePetSpecialNoteRequest, @@ -78,6 +83,9 @@ export type { NotificationUpdateResponse, UpdateMyProfileRequest, UpdateMyProfileResponse, + WithdrawalEmailResponse, + WithdrawUserRequest, + WithdrawUserResponse, PetDetailResponse, PetFamilyApprovalAction, PetFamilyApprovalRequest, @@ -102,8 +110,11 @@ export type { export { getApiErrorMessage, getErrorBodyMessage } from '@/shared/lib/api/errorMessage'; export { SOCIAL_LOGIN_STATUS_MESSAGES, + LOGOUT_STATUS_MESSAGES, REGISTER_PROFILE_STATUS_MESSAGES, NOTIFICATION_SETTING_STATUS_MESSAGES, PROFILE_UPDATE_STATUS_MESSAGES, NICKNAME_CHECK_STATUS_MESSAGES, + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + WITHDRAW_USER_STATUS_MESSAGES, } from './lib/apiErrorMessages'; diff --git a/src/features/auth/lib/apiErrorMessages.ts b/src/features/auth/lib/apiErrorMessages.ts index 81dbf44..3db72ed 100644 --- a/src/features/auth/lib/apiErrorMessages.ts +++ b/src/features/auth/lib/apiErrorMessages.ts @@ -8,6 +8,14 @@ export const SOCIAL_LOGIN_STATUS_MESSAGES: Partial> = { 500: '서버 오류가 발생했어요. 잠시 후 다시 시도해주세요.', }; +/** 로그아웃 POST /auth/logout */ +export const LOGOUT_STATUS_MESSAGES: Partial> = { + 400: '잘못된 요청이에요. 잠시 후 다시 시도해주세요.', + 401: '인증 정보가 유효하지 않아요. 다시 로그인해주세요.', + 404: '로그인 정보를 찾을 수 없어요.', + 500: '로그아웃에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + /** 회원가입 완료 PUT /users/me/profile */ export const REGISTER_PROFILE_STATUS_MESSAGES: Partial> = { 400: '입력 정보를 다시 확인해주세요.', @@ -31,6 +39,22 @@ export const PROFILE_UPDATE_STATUS_MESSAGES: Partial> = { 500: '회원정보 수정에 실패했어요. 잠시 후 다시 시도해주세요.', }; +/** 탈퇴 인증 메일 발송 POST /users/me/withdrawal/email */ +export const WITHDRAWAL_EMAIL_STATUS_MESSAGES: Partial> = { + 401: '로그인이 필요한 기능이에요. 다시 로그인해주세요.', + 404: '사용자를 찾을 수 없어요.', + 429: '잠시 후 다시 시도해주세요. (1분 이내 재요청은 불가해요)', + 500: '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + +/** 최종 회원 탈퇴 DELETE /users/me */ +export const WITHDRAW_USER_STATUS_MESSAGES: Partial> = { + 400: '인증번호를 다시 확인해주세요.', + 401: '인증번호가 올바르지 않거나 만료되었어요. 다시 시도해주세요.', + 404: '사용자를 찾을 수 없어요.', + 500: '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', +}; + /** 닉네임 중복 확인 GET /users/nickname/check */ export const NICKNAME_CHECK_STATUS_MESSAGES: Partial> = { 500: '중복 확인에 실패했어요. 잠시 후 다시 시도해주세요.', diff --git a/src/features/auth/model/types.ts b/src/features/auth/model/types.ts index d6e58a6..ea0bd47 100644 --- a/src/features/auth/model/types.ts +++ b/src/features/auth/model/types.ts @@ -22,6 +22,17 @@ export interface TokenReissueResponse { accessTokenExpiresIn: number; } +// ---- 로그아웃 (POST /auth/logout) ---- + +export interface LogoutRequest { + /** 삭제할 리프레시 토큰 */ + refreshToken: string; +} + +export interface LogoutResponse { + message: string; +} + // ---- 소셜 로그인 (POST /auth/social-login) ---- export interface SocialLoginRequest { @@ -408,6 +419,23 @@ export interface UpdateMyProfileResponse { userCreatedAt: string; } +// ---- 회원 탈퇴 ---- + +/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */ +export interface WithdrawalEmailResponse { + message: string; +} + +/** 최종 회원 탈퇴 (DELETE /users/me) */ +export interface WithdrawUserRequest { + /** 메일로 받은 6자리 인증번호 */ + authCode: string; +} + +export interface WithdrawUserResponse { + message: string; +} + export interface UserProfile { message: string; userId?: string; diff --git a/src/features/auth/model/useLogout.ts b/src/features/auth/model/useLogout.ts new file mode 100644 index 0000000..7a3a85f --- /dev/null +++ b/src/features/auth/model/useLogout.ts @@ -0,0 +1,21 @@ +import { useMutation } from '@tanstack/react-query'; + +import { logout } from '@/features/auth/api/auth'; +import type { LogoutResponse } from '@/features/auth/model/types'; +import { getRefreshToken } from '@/shared/lib/auth/token'; + +/** + * 로그아웃 (POST /auth/logout) + * - refreshToken이 없으면 서버 호출 없이 로컬 세션만 정리하도록 null 반환 + * - 토큰/캐시 정리·이동은 호출 측에서 처리 + */ +export function useLogout() { + return useMutation({ + mutationFn: async () => { + const refreshToken = getRefreshToken(); + if (!refreshToken) return null; + + return logout(refreshToken); + }, + }); +} diff --git a/src/features/auth/model/useSendWithdrawalEmail.ts b/src/features/auth/model/useSendWithdrawalEmail.ts new file mode 100644 index 0000000..18bd0ef --- /dev/null +++ b/src/features/auth/model/useSendWithdrawalEmail.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { sendWithdrawalEmail } from '@/features/auth/api/users'; +import type { WithdrawalEmailResponse } from '@/features/auth/model/types'; + +/** 탈퇴 인증 메일 발송 (POST /users/me/withdrawal/email) */ +export function useSendWithdrawalEmail() { + return useMutation({ + mutationFn: () => sendWithdrawalEmail(), + }); +} diff --git a/src/features/auth/model/useWithdrawUser.ts b/src/features/auth/model/useWithdrawUser.ts new file mode 100644 index 0000000..0251efa --- /dev/null +++ b/src/features/auth/model/useWithdrawUser.ts @@ -0,0 +1,11 @@ +import { useMutation } from '@tanstack/react-query'; + +import { withdrawUser } from '@/features/auth/api/users'; +import type { WithdrawUserResponse } from '@/features/auth/model/types'; + +/** 최종 회원 탈퇴 (DELETE /users/me) — 메일로 받은 6자리 인증번호로 계정 삭제 */ +export function useWithdrawUser() { + return useMutation({ + mutationFn: (authCode) => withdrawUser(authCode), + }); +} diff --git a/src/pages/my/WithdrawalPage.tsx b/src/pages/my/WithdrawalPage.tsx new file mode 100644 index 0000000..5108c62 --- /dev/null +++ b/src/pages/my/WithdrawalPage.tsx @@ -0,0 +1,44 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; + +import { WithdrawalCompleteModal } from '@/pages/my/ui/WithdrawalCompleteModal'; +import { WithdrawalFlow } from '@/pages/my/ui/WithdrawalFlow'; + +export function WithdrawalPage() { + const [completed, setCompleted] = useState(false); + + // 토큰 정리는 완료 모달을 닫고 홈으로 이동하는 시점(WithdrawalCompleteModal)으로 미룬다. + // RequireAuth 가드 아래에서 즉시 clearTokens 하면 모달 노출 전에 /auth로 리다이렉트될 수 있다. + const handleCompleted = () => { + setCompleted(true); + }; + + return ( +
+
+ + ← 회원정보 수정으로 돌아가기 + +
+ +
+
+

회원 탈퇴

+

+ 탈퇴를 진행하려면 본인 확인이 필요해요. 가입하신 이메일로 인증번호를 보내드릴게요. +

+ {/*

탈퇴 시 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요.

*/} +
+ +
+ +
+
+ + +
+ ); +} diff --git a/src/pages/my/model/menu.ts b/src/pages/my/model/menu.ts index f77232c..48793ea 100644 --- a/src/pages/my/model/menu.ts +++ b/src/pages/my/model/menu.ts @@ -10,10 +10,14 @@ export type MyDodoMenuKey = | 'notifications' | 'logout'; +/** link: 콘텐츠 패널/페이지로 이동, action: 클릭 시 동작(모달 등) 실행 */ +export type MyDodoMenuType = 'link' | 'action'; + export interface MyDodoMenuItem { key: MyDodoMenuKey; label: string; section: MyDodoMenuSection; + type: MyDodoMenuType; } export interface MyDodoContent { @@ -31,14 +35,14 @@ export const MY_DODO_SECTION_LABELS: Record = { }; export const MY_DODO_MENU_ITEMS: MyDodoMenuItem[] = [ - { key: 'pet-list', label: '반려동물 리스트', section: 'pet' }, - { key: 'device', label: '디바이스 관리', section: 'pet' }, - { key: 'family', label: '가족 관리', section: 'pet' }, - { key: 'walk-history', label: '산책 기록', section: 'pet' }, - { key: 'ai-report', label: 'AI 레포트', section: 'pet' }, - { key: 'profile-edit', label: '회원정보 수정', section: 'account' }, - { key: 'notifications', label: '알림함', section: 'account' }, - { key: 'logout', label: '로그아웃', section: 'account' }, + { key: 'pet-list', label: '반려동물 리스트', section: 'pet', type: 'link' }, + { key: 'device', label: '디바이스 관리', section: 'pet', type: 'link' }, + { key: 'family', label: '가족 관리', section: 'pet', type: 'link' }, + { key: 'walk-history', label: '산책 기록', section: 'pet', type: 'link' }, + { key: 'ai-report', label: 'AI 레포트', section: 'pet', type: 'link' }, + { key: 'profile-edit', label: '회원정보 수정', section: 'account', type: 'link' }, + { key: 'notifications', label: '알림함', section: 'account', type: 'link' }, + { key: 'logout', label: '로그아웃', section: 'account', type: 'action' }, ]; export const MY_DODO_CONTENT_BY_KEY: Record = { diff --git a/src/pages/my/ui/AuthCodeInput.tsx b/src/pages/my/ui/AuthCodeInput.tsx new file mode 100644 index 0000000..97f06b4 --- /dev/null +++ b/src/pages/my/ui/AuthCodeInput.tsx @@ -0,0 +1,93 @@ +import { useRef, type ClipboardEvent, type KeyboardEvent } from 'react'; + +interface AuthCodeInputProps { + length: number; + value: string; + onChange: (value: string) => void; + disabled?: boolean; +} + +export function AuthCodeInput({ length, value, onChange, disabled = false }: AuthCodeInputProps) { + const inputsRef = useRef>([]); + + const focusInput = (index: number) => { + inputsRef.current[Math.max(0, Math.min(index, length - 1))]?.focus(); + }; + + const handleChange = (index: number, raw: string) => { + const chars = raw.replace(/\D/g, '').split(''); + if (chars.length === 0) { + const next = value.split(''); + next[index] = ''; + onChange(next.join('')); + return; + } + + const next = value.padEnd(length, ' ').split(''); + let cursor = index; + for (const ch of chars) { + if (cursor >= length) break; + next[cursor] = ch; + cursor += 1; + } + + onChange(next.join('').replace(/ /g, '').slice(0, length)); + focusInput(cursor); + }; + + const handleKeyDown = (index: number, event: KeyboardEvent) => { + if (event.key === 'Backspace') { + const next = value.split(''); + if (value[index]) { + next[index] = ''; + onChange(next.join('')); + } else if (index > 0) { + next[index - 1] = ''; + onChange(next.join('')); + focusInput(index - 1); + } + } else if (event.key === 'ArrowLeft') { + focusInput(index - 1); + } else if (event.key === 'ArrowRight') { + focusInput(index + 1); + } + }; + + const handlePaste = (event: ClipboardEvent) => { + event.preventDefault(); + const pasted = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, length); + if (!pasted) return; + + onChange(pasted); + focusInput(pasted.length); + }; + + return ( +
+ {Array.from({ length }).map((_, index) => ( + { + inputsRef.current[index] = el; + }} + type="text" + inputMode="numeric" + autoComplete={index === 0 ? 'one-time-code' : 'off'} + maxLength={1} + value={value[index] ?? ''} + disabled={disabled} + onChange={(event) => handleChange(index, event.target.value)} + onKeyDown={(event) => handleKeyDown(index, event)} + onFocus={() => { + // 빈 슬롯보다 뒤쪽 칸을 클릭하면 첫 번째 빈 슬롯으로 포커스를 당겨 입력 순서를 맞춘다. + const firstEmptyIndex = value.length; + if (index > firstEmptyIndex) { + focusInput(firstEmptyIndex); + } + }} + className="h-20 w-full rounded-xl border border-neutral-200 bg-white text-center text-2xl font-semibold text-neutral-900 outline-none transition-colors focus:border-brand disabled:bg-neutral-50 disabled:opacity-60" + /> + ))} +
+ ); +} diff --git a/src/pages/my/ui/LogoutConfirmDialog.tsx b/src/pages/my/ui/LogoutConfirmDialog.tsx new file mode 100644 index 0000000..202cee1 --- /dev/null +++ b/src/pages/my/ui/LogoutConfirmDialog.tsx @@ -0,0 +1,65 @@ +import { useNavigate } from 'react-router-dom'; + +import { useLogout } from '@/features/auth'; +import { clearTokens } from '@/shared/lib/auth/token'; +import { Modal } from '@/shared/ui'; + +interface LogoutConfirmDialogProps { + open: boolean; + onClose: () => void; +} + +export function LogoutConfirmDialog({ open, onClose }: LogoutConfirmDialogProps) { + const navigate = useNavigate(); + const { mutateAsync, isPending } = useLogout(); + + const handleClose = () => { + if (isPending) return; + onClose(); + }; + + const handleConfirm = async () => { + try { + await mutateAsync(); + } catch (error) { + // 서버 로그아웃이 실패해도 로컬 세션은 반드시 정리해 사용자가 갇히지 않도록 한다. + console.error('[auth/logout] 서버 로그아웃 실패', error); + } finally { + // 인증 페이지(마이도도)에서 먼저 빠져나간 뒤 토큰을 정리해야 + // 잔여 인증 쿼리의 재요청 → 401 → 세션 만료 리다이렉트를 피할 수 있다. + navigate('/', { replace: true }); + clearTokens(); + } + }; + + return ( + +
+

LOGOUT

+

로그아웃 하시겠습니까?

+

+ 로그아웃하면 현재 기기에서 로그인 정보가 정리돼요. 다시 이용하려면 로그인이 필요해요. +

+ +
+ + +
+
+
+ ); +} diff --git a/src/pages/my/ui/MyDodoSidebar.tsx b/src/pages/my/ui/MyDodoSidebar.tsx index 9f967b7..3f35959 100644 --- a/src/pages/my/ui/MyDodoSidebar.tsx +++ b/src/pages/my/ui/MyDodoSidebar.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { Link } from 'react-router-dom'; import { @@ -7,6 +8,7 @@ import { type MyDodoMenuKey, type MyDodoMenuSection, } from '@/pages/my/model/menu'; +import { LogoutConfirmDialog } from '@/pages/my/ui/LogoutConfirmDialog'; interface MyDodoSidebarProps { activeKey: MyDodoMenuKey; @@ -21,30 +23,48 @@ function menuItemClass(active: boolean) { ].join(' '); } -function SidebarSection({ section, activeKey }: { section: MyDodoMenuSection; activeKey: MyDodoMenuKey }) { +function SidebarSection({ + section, + activeKey, + onLogout, +}: { + section: MyDodoMenuSection; + activeKey: MyDodoMenuKey; + onLogout: () => void; +}) { const items = MY_DODO_MENU_ITEMS.filter((item) => item.section === section); return (

{MY_DODO_SECTION_LABELS[section]}

- {items.map((item) => ( - - {item.label} - - ))} + {items.map((item) => + item.type === 'action' ? ( + + ) : ( + + {item.label} + + ), + )}
); } export function MyDodoSidebar({ activeKey }: MyDodoSidebarProps) { + const [logoutOpen, setLogoutOpen] = useState(false); + return (
- - + setLogoutOpen(true)} /> + setLogoutOpen(true)} />
+ + setLogoutOpen(false)} />
); } diff --git a/src/pages/my/ui/MyProfileEditContent.tsx b/src/pages/my/ui/MyProfileEditContent.tsx index 36bc020..d818c96 100644 --- a/src/pages/my/ui/MyProfileEditContent.tsx +++ b/src/pages/my/ui/MyProfileEditContent.tsx @@ -1,4 +1,5 @@ import { type ChangeEvent, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; +import { Link } from 'react-router-dom'; import profileDefaultIllustration from '@/features/auth/assets/profile-default.svg'; import { PROFILE_UPDATE_STATUS_MESSAGES, getApiErrorMessage, updateMyProfile, type UserProfile } from '@/features/auth'; @@ -320,6 +321,23 @@ export function MyProfileEditContent({ user, isLoading = false }: MyProfileEditC +
+
+
+

회원 탈퇴

+

+ 탈퇴하면 계정과 모든 데이터가 삭제되며 되돌릴 수 없어요. +

+
+ + 회원 탈퇴 + +
+
+ { + navigate('/', { replace: true }); + clearTokens(); + }; + + return ( + +
+ +

회원 탈퇴 완료

+

+ 그동안 DoDo를 이용해 주셔서 감사합니다. +
더 좋은 모습으로 다시 만날 수 있기를 바라요. +

+ + +
+
+ ); +} diff --git a/src/pages/my/ui/WithdrawalFlow.tsx b/src/pages/my/ui/WithdrawalFlow.tsx new file mode 100644 index 0000000..c850624 --- /dev/null +++ b/src/pages/my/ui/WithdrawalFlow.tsx @@ -0,0 +1,150 @@ +import { useState } from 'react'; + +import { + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + WITHDRAW_USER_STATUS_MESSAGES, + getApiErrorMessage, + useSendWithdrawalEmail, + useWithdrawUser, +} from '@/features/auth'; +import { getApiErrorStatus } from '@/shared/lib/api/errorMessage'; +import { useCooldown } from '@/shared/lib/useCooldown'; +import { Toast } from '@/shared/ui'; +import { AuthCodeInput } from '@/pages/my/ui/AuthCodeInput'; + +const AUTH_CODE_LENGTH = 6; +const RESEND_COOLDOWN_SECONDS = 60; +/** 인증번호 불일치로 간주하는 상태 코드 */ +const INVALID_CODE_STATUSES = new Set([400, 401]); + +interface WithdrawalFlowProps { + /** 최종 탈퇴 성공 시 호출 (토큰 정리·완료 화면 전환은 상위에서 처리) */ + onCompleted: () => void; +} + +interface ToastState { + message: string; + tone: 'success' | 'error'; +} + +export function WithdrawalFlow({ onCompleted }: WithdrawalFlowProps) { + const [emailSent, setEmailSent] = useState(false); + const [authCode, setAuthCode] = useState(''); + const [withdrawError, setWithdrawError] = useState(''); + const [toast, setToast] = useState(null); + const { seconds: cooldown, start: startCooldown } = useCooldown(); + + const { mutateAsync: sendEmail, isPending: isSending } = useSendWithdrawalEmail(); + const { mutateAsync: withdraw, isPending: isWithdrawing } = useWithdrawUser(); + + const handleSendEmail = async () => { + if (isSending || cooldown > 0) return; + + try { + await sendEmail(); + setEmailSent(true); + startCooldown(RESEND_COOLDOWN_SECONDS); + setToast({ message: '인증번호를 메일로 보냈어요. 메일함을 확인해주세요.', tone: 'success' }); + } catch (error) { + setToast({ + message: getApiErrorMessage( + error, + '인증 메일 발송에 실패했어요. 잠시 후 다시 시도해주세요.', + WITHDRAWAL_EMAIL_STATUS_MESSAGES, + ), + tone: 'error', + }); + } + }; + + const handleCodeChange = (value: string) => { + setAuthCode(value); + setWithdrawError(''); + }; + + const handleWithdraw = async () => { + if (isWithdrawing) return; + + if (authCode.length !== AUTH_CODE_LENGTH) { + setWithdrawError('6자리 인증번호를 입력해주세요.'); + return; + } + + setWithdrawError(''); + + try { + await withdraw(authCode); + onCompleted(); + } catch (error) { + const status = getApiErrorStatus(error); + if (status !== null && INVALID_CODE_STATUSES.has(status)) { + setWithdrawError('인증번호가 틀렸습니다. 다시 확인해주세요.'); + return; + } + + setWithdrawError( + getApiErrorMessage(error, '회원 탈퇴에 실패했어요. 잠시 후 다시 시도해주세요.', WITHDRAW_USER_STATUS_MESSAGES), + ); + } + }; + + return ( + <> + setToast(null)} + /> + + {!emailSent ? ( + + ) : ( +
+
+

인증번호

+

메일로 받은 6자리 숫자를 입력해주세요.

+
+ +
+
+ +
+ 인증번호를 받지 못하셨나요? + +
+ + {withdrawError ?

{withdrawError}

: null} + + +
+ )} + + ); +} diff --git a/src/shared/lib/useCooldown.ts b/src/shared/lib/useCooldown.ts new file mode 100644 index 0000000..bc9b1fb --- /dev/null +++ b/src/shared/lib/useCooldown.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; + +interface UseCooldownResult { + /** 남은 초 (0이면 쿨다운 종료) */ + seconds: number; + /** 지정한 초만큼 쿨다운 시작 */ + start: (durationSeconds: number) => void; +} + +/** 초 단위 카운트다운 쿨다운 (재발송 제한 등) */ +export function useCooldown(): UseCooldownResult { + const [seconds, setSeconds] = useState(0); + const isActive = seconds > 0; + + // isActive(쿨다운 진행 여부)가 바뀔 때만 타이머를 재설정해 매초 재생성되지 않도록 한다. + useEffect(() => { + if (!isActive) return; + + const timer = setInterval(() => { + setSeconds((prev) => (prev <= 1 ? 0 : prev - 1)); + }, 1000); + + return () => clearInterval(timer); + }, [isActive]); + + return { seconds, start: setSeconds }; +} diff --git a/src/shared/ui/Toast.tsx b/src/shared/ui/Toast.tsx new file mode 100644 index 0000000..990bbce --- /dev/null +++ b/src/shared/ui/Toast.tsx @@ -0,0 +1,48 @@ +import { useEffect, useRef } from 'react'; +import { createPortal } from 'react-dom'; + +type ToastTone = 'default' | 'success' | 'error'; + +interface ToastProps { + open: boolean; + message: string; + onClose: () => void; + tone?: ToastTone; + /** 자동 사라짐 시간(ms) */ + duration?: number; +} + +const TONE_CLASS: Record = { + default: 'bg-neutral-950 text-white', + success: 'bg-neutral-950 text-white', + error: 'bg-red-500 text-white', +}; + +export function Toast({ open, message, onClose, tone = 'default', duration = 3000 }: ToastProps) { + const onCloseRef = useRef(onClose); + + useEffect(() => { + onCloseRef.current = onClose; + }, [onClose]); + + useEffect(() => { + if (!open) return; + + const timer = setTimeout(() => onCloseRef.current(), duration); + return () => clearTimeout(timer); + }, [open, duration, message]); + + if (!open) return null; + + return createPortal( +
+
+ {message} +
+
, + document.body, + ); +} diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 97d5cb7..abc7fbb 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -2,3 +2,4 @@ export { CloseButton } from './CloseButton'; export { LoadingSpinner } from './LoadingSpinner'; export { Modal } from './Modal'; export { Skeleton } from './Skeleton'; +export { Toast } from './Toast';