) => {
+ 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(
+ ,
+ 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';