page.tsx 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. 'use client';
  2. import './style.scss';
  3. import Link from 'next/link';
  4. import { useState, useEffect, useRef } from 'react';
  5. import { fetchApi } from '@/lib/utils/client';
  6. import useAuth from '@/hooks/useAuth';
  7. import useErrorAlert from '@/hooks/useErrorAlert';
  8. import Loading from '@/app/component/Loading';
  9. import NavTabs from '../navTabs';
  10. const CODE_EXPIRATION_SECONDS = 600; // 10분
  11. export default function Withdraw()
  12. {
  13. const { member, logout } = useAuth();
  14. const { setError } = useErrorAlert();
  15. const [loading, setLoading] = useState<boolean>(false);
  16. const [isComplete, setComplete] = useState<boolean>(false);
  17. const [agree, setAgree] = useState<boolean>(false);
  18. const [step, setStep] = useState<1|2>(1);
  19. const [code, setCode] = useState<string>('');
  20. const [remaining, setRemaining] = useState<number>(0);
  21. const codeRef = useRef<HTMLInputElement>(null);
  22. // 카운트다운
  23. useEffect(() => {
  24. if (step !== 2 || remaining <= 0) {
  25. return;
  26. }
  27. const timer = setInterval(() => {
  28. setRemaining(prev => Math.max(0, prev - 1));
  29. }, 1000);
  30. return () => clearInterval(timer);
  31. }, [step, remaining]);
  32. const handleRequestCode = async () => {
  33. if (!member) {
  34. return;
  35. }
  36. if (!agree) {
  37. alert('탈퇴에 동의해주세요.');
  38. return;
  39. }
  40. setLoading(true);
  41. try {
  42. await fetchApi('/api/mypage/withdraw/request-code', {
  43. method: 'POST'
  44. });
  45. setStep(2);
  46. setRemaining(CODE_EXPIRATION_SECONDS);
  47. setCode('');
  48. setTimeout(() => codeRef.current?.focus(), 100);
  49. } catch (err) {
  50. if (err instanceof Error) {
  51. setError(err.message);
  52. }
  53. } finally {
  54. setLoading(false);
  55. }
  56. };
  57. const handleResendCode = async () => {
  58. setLoading(true);
  59. try {
  60. await fetchApi('/api/mypage/withdraw/request-code', {
  61. method: 'POST'
  62. });
  63. setRemaining(CODE_EXPIRATION_SECONDS);
  64. setCode('');
  65. alert('인증번호를 재발송했습니다. 메일함을 확인해주세요.');
  66. } catch (err) {
  67. if (err instanceof Error) {
  68. setError(err.message);
  69. }
  70. } finally {
  71. setLoading(false);
  72. }
  73. };
  74. const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
  75. e.preventDefault();
  76. if (!member) {
  77. return;
  78. }
  79. if (!code) {
  80. alert('인증번호를 입력해주세요.');
  81. codeRef.current?.focus();
  82. return;
  83. }
  84. if (!confirm('정말 탈퇴하시겠습니까?')) {
  85. return;
  86. }
  87. setLoading(true);
  88. try {
  89. await fetchApi('/api/mypage/withdraw', {
  90. method: 'POST',
  91. body: { code }
  92. });
  93. setComplete(true);
  94. } catch (err) {
  95. if (err instanceof Error) {
  96. setError(err.message);
  97. }
  98. } finally {
  99. setLoading(false);
  100. }
  101. };
  102. useEffect(() => {
  103. if (isComplete) {
  104. alert('탈퇴가 완료되었습니다.');
  105. setComplete(false);
  106. // 탈퇴 후 자동 로그아웃: httpOnly 쿠키(accessToken/refreshToken) 클리어 + AuthContext 리셋 + 홈 이동
  107. logout();
  108. }
  109. }, [isComplete]);
  110. const formatRemaining = (sec: number) => {
  111. const m = Math.floor(sec / 60).toString().padStart(2, '0');
  112. const s = (sec % 60).toString().padStart(2, '0');
  113. return `${m}:${s}`;
  114. };
  115. if (!member) {
  116. return null;
  117. }
  118. return (
  119. <>
  120. <NavTabs />
  121. <div id="withdraw">
  122. { loading && <Loading /> }
  123. <h1>회원탈퇴</h1>
  124. {step === 1 && (
  125. <div>
  126. <table className="table-auto max-xl:w-full lg:w-[600px]">
  127. <tbody>
  128. <tr>
  129. <th>
  130. <blockquote>
  131. 사용하고 계신 계정({member.email})은 탈퇴할 경우 재사용 및 복구가 불가능합니다.
  132. <ins>탈퇴한 계정은 본인과 타인 모두 재사용 및 복구가 불가하오니 신중하게 선택하시기 바랍니다.</ins>
  133. <br/>
  134. <br/>
  135. 추가 회원가입은 탈퇴일로부터 90일 후에 가능합니다.
  136. 탈퇴 후 회원정보와 주요 서비스 이용기록은 모두 삭제되며, 삭제된 데이터는 복구되지 않습니다.
  137. <br/>
  138. <br/>
  139. 삭제되는 내용을 확인하시고 필요한 데이터는 미리 백업을 해주세요.
  140. 탈퇴 후 게시판, 댓글에 등록한 게시물은 삭제되지 않고 유지됩니다.
  141. <br/>
  142. <br/>
  143. 본인 확인을 위해 가입하신 이메일({member.email})로 인증번호를 발송합니다.
  144. </blockquote>
  145. </th>
  146. </tr>
  147. <tr>
  148. <td>
  149. <label>
  150. <input type="checkbox" name="agree" checked={agree} onChange={e => setAgree(e.target.checked)} />
  151. 위 내용으로 탈퇴에 동의합니다.
  152. </label>
  153. </td>
  154. </tr>
  155. </tbody>
  156. <tfoot>
  157. <tr>
  158. <td>
  159. <div className="flex justify-center gap-2">
  160. <button type="button" className="btn btn-primary" onClick={handleRequestCode} disabled={loading || !agree}>
  161. 인증번호 받기
  162. </button>
  163. <Link href="/profile" className="btn btn-default">취소</Link>
  164. </div>
  165. </td>
  166. </tr>
  167. </tfoot>
  168. </table>
  169. </div>
  170. )}
  171. {step === 2 && (
  172. <form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
  173. <table className="table-auto max-xl:w-full lg:w-[600px]">
  174. <tbody>
  175. <tr>
  176. <th>
  177. <blockquote>
  178. {member.email} 으로 인증번호를 발송했습니다. 메일함을 확인하시고 아래에 6자리 코드를 입력해주세요.
  179. <br/>
  180. <br/>
  181. 이 코드는 10분간 유효하며, 만료되면 재발송 버튼을 눌러주세요.
  182. </blockquote>
  183. </th>
  184. </tr>
  185. <tr>
  186. <td>
  187. <div className='flex flex-col sm:flex-row gap-2 justify-self-center items-center'>
  188. <label htmlFor="code" className='pe-2 shrink-0'>인증번호</label>
  189. <input
  190. type="text"
  191. name="code"
  192. id="code"
  193. className="form-control w-full"
  194. ref={codeRef}
  195. value={code}
  196. onChange={e => setCode(e.target.value.replace(/[^0-9]/g, '').slice(0, 6))}
  197. maxLength={6}
  198. inputMode="numeric"
  199. pattern="[0-9]{6}"
  200. placeholder="6자리 숫자"
  201. autoFocus
  202. />
  203. <span className='text-sm text-gray-500 shrink-0'>
  204. {remaining > 0 ? formatRemaining(remaining) : '만료됨'}
  205. </span>
  206. </div>
  207. </td>
  208. </tr>
  209. </tbody>
  210. <tfoot>
  211. <tr>
  212. <td>
  213. <div className="flex justify-center gap-2">
  214. <button type="submit" className="btn btn-primary" disabled={loading || remaining <= 0}>탈퇴 완료</button>
  215. <button type="button" className="btn btn-default" onClick={handleResendCode} disabled={loading}>재발송</button>
  216. <button type="button" className="btn btn-default" onClick={() => { setStep(1); setCode(''); setRemaining(0); }} disabled={loading}>이전</button>
  217. </div>
  218. </td>
  219. </tr>
  220. </tfoot>
  221. </table>
  222. </form>
  223. )}
  224. </div>
  225. </>
  226. );
  227. }