KIM-JINO5 3 ماه پیش
والد
کامیت
74f4fb13b7
100فایلهای تغییر یافته به همراه1186 افزوده شده و 3528 حذف شده
  1. 0 6
      app/(account)/change-approve/loading.tsx
  2. 18 9
      app/(account)/change-approve/page.tsx
  3. 0 6
      app/(account)/change-email/loading.tsx
  4. 14 5
      app/(account)/change-email/page.tsx
  5. 0 6
      app/(account)/change-intro/loading.tsx
  6. 18 5
      app/(account)/change-intro/page.tsx
  7. 0 6
      app/(account)/change-name/loading.tsx
  8. 17 5
      app/(account)/change-name/page.tsx
  9. 0 6
      app/(account)/change-password/loading.tsx
  10. 47 19
      app/(account)/change-password/page.tsx
  11. 0 6
      app/(account)/change-photo/loading.tsx
  12. 0 6
      app/(account)/change-summary/loading.tsx
  13. 15 5
      app/(account)/change-summary/page.tsx
  14. 68 30
      app/(account)/change-thumb/page.tsx
  15. 1 1
      app/(account)/change-thumb/style.scss
  16. 17 0
      app/(account)/layout.tsx
  17. 5 0
      app/(account)/loading.tsx
  18. 28 35
      app/(account)/login-log/page.tsx
  19. 1 1
      app/(account)/login-log/style.scss
  20. 1 2
      app/(account)/navTabs.tsx
  21. 9 32
      app/(account)/profile/page.tsx
  22. 9 24
      app/(account)/verify-email/page.tsx
  23. 0 0
      app/(account)/verify-email/style.scss
  24. 30 20
      app/(account)/withdraw/page.tsx
  25. 9 10
      app/(auth)/approval/page.tsx
  26. 21 0
      app/(auth)/approval/style.scss
  27. 10 10
      app/(auth)/forgot-password/page.tsx
  28. 19 1
      app/(auth)/forgot-password/style.scss
  29. 24 2
      app/(auth)/layout.tsx
  30. 0 6
      app/(auth)/login/loading.tsx
  31. 104 10
      app/(auth)/login/page.tsx
  32. 25 0
      app/(auth)/login/style.scss
  33. 0 111
      app/(auth)/login/view.tsx
  34. 0 6
      app/(auth)/register/loading.tsx
  35. 178 11
      app/(auth)/register/page.tsx
  36. 21 2
      app/(auth)/register/style.scss
  37. 0 183
      app/(auth)/register/view.tsx
  38. 11 12
      app/(auth)/reset-password/page.tsx
  39. 20 0
      app/(auth)/reset-password/style.scss
  40. 1 1
      app/(forum)/board/[code]/page.tsx
  41. 2 2
      app/(forum)/board/_component/PostWriteButton.tsx
  42. 1 1
      app/(forum)/comment/_component/EditForm.tsx
  43. 4 3
      app/(forum)/comment/_component/Item.tsx
  44. 5 5
      app/(forum)/comment/_component/List.tsx
  45. 1 1
      app/(forum)/comment/_component/WriteForm.tsx
  46. 3 1
      app/(forum)/comment/view.tsx
  47. 1 1
      app/(forum)/post/[id]/page.tsx
  48. 10 10
      app/(forum)/post/[id]/view.tsx
  49. 1 1
      app/(forum)/post/_component/Report.tsx
  50. 3 3
      app/(forum)/post/edit/[id]/page.tsx
  51. 3 3
      app/(forum)/post/edit/[id]/view.tsx
  52. 3 3
      app/(forum)/post/write/view.tsx
  53. 27 0
      app/api/auth/[...path]/route.ts
  54. 0 70
      app/api/auth/[slug]/route.ts
  55. 47 0
      app/api/mypage/[...path]/route.ts
  56. 0 10
      app/api/ping/route.ts
  57. 36 0
      app/api/uploads/[...path]/route.ts
  58. 8 7
      app/globals.scss
  59. 5 4
      app/layout.tsx
  60. 5 0
      app/loading.tsx
  61. 0 251
      components/ChatWindow.tsx
  62. 0 204
      components/DonationModal.tsx
  63. 0 258
      components/LivePlayer.module.scss
  64. 0 79
      components/LivePlayer.tsx
  65. 0 58
      components/broadcast/BroadcastCard.tsx
  66. 0 40
      components/broadcast/LiveSection.tsx
  67. 0 42
      components/broadcast/PopularSection.tsx
  68. 0 3
      components/broadcast/index.ts
  69. 0 251
      components/broadcast/styles/BroadcastCard.module.scss
  70. 0 151
      components/broadcast/styles/BroadcastSection.module.scss
  71. 0 186
      components/cash/AlertModal.tsx
  72. 0 84
      components/cash/AlertSystem.tsx
  73. 0 30
      components/live/LiveLayout.tsx
  74. 0 50
      components/live/LiveNavbar.tsx
  75. 0 27
      components/live/LiveVideoSection.tsx
  76. 0 75
      components/live/StreamerInfoSection.tsx
  77. 0 4
      components/live/index.ts
  78. 0 53
      components/live/styles/LiveLayout.module.scss
  79. 0 93
      components/live/styles/LiveNavbar.module.scss
  80. 0 50
      components/live/styles/LiveVideoSection.module.scss
  81. 0 139
      components/live/styles/StreamerInfoSection.module.scss
  82. 4 4
      constants/common.ts
  83. 3 9
      contexts/authProvider.tsx
  84. 0 154
      contexts/broadcastProvider.tsx
  85. 83 54
      contexts/signalrProvider.tsx
  86. 1 1
      dtos/request/account.ts
  87. 0 13
      dtos/request/payment/danal/DanalConfirmRequest.ts
  88. 0 5
      dtos/request/payment/danal/DanalFailedRequest.ts
  89. 11 0
      dtos/response/account/loginLogs.ts
  90. 22 37
      dtos/response/account/member.ts
  91. 12 0
      dtos/response/auth.ts
  92. 6 38
      dtos/response/common.ts
  93. 0 8
      dtos/response/payment/danal/DanalConfirmResponse.ts
  94. 0 4
      dtos/response/payment/danal/DanalFailedResponse.ts
  95. 21 42
      hooks/useAuth.ts
  96. 0 134
      hooks/useBroadcasts.ts
  97. 0 91
      hooks/useBroadcastsMock.ts
  98. 33 27
      lib/api/account.ts
  99. 55 32
      lib/api/auth.ts
  100. 29 22
      lib/api/forum/board.ts

+ 0 - 6
app/(account)/change-approve/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 18 - 9
app/(account)/change-approve/page.tsx

@@ -5,13 +5,14 @@ import Link from 'next/link';
 import { useState, useEffect } from 'react';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangeApproveRequest } from '@/dtos/request/account';
-import { fetchChangeApprove } from '@/lib/api/account';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 
 export default function ChangeApprove()
 {
 	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
 	const [notifications, setNotifications] = useState({
 		isReceiveSMS: member?.memberApprove.isReceiveSMS || false,
 		isReceiveEmail: member?.memberApprove.isReceiveEmail || false,
@@ -44,12 +45,16 @@ export default function ChangeApprove()
 			return;
 		}
 
-		fetchChangeApprove({
-			IsReceiveSMS: notifications.isReceiveSMS,
-			IsReceiveEmail: notifications.isReceiveEmail,
-			IsReceiveNote: notifications.isReceiveNote
-		} as ChangeApproveRequest
-		).then(res => {
+		setLoading(true);
+
+		fetchApi('/api/mypage/receive-settings', {
+			method: 'POST',
+			body: {
+				IsReceiveSMS: notifications.isReceiveSMS,
+				IsReceiveEmail: notifications.isReceiveEmail,
+				IsReceiveNote: notifications.isReceiveNote
+			} as ChangeApproveRequest
+		}).then(res => {
 			throwError(res);
 
 			member.memberApprove.isReceiveSMS = notifications.isReceiveSMS;
@@ -61,6 +66,8 @@ export default function ChangeApprove()
 
 		}).catch(err => {
 			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
 		});
 	}
 
@@ -75,8 +82,10 @@ export default function ChangeApprove()
 
 	return (
 		<div id="changeApprove">
+			{ loading && <Loading /> }
+
 			<h1>알림 수신 설정</h1>
-			<form id="fChangeApprove" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						보다 편리한 서비스 이용을 위해 원하는 알림 수신 여부를 설정할 수 있습니다.

+ 0 - 6
app/(account)/change-email/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 14 - 5
app/(account)/change-email/page.tsx

@@ -6,8 +6,8 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { useConfigContext } from '@/contexts/configProvider';
 import { ChangeEmailRequest } from '@/dtos/request/account';
-import { fetchChangeEmail } from '@/lib/api/account';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 
 export default function ChangeEmail()
 {
@@ -15,6 +15,7 @@ export default function ChangeEmail()
 	const { member } = useMemberContext();
 	const [error, setError] = useState<string>('');
 	const [isComplete, setComplete] = useState<boolean>(false);
+	const [loading, setLoading] = useState<boolean>(false);
 	const [newEmail, setNewEmail] = useState<string>('');
 	const newEmailRef = useRef<HTMLInputElement>(null);
 
@@ -38,12 +39,18 @@ export default function ChangeEmail()
 			return setError('변경하실 이메일을 입력하세요.');
 		}
 
-		fetchChangeEmail({ Email: newEmail } as ChangeEmailRequest).then((res) => {
-			throwError(res);
+		setLoading(true);
 
+		fetchApi('/api/mypage/email', {
+			method: 'POST',
+			body: { NewEmail: newEmail } as ChangeEmailRequest
+		}).then((res) => {
+			throwError(res);
 			setComplete(true);
 		}).catch(err => {
 			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
 		});
 	}
 
@@ -56,10 +63,12 @@ export default function ChangeEmail()
 	return (
 		<>
 		<div id="changeEmail">
+			{ loading && <Loading /> }
+
 			{!isComplete ?
 			<>
 				<h1>이메일 변경</h1>
-				<form id="fChangeEmail" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+				<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 					<table className="table-auto max-xl:w-full lg:w-[600px]">
 						<caption>
 							새 이메일 주소를 입력하고 &quot;확인&quot; 버튼을 누릅니다.<br />

+ 0 - 6
app/(account)/change-intro/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 18 - 5
app/(account)/change-intro/page.tsx

@@ -6,8 +6,8 @@ import { useState, useEffect } from 'react';
 import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangeIntroRequest } from '@/dtos/request/account';
-import { fetchChangeIntro, fetchRemoveIntro } from '@/lib/api/account';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 import Editor from '@/app/component/Editor';
 
 export default function ChangeIntro()
@@ -15,6 +15,7 @@ export default function ChangeIntro()
 	const config = useConfigContext();
 	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
 	const [newIntro, setNewIntro] = useState<string|null>('');
 
 	useEffect(() => {
@@ -41,7 +42,12 @@ export default function ChangeIntro()
 			return;
 		}
 
-		fetchChangeIntro({ Intro: newIntro } as ChangeIntroRequest).then((res) => {
+		setLoading(true);
+
+		fetchApi('/api/mypage/intro', {
+			method: 'POST',
+			body: { Intro: newIntro } as ChangeIntroRequest
+		}).then((res) => {
 			throwError(res);
 
 			member.intro = newIntro;
@@ -53,6 +59,8 @@ export default function ChangeIntro()
 		}).catch(err => {
 			setError(err.message);
 			setNewIntro(member.intro);
+		}).finally(() => {
+			setLoading(false);
 		});
 	}
 
@@ -62,7 +70,9 @@ export default function ChangeIntro()
 				return;
 			}
 
-			fetchRemoveIntro().then((res) => {
+			setLoading(true);
+
+			fetchApi('/api/mypage/intro', { method: 'DELETE' }).then((res) => {
 				throwError(res);
 
 				member.intro = null;
@@ -73,6 +83,7 @@ export default function ChangeIntro()
 			}).catch(err => {
 				setError(err.message);
 			}).finally(() => {
+				setLoading(false);
 				setNewIntro('');
 			});
 		}
@@ -81,8 +92,10 @@ export default function ChangeIntro()
 	return (
 		<>
 		<div id='changeIntro'>
+			{ loading && <Loading /> }
+
 			<h1>자기소개 변경</h1>
-			<form id='fChangeIntro' method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
+			<form method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
 				<table className='table-auto max-xl:w-full xl:w-[800px]'>
 					<caption>
 						커뮤니티 프로필에 표시되는 자기소개를 설정할 수 있습니다.

+ 0 - 6
app/(account)/change-name/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 17 - 5
app/(account)/change-name/page.tsx

@@ -6,14 +6,15 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangeNameRequest } from '@/dtos/request/account';
-import { fetchChangeName, fetchRemoveName } from '@/lib/api/account';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 
 export default function ChangeName()
 {
 	const config = useConfigContext();
 	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
 	const [newName, setNewName] = useState<string>('');
 	const newNameRef = useRef<HTMLInputElement>(null);
 
@@ -36,7 +37,12 @@ export default function ChangeName()
 			return setError('별명을 입력하세요.');
 		}
 
-		fetchChangeName({ Name: newName } as ChangeNameRequest).then((res) => {
+		setLoading(true);
+
+		fetchApi('/api/mypage/name', {
+			method: 'POST',
+			body: { Name: newName } as ChangeNameRequest
+		}).then((res) => {
 			throwError(res);
 
 			member.name = newName;
@@ -47,6 +53,7 @@ export default function ChangeName()
 		}).catch(err => {
 			setError(err.message);
 		}).finally(() => {
+			setLoading(false);
 			setNewName('');
 		});
 	}
@@ -61,7 +68,9 @@ export default function ChangeName()
 				return;
 			}
 
-			fetchRemoveName().then((res) => {
+			setLoading(true);
+
+			fetchApi('/api/mypage/name', { method: 'DELETE' }).then((res) => {
 				throwError(res);
 
 				member.name = null;
@@ -72,6 +81,7 @@ export default function ChangeName()
 			}).catch(err => {
 				setError(err.message);
 			}).finally(() => {
+				setLoading(false);
 				setNewName('');
 			});
 		}
@@ -80,8 +90,10 @@ export default function ChangeName()
 	return (
 		<>
 		<div id="changeName">
+			{ loading && <Loading /> }
+
 			<h1>별명 변경</h1>
-			<form id="fChangeName" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						개성을 담아 별명을 지어보세요!<br />

+ 0 - 6
app/(account)/change-password/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 47 - 19
app/(account)/change-password/page.tsx

@@ -2,12 +2,12 @@
 
 import './style.scss';
 import Link from 'next/link';
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect, useCallback, useRef } from 'react';
 import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangePasswordRequest } from '@/dtos/request/account';
-import { fetchChangePassword } from '@/lib/api/account';
-import { getPasswordPolicyMessage } from '@/lib/utils/client';
+import { fetchApi, getPasswordPolicyMessage, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 import NavTabs from '../navTabs';
 
 export default function ChangePassword()
@@ -15,12 +15,16 @@ export default function ChangePassword()
 	const config = useConfigContext();
 	const { member } = useMemberContext();
 	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
 	const [isComplete, setComplete] = useState<boolean>(false);
 	const [formData, setFormData] = useState({
 		currentPassword: '',
 		newPassword: '',
 		confirmPassword: ''
 	});
+	const currentPasswordRef = useRef<HTMLInputElement>(null);
+	const newPasswordRef = useRef<HTMLInputElement>(null);
+	const confirmPasswordRef = useRef<HTMLInputElement>(null);
 
 	useEffect(() => {
 		if (error) {
@@ -36,6 +40,24 @@ export default function ChangePassword()
 			return;
 		}
 
+		if (!formData.currentPassword) {
+			setError("현재 비밀번호를 입력하세요.");
+			currentPasswordRef.current?.focus();
+			return;
+		}
+
+		if (!formData.newPassword) {
+			setError("새 비밀번호를 입력하세요.");
+			newPasswordRef.current?.focus();
+			return;
+		}
+
+		if (!formData.confirmPassword) {
+			setError("새 비밀번호 확인을 입력하세요.");
+			confirmPasswordRef.current?.focus();
+			return;
+		}
+
 		if (formData.newPassword !== formData.confirmPassword) {
 			setError("새 비밀번호가 일치하지 않습니다.");
 			return;
@@ -46,19 +68,23 @@ export default function ChangePassword()
 			return;
 		}
 
-		fetchChangePassword({
-			ID: member.id,
-			CurrentPassword: formData.currentPassword,
-			NewPassword: formData.newPassword,
-			ConfirmPassword: formData.confirmPassword
-		} as ChangePasswordRequest).then((res) => {
-			if (!res.ok) {
-				throw new Error(res.message!);
-			}
+		setLoading(true);
+
+		fetchApi('/api/mypage/password', {
+			method: 'POST',
+			body: {
+				CurrentPassword: formData.currentPassword,
+				NewPassword: formData.newPassword,
+				ConfirmPassword: formData.confirmPassword
+			} as ChangePasswordRequest
+		}).then((res) => {
+			throwError(res);
 
 			setComplete(true);
 		}).catch(err => {
 			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
 		});
 	}
 
@@ -93,8 +119,10 @@ export default function ChangePassword()
 		<NavTabs />
 
 		<div id="changePassword">
+			{ loading && <Loading /> }
+
 			<h1>비밀번호 변경</h1>
-			<form id="fChangePassword" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						비밀번호는 { config.account.passwordMinLength }자 이상 20자 이하로 설정 가능합니다.<br />
@@ -107,23 +135,23 @@ export default function ChangePassword()
 					</colgroup>
 					<tbody>
 						<tr>
-							<th>현재 비밀번호</th>
+							<th><label htmlFor='currentPassword'>현재 비밀번호</label></th>
 							<td>
-								<input type="password" id="currentPassword" value={formData.currentPassword} onChange={handleChange} placeholder="현재 비밀번호" required />
+								<input type="password" id="currentPassword" value={formData.currentPassword} onChange={handleChange} placeholder="현재 비밀번호" ref={currentPasswordRef} />
 							</td>
 							<td>&nbsp;</td>
 						</tr>
 						<tr>
-							<th>새 비밀번호</th>
+							<th><label htmlFor='newPassword'>새 비밀번호</label></th>
 							<td>
-								<input type="password" id="newPassword" value={formData.newPassword} onChange={handleChange} placeholder="새 비밀번호" required />
+								<input type="password" id="newPassword" value={formData.newPassword} onChange={handleChange} placeholder="새 비밀번호" ref={newPasswordRef} />
 							</td>
 							<td>&nbsp;</td>
 						</tr>
 						<tr>
-							<th>새 비밀번호 확인</th>
+							<th><label htmlFor='confirmPassword'>새 비밀번호 확인</label></th>
 							<td>
-								<input type="password" id="confirmPassword" value={formData.confirmPassword} onChange={handleChange} placeholder="새 비밀번호 확인" required />
+								<input type="password" id="confirmPassword" value={formData.confirmPassword} onChange={handleChange} placeholder="새 비밀번호 확인" ref={confirmPasswordRef} />
 							</td>
 							<td>&nbsp;</td>
 						</tr>

+ 0 - 6
app/(account)/change-photo/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 0 - 6
app/(account)/change-summary/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 15 - 5
app/(account)/change-summary/page.tsx

@@ -6,14 +6,15 @@ import { useState, useEffect, useCallback, useRef } from 'react';
 import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangeSummaryRequest } from '@/dtos/request/account';
-import { fetchChangeSummary, fetchRemoveSummary } from '@/lib/api/account';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 
 export default function ChangeSummary()
 {
 	const config = useConfigContext();
 	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
 	const [newSummary, setNewSummary] = useState<string|null>('');
 	const newSummaryRef = useRef<HTMLInputElement>(null);
 
@@ -38,7 +39,10 @@ export default function ChangeSummary()
 
 		setLoading(true);
 
-		fetchChangeSummary({ Summary: newSummary } as ChangeSummaryRequest).then((res) => {
+		fetchApi('/api/mypage/summary', {
+			method: 'POST',
+			body: { Summary: newSummary } as ChangeSummaryRequest
+		}).then((res) => {
 			throwError(res);
 
 			member.summary = newSummary;
@@ -49,6 +53,7 @@ export default function ChangeSummary()
 		}).catch(err => {
 			setError(err.message);
 		}).finally(() => {
+			setLoading(false);
 			setNewSummary('');
 		});
 	}
@@ -63,7 +68,9 @@ export default function ChangeSummary()
 				return;
 			}
 
-			fetchRemoveSummary().then((res) => {
+			setLoading(true);
+
+			fetchApi('/api/mypage/summary', { method: 'DELETE' }).then((res) => {
 				throwError(res);
 
 				member.summary = null;
@@ -74,6 +81,7 @@ export default function ChangeSummary()
 			}).catch(err => {
 				setError(err.message);
 			}).finally(() => {
+				setLoading(false);
 				setNewSummary('');
 			});
 		}
@@ -82,8 +90,10 @@ export default function ChangeSummary()
 	return (
 		<>
 		<div id='changeSummary'>
+			{ loading && <Loading /> }
+
 			<h1>한마디 변경</h1>
-			<form id='fChangeSummary' method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
+			<form method='post' acceptCharset='utf-8' autoComplete='off' onSubmit={handleSubmit}>
 				<table className='table-auto max-xl:w-full lg:w-[600px]'>
 					<caption>
 						커뮤니티 프로필에 표시되는 한마디를 설정할 수 있습니다.

+ 68 - 30
app/(account)/change-photo/page.tsx → app/(account)/change-thumb/page.tsx

@@ -5,15 +5,16 @@ import Link from 'next/link';
 import Image from 'next/image';
 import { useState, useEffect } from 'react';
 import { useMemberContext } from '@/contexts/memberProvider';
-import { fetchChangePhoto } from '@/lib/api/account';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
 
-export default function ChangePhoto()
+export default function ChangeThumb()
 {
 	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
-	const [newPhoto, setNewPhoto] = useState<File|null>(null);
+	const [loading, setLoading] = useState<boolean>(false);
+	const [newThumb, setNewThumb] = useState<File|null>(null);
 	const [preview, setPreview] = useState<string|null>(null);
-	const [remove, setRemove] = useState<boolean>(false);
 
 	useEffect(() => {
 		if (error) {
@@ -22,6 +23,11 @@ export default function ChangePhoto()
 		}
 	}, [error]);
 
+	const resetFileInput = () => {
+		const fileInput = document.getElementById("thumb") as HTMLInputElement;
+		if (fileInput) fileInput.value = "";
+	};
+
 	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
 		e.preventDefault();
 
@@ -29,35 +35,40 @@ export default function ChangePhoto()
 			return;
 		}
 
-		if (!newPhoto && !remove) {
+		if (!newThumb) {
 			return setError('사진을 선택하세요.');
 		}
 
-		fetchChangePhoto(newPhoto).then((res) => {
-			if (!res.ok) {
-				throw new Error(res.message!);
-			}
+		setLoading(true);
+
+		const formData = new FormData();
+		formData.append('thumb', newThumb);
 
-			member.photo = (res.data?.photoURL || null);
+		fetchApi<{thumbUrl: string}>('/api/mypage/thumb', {
+			method: 'POST',
+			body: formData
+		}).then((res) => {
+			throwError(res);
+
+			member.thumb = (res.data?.thumbUrl || null);
 			setMember(member);
 			localStorage.setItem('member', JSON.stringify(member));
 
 			alert("사진이 변경되었습니다.");
 		}).catch(err => {
-			handleRemove();
 			setError(err.message);
 		}).finally(() => {
-			setRemove(false);
-			setNewPhoto(null);
+			setLoading(false);
+			setNewThumb(null);
 			setPreview(null);
+			resetFileInput();
 		});
 	}
 
 	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
 		if (e.target.files && e.target.files[0]) {
 			const selectedFile = e.target.files[0];
-			setNewPhoto(selectedFile);
-			setRemove(false);
+			setNewThumb(selectedFile);
 
 			// 이미지 미리보기
 			const reader = new FileReader();
@@ -66,21 +77,48 @@ export default function ChangePhoto()
 		}
 	};
 
-	const handleRemove = () => {
-		setNewPhoto(null);
-		setPreview(null);
-		setRemove(true);
+	const handleRemove = async () => {
+		// 미리보기만 있고 서버에 저장된 사진이 아닌 경우 로컬만 초기화
+		if (!member?.thumb) {
+			setNewThumb(null);
+			setPreview(null);
+			resetFileInput();
+			return;
+		}
 
-		const fileInput = document.getElementById("photo") as HTMLInputElement;
-		if (fileInput) {
-			fileInput.value = ""; // 파일 선택 초기화
+		if (!confirm('사진을 삭제하시겠습니까?')) {
+			return;
 		}
+
+		setLoading(true);
+
+		fetchApi('/api/mypage/thumb', {
+			method: 'DELETE'
+		}).then((res) => {
+			throwError(res);
+
+			member.thumb = null;
+			setMember(member);
+			localStorage.setItem('member', JSON.stringify(member));
+
+			setNewThumb(null);
+			setPreview(null);
+			resetFileInput();
+
+			alert("사진이 삭제되었습니다.");
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
 	};
 
 	return (
-		<div id="changePhoto">
+		<div id="changeThumb">
+			{ loading && <Loading /> }
+
 			<h1>회원 사진</h1>
-			<form id="fChangePhoto" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						커뮤니티 프로필에 대표 사진을 설정할 수 있습니다.
@@ -96,8 +134,8 @@ export default function ChangePhoto()
 									{preview ?
 										<Image src={preview} alt="미리보기" width={140} height={140} layout="responsive" style={{objectFit: "cover"}} />
 										:
-										(member.photo && !remove) ?
-											<Image src={member.photo} alt={member.email} width={140} height={140} style={{objectFit: "cover"}} unoptimized={true} />
+										member?.thumb ?
+											<Image src={member.thumb} alt={member.email} width={140} height={140} style={{objectFit: "cover"}} unoptimized={true} />
 											:
 											<Image src="/resources/thumb.gif" alt="기본 사진" width={140} height={140} style={{objectFit: "cover"}} />
 									}
@@ -105,10 +143,10 @@ export default function ChangePhoto()
 							</th>
 							<td>
 								<div className="flex justify-start gap-2">
-									<label htmlFor="photo" className="btn btn-default">사진 변경</label>
-									<input type="file" id="photo" accept="image/jpg,image/jpeg,image/png,image/gif" hidden onChange={handleChange} />
+									<label htmlFor="thumb" className="btn btn-default">사진 변경</label>
+									<input type="file" id="thumb" accept="image/jpg,image/jpeg,image/png,image/gif" hidden onChange={handleChange} />
 
-									{(preview || member?.photo) &&
+									{(preview || member?.thumb) &&
 										<button type="button" className="btn btn-default" onClick={handleRemove}>삭제</button>
 									}
 								</div>
@@ -132,4 +170,4 @@ export default function ChangePhoto()
 			</form>
 		</div>
 	);
-}
+}

+ 1 - 1
app/(account)/change-photo/style.scss → app/(account)/change-thumb/style.scss

@@ -1,4 +1,4 @@
-#changePhoto {
+#changeThumb {
 	padding: 25px 32px 32px 32px;
 
 	h1 {

+ 17 - 0
app/(account)/layout.tsx

@@ -1,9 +1,26 @@
 'use client';
 
 import './style.scss';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
 import Layout from "@/app/component/Layout";
+import useAuth from '@/hooks/useAuth';
 
 export default function AccountLayout({ children }: { children: React.ReactNode }) {
+	const router = useRouter();
+	const { isAuthenticated, isLoading } = useAuth();
+
+	// 로그인 만료 시 홈으로 리다이렉트
+	useEffect(() => {
+		if (!isLoading && !isAuthenticated) {
+			router.replace('/');
+		}
+	}, [isAuthenticated, isLoading, router]);
+
+	if (isLoading || !isAuthenticated) {
+		return null;
+	}
+
     return (
 		<Layout>{children}</Layout>
 	);

+ 5 - 0
app/(account)/loading.tsx

@@ -0,0 +1,5 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading() {
+	return <LoadHtml />;
+}

+ 28 - 35
app/(account)/login-log/page.tsx

@@ -2,10 +2,10 @@
 
 import './style.scss';
 import { useState, useEffect } from 'react';
-import { UAParser } from "ua-parser-js";
 import { LoginLogType } from '@/constants/common';
-import { fetchLoginLog } from '@/lib/api/account';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import type { LoginLog } from '@/types/account/loginLog';
+import { LoginLogsResponse } from '@/dtos/response/account/loginLogs';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
 import NavTabs from '../navTabs';
@@ -16,8 +16,10 @@ export default function LoginLog()
 	const [loading, setLoading] = useState<boolean>(true);
 	const [page, setPage] = useState<number>(1);
 	const [type, setType] = useState<LoginLogType>(LoginLogType.Today);
-	const [total, setTotal] = useState<number>(0);
-	const [logs, setLogs] = useState<LoginLog[]>([]);
+	const [logs, setLogs] = useState<LoginLogsResponse>({
+		total: 0,
+		list: []
+	});
 
 	useEffect(() => {
 		if (error) {
@@ -28,13 +30,9 @@ export default function LoginLog()
 
 	useEffect(() => {
 		setLoading(true);
-		fetchLoginLog(type, page).then((res) => {
-			if (!res.ok) {
-				throw new Error(res.message!);
-			}
-
-			setTotal(res.data.total);
-			setLogs(res.data.list);
+		fetchApi<LoginLogsResponse>(`/api/mypage/login-logs?page=${page}&type=${type}&pageSize=20`).then((res) => {
+			throwError(res);
+			setLogs(res.data!);
 		}).catch(err => {
 			setError(err.message);
 		}).finally(() => {
@@ -66,22 +64,23 @@ export default function LoginLog()
 				<caption>
 					<div className="grid grid-cols-[auto,1fr] gap-4 items-center">
 						<div>
-							합계: {total}
+							합계: {logs.total}
 						</div>
 						<div id="loginTypeTab" className="justify-self-end">
 							{tabItems.map((item, i) => (
-								<button key={i} className={`flex-1 py-2 px-4 text-center text-sm font-medium
+								<button type="button" key={i} className={`flex-1 py-2 px-4 text-center text-sm font-medium
 									${type === item.value ? "border-b-2 border-blue-500 text-blue-500" : "text-gray-500"}
-								`} onClick={() => setType(item.value)}>{item.label}</button>
+								`} onClick={() => setType(item.value)}>{item.label}
+								</button>
 							))}
 						</div>
 					</div>
 				</caption>
 				<colgroup>
 					<col width="20%"/>
-					<col width="*"/>
-					<col width="15%"/>
-					<col width="15%"/>
+					<col />
+					<col />
+					<col />
 					<col width="15%"/>
 				</colgroup>
 				<thead>
@@ -94,24 +93,16 @@ export default function LoginLog()
 					</tr>
 				</thead>
 				<tbody>
-					{logs.length > 0 ? (
-						logs.map((log) => {
-							const parser = new UAParser();
-							parser.setUA(log.userAgent);
-							const browser = `${parser.getBrowser().name ?? '알 수 없음'}`;
-							const device = `${parser.getOS().name ?? '알 수 없음'}`;
-
+					{logs.list.length > 0 ? (
+						logs.list.map((row) => {
 							return (
-								<tr key={log.id} className="hover:bg-gray-100">
-									<td>{log.createdAt}</td>
-									<td>{log.ipAddress}</td>
-									<td>{device}</td>
-									<td>{browser}</td>
-									<td className={`border p-2 font-semibold ${
-											log.success ? "text-green-500" : "text-red-500"
-										}`}
-										>
-										{log.success ? "성공" : "실패"}
+								<tr key={row.id} className="hover:bg-gray-100">
+									<td>{row.createdAt}</td>
+									<td>{row.ipAddress}</td>
+									<td>{row.ipAddress}</td>
+									<td>{row.userAgent}</td>
+									<td className={`border p-2 font-semibold ${row.success ? "text-green-500" : "text-red-500"}`}>
+										{row.success ? "성공" : "실패"}
 									</td>
 								</tr>
 							);
@@ -124,13 +115,15 @@ export default function LoginLog()
 							</tr>
 						)}
 				</tbody>
+				{logs.list.length > 0 && (
 				<tfoot>
 					<tr>
 						<td colSpan={5}>
-							<Pagination total={total} page={page} perPage={20} onChange={setPage} />
+							<Pagination total={logs.total} page={page} perPage={20} onChange={setPage} />
 						</td>
 					</tr>
 				</tfoot>
+				)}
 			</table>
 		</div>
 		</>

+ 1 - 1
app/(account)/login-log/style.scss

@@ -3,7 +3,7 @@
 
 	h1 {
 		font-size: 22px;
-		margin-bottom: 20px;
+		margin-bottom: 10px;
 	}
 
 	table {

+ 1 - 2
app/(account)/navTabs.tsx

@@ -17,8 +17,7 @@ export default function NavTabs() {
 				{ href: "/my-comments", label: "작성 댓글" },
 				{ href: "/exp-logs", label: "경험치 내역" },
 				{ href: "/login-log", label: "로그인 기록" },
-				{ href: "/withdraw", label: "회원탈퇴" },
-				{ href: "/cash", label:"캐시 충전"}
+				{ href: "/withdraw", label: "회원탈퇴" }
 			].map(({ href, label }, index, array) => (
 				<React.Fragment key={href}>
 					<Link href={href} className={pathname === href ? 'active' : ''}>{label}</Link>

+ 9 - 32
app/(account)/profile/page.tsx

@@ -2,34 +2,16 @@
 
 import './style.scss';
 import Link from 'next/link';
-import Image from 'next/image';
-import { useState, useEffect } from 'react';
 import useAuth from '@/hooks/useAuth';
 import { formatDate, stripHtmlTags } from '@/lib/utils/client';
 import NavTabs from '../navTabs';
-import Loading from '@/app/component/Loading';
 
 export default function Profile()
 {
 	const { member } = useAuth();
-	const [error, setError] = useState<string>('');
-	const [loading, setLoading] = useState<boolean>(true);
-
-	useEffect(() => {
-		if (error) {
-			alert(error);
-			setError('');
-		}
-	}, [error]);
-
-	useEffect(() => {
-		if (member) {
-			setLoading(false);
-		}
-	}, [member]);
 
 	if (!member) {
-		return <Loading />;
+		return null;
 	}
 
 	return (
@@ -37,15 +19,12 @@ export default function Profile()
 		<NavTabs />
 
 		<div id="profile">
-			{ loading && <Loading /> }
-
-			<h1>내 정보</h1>
+				<h1>내 정보</h1>
 			<div className="grid grid-cols-1 xl:grid-cols-2 gap-3">
 				<table className="table-auto">
 					<caption>등록 정보</caption>
 					<colgroup>
-						<col style={{width: "100%", minWidth: "80px", maxWidth: "30%"}}/>
-						<col/>
+						<col style={{width: "clamp(80px, 30%, 200px)"}}/>
 						<col/>
 					</colgroup>
 					<tbody>
@@ -83,23 +62,21 @@ export default function Profile()
 						<tr>
 							<th>알림 수신</th>
 							<td>
-								<div className="flex items-center gap-6">
-									<div className="flex items-center gap-4">
+								<div className="flex items-center gap-4 flex-wrap">
 									<label className="flex items-center gap-2 text-gray-700">
-										<input type="checkbox" checked={member.memberApprove.isReceiveSMS} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										<input type="checkbox" checked={member.memberApprove.isReceiveSMS} readOnly disabled className="accent-gray-500 cursor-not-allowed" />
 										SMS
 									</label>
 
 									<label className="flex items-center gap-2 text-gray-700">
-										<input type="checkbox" checked={member.memberApprove.isReceiveEmail} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										<input type="checkbox" checked={member.memberApprove.isReceiveEmail} readOnly disabled className="accent-gray-500 cursor-not-allowed" />
 										메일
 									</label>
 
 									<label className="flex items-center gap-2 text-gray-700">
-										<input type="checkbox" checked={member.memberApprove.isReceiveNote} readOnly disabled className="h-5 w-5 accent-gray-500 cursor-not-allowed" />
+										<input type="checkbox" checked={member.memberApprove.isReceiveNote} readOnly disabled className="accent-gray-500 cursor-not-allowed" />
 										쪽지
 									</label>
-									</div>
 								</div>
 							</td>
 							<td>
@@ -120,10 +97,10 @@ export default function Profile()
 						<tr>
 							<th>회원 사진</th>
 							<td>
-								{/* {member?.photo ? <Image src={member?.photo} alt="회원 사진" width={98} height={98} style={{objectFit: "cover"}} unoptimized={true} /> : '-'} */}
+								{/* {member?.thumb ? <Image src={member?.thumb} alt="회원 사진" width={98} height={98} style={{objectFit: "cover"}} unoptimized={true} /> : '-'} */}
 							</td>
 							<td>
-								<Link href="/change-photo" className="btn btn-default">사진 변경</Link>
+								<Link href="/change-thumb" className="btn btn-default">사진 변경</Link>
 							</td>
 						</tr>
 						<tr>

+ 9 - 24
app/(account)/valid-email/page.tsx → app/(account)/verify-email/page.tsx

@@ -4,18 +4,15 @@ import './style.scss';
 import Link from 'next/link';
 import { useSearchParams } from 'next/navigation';
 import { useState, useEffect } from 'react';
-import { useMemberContext } from '@/contexts/memberProvider';
-import { fetchValidEmail } from '@/lib/api/account';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function VerifyEmail()
 {
 	const searchParams = useSearchParams();
-	const { member, setMember } = useMemberContext();
 	const [error, setError] = useState<string>('');
-	const [loading, setLoading] = useState<boolean>(false);
+	const [loading, setLoading] = useState<boolean>(true);
 	const [isComplete, setComplete] = useState<boolean>(false);
-	const [newEmail, setNewEmail] = useState<string>('');
 	const [token] = useState(searchParams.get('token'));
 
 	useEffect(() => {
@@ -27,12 +24,6 @@ export default function VerifyEmail()
 
 	useEffect(() => {
 
-		if (!member) {
-			return;
-		}
-
-		setLoading(true);
-
 		if (!token) {
 			setError('잘못된 접근입니다.');
 			setLoading(false);
@@ -40,23 +31,17 @@ export default function VerifyEmail()
 			return;
 		}
 
-		fetchValidEmail(token).then((res) => {
-			if (!res.ok) {
-				throw new Error(res.message!);
-			}
-
-			member.email = res.data;
+		fetchApi(`/api/mypage/email/verify?token=${encodeURIComponent(token)}`).then((res) => {
+			throwError(res);
+			localStorage.removeItem('member');
 			setComplete(true);
-			setMember(member);
-			setNewEmail(member.email);
-			localStorage.setItem('member', JSON.stringify(member));
 		}).catch(err => {
 			setError(err.message);
 		}).finally(() => {
 			setLoading(false);
 		});
 
-	}, [token, member, setMember]);
+	}, [token]);
 
 	if (loading) {
 		return <Loading />;
@@ -69,11 +54,11 @@ export default function VerifyEmail()
 			<>
 				<h1>이메일 변경이 완료되었습니다.</h1>
 				<blockquote>
-					<strong>{newEmail} 주소의 인증이 확인되었습니다. </strong><br />
+					<strong>이메일 인증이 확인되었습니다.</strong><br />
 					다시 로그인 후 변경된 이메일로 서비스 이용이 가능합니다.<br />
 				</blockquote>
 				<br />
-				<Link href="/profile" className="btn btn-default">확인</Link>
+				<Link href="/login" className="btn btn-default">로그인</Link>
 			</>
 			:
 			<>
@@ -89,4 +74,4 @@ export default function VerifyEmail()
 		</div>
 		</>
 	);
-}
+}

+ 0 - 0
app/(account)/valid-email/style.scss → app/(account)/verify-email/style.scss


+ 30 - 20
app/(account)/withdraw/page.tsx

@@ -2,8 +2,8 @@
 
 import './style.scss';
 import Link from 'next/link';
-import { useState, useEffect } from 'react';
-import { fetchWithdraw } from '@/lib/api/account';
+import { useState, useEffect, useRef } from 'react';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import useAuth from '@/hooks/useAuth';
 import Loading from '@/app/component/Loading';
 import NavTabs from '../navTabs';
@@ -12,9 +12,11 @@ export default function Withdraw()
 {
 	const { member } = useAuth();
 	const [error, setError] = useState<string>('');
-	const [loading, setLoading] = useState<boolean>(true);
+	const [loading, setLoading] = useState<boolean>(false);
 	const [isComplete, setComplete] = useState<boolean>(false);
 	const [agree, setAgree] = useState<boolean>(false);
+	const [password, setPassword] = useState<string|null>('');
+	const passwordRef = useRef<HTMLInputElement>(null);
 
 	useEffect(() => {
 		if (error) {
@@ -23,12 +25,6 @@ export default function Withdraw()
 		}
 	}, [error]);
 
-	useEffect(() => {
-        if (member) {
-            setLoading(false);
-        }
-    }, [member]);
-
 	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
 		e.preventDefault();
 
@@ -36,7 +32,13 @@ export default function Withdraw()
 			return;
 		}
 
-		if (agree === false) {
+		if (!password) {
+			alert('비밀번호를 입력해주세요.');
+			passwordRef.current?.focus();
+			return;
+		}
+
+		if (!agree) {
 			alert('탈퇴에 동의해주세요.');
 			return;
 		}
@@ -44,11 +46,11 @@ export default function Withdraw()
 		if (confirm('정말 탈퇴하시겠습니까?')) {
 			setLoading(true);
 
-			fetchWithdraw().then((res) => {
-				if (!res.ok) {
-					throw new Error(res.message!);
-				}
-
+			fetchApi('/api/mypage/withdraw', {
+				method: 'POST',
+				body: { password }
+			}).then((res) => {
+				throwError(res);
 				setComplete(true);
 				localStorage.removeItem('member');
 			}).catch(err => {
@@ -72,8 +74,8 @@ export default function Withdraw()
 	}, [isComplete]);
 
 	if (!member) {
-        return <Loading />;
-    }
+		return null;
+	}
 
 	return (
 		<>
@@ -83,7 +85,7 @@ export default function Withdraw()
 			{ loading && <Loading /> }
 
 			<h1>회원탈퇴</h1>
-			<form id="fWithdraw" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<tbody>
 						<tr>
@@ -105,10 +107,18 @@ export default function Withdraw()
 								</blockquote>
 							</th>
 						</tr>
+						<tr>
+							<td>
+								<div className='flex flex-col sm:flex-row gap-2 justify-self-center items-center'>
+									<label htmlFor="password" className='pe-2 shrink-0'>비밀번호</label>
+									<input type="password" name="password" id="password" className="form-control w-full" ref={passwordRef} onChange={(e) => setPassword(e.target.value)} autoFocus />
+								</div>
+							</td>
+						</tr>
 						<tr>
 							<td>
 								<label>
-									<input type="checkbox" name="agree" onChange={handleChange} required />
+									<input type="checkbox" name="agree" onChange={handleChange} />
 									위 내용으로 탈퇴에 동의합니다.
 								</label>
 							</td>
@@ -129,4 +139,4 @@ export default function Withdraw()
 		</div>
 	</>
 	);
-}
+}

+ 9 - 10
app/(auth)/approval/page.tsx

@@ -5,8 +5,8 @@ import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
 import { VerificationType } from '@/constants/common';
-import { fetchVerifyEmail, fetchResendEmail } from '@/lib/api/auth';
-import { throwError } from '@/lib/utils/client';
+import { VerifyEmailRequest, ResendEmailRequest } from '@/dtos/request/auth';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function Approval()
@@ -120,13 +120,12 @@ export default function Approval()
 
             await new Promise(resolve => setTimeout(resolve, 500));
 
-			const res = await fetchVerifyEmail({
-				Email: email,
-				Code: verifyCode,
-				Type: Number(type)
+			const res = await fetchApi('/api/auth/verify-email', {
+				method: 'POST',
+				body: { Email: email, Code: verifyCode, Type: Number(type) } as VerifyEmailRequest
 			});
 
-            if (!res.ok) {
+            if (!res.success) {
                 throwError(res);
             }
 
@@ -155,9 +154,9 @@ export default function Approval()
     const handleResend = async () => {
         try {
 
-			await fetchResendEmail({
-				Email: email,
-				Type: Number(type)
+			await fetchApi('/api/auth/resend-email', {
+				method: 'POST',
+				body: { Email: email, Type: Number(type) } as ResendEmailRequest
 			});
 
             setCanResend(false);

+ 21 - 0
app/(auth)/approval/style.scss

@@ -58,6 +58,7 @@
             dl {
                 dt {
                     font-weight: bold;
+                    padding-bottom: 5px;
                 }
 
                 dd {
@@ -80,6 +81,26 @@
     }
 }
 
+@media (max-width: 420px) {
+    #approvalForm {
+        fieldset {
+            padding: 1rem 0 0 0;
+            border: none;
+
+            > legend {
+                position: initial;
+                padding: initial;
+                font-size: 1rem;
+                font-weight: bold;
+            }
+
+            > form {
+                padding: 0;
+            }
+        }
+    }
+}
+
 @media (max-width: 640px) {
     #approvalForm {
         fieldset {

+ 10 - 10
app/(auth)/forgot-password/page.tsx

@@ -5,8 +5,7 @@ import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
 import { ForgotPasswordRequest } from '@/dtos/request/auth';
-import { fetchForgotPassword } from '@/lib/api/auth';
-import { throwError } from '@/lib/utils/client';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import { VerificationType } from '@/constants/common';
 import Loading from '@/app/component/Loading';
 
@@ -41,13 +40,12 @@ export default function ForgotPassword()
 
             await new Promise(resolve => setTimeout(resolve, 500));
 
-            const res = await fetchForgotPassword({
-				Email: email
-			} as ForgotPasswordRequest);
+            const res = await fetchApi('/api/auth/forgot-password', {
+                method: 'POST',
+                body: { Email: email } as ForgotPasswordRequest
+            });
 
-			if (!res.ok) {
-				throwError(res);
-			}
+			throwError(res);
 
 			// 시간 제한 생성
 			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
@@ -58,8 +56,10 @@ export default function ForgotPassword()
 			sessionStorage.setItem("email", email);
 			router.push("/approval");
 
-        } catch (err: any) {
-            setError(err.message);
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
         } finally {
             setLoading(false);
         }

+ 19 - 1
app/(auth)/forgot-password/style.scss

@@ -53,5 +53,23 @@
                 }
             }
         }
+
+        @media (max-width: 420px) {
+            & {
+                padding: 1rem 0 0 0;
+                border: none;
+
+                > legend {
+                    position: initial;
+                    padding: initial;
+                    font-size: 1rem;
+                    font-weight: bold;
+                }
+
+                > form {
+                    padding: 0;
+                }
+            }
+        }
     }
-}
+}

+ 24 - 2
app/(auth)/layout.tsx

@@ -1,9 +1,31 @@
+'use client';
+
 import "./style.scss";
+import Image from 'next/image';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useEffect } from 'react';
+import useAuth from '@/hooks/useAuth';
 
 export default function Layout({ children }: { children: React.ReactNode }) {
+    const router = useRouter();
+    const { isAuthenticated, isLoading } = useAuth();
+
+    // 이미 로그인된 상태면 홈으로 리다이렉트
+    useEffect(() => {
+        if (!isLoading && isAuthenticated) {
+            router.replace('/');
+        }
+    }, [isAuthenticated, isLoading, router]);
+
     return (
-        <div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 sm:p-20 font-[family-name:var(--font-geist-sans)]">
-            {children}
+        <div className="grid grid-rows-[1fr_20px] items-center justify-items-center min-h-screen p-4 sm:p-20 font-[family-name:var(--font-geist-sans)]">
+            <div className="flex flex-col items-center gap-10">
+                <Link href="/" className="inline-block w-28 sm:w-30 md:w-36 lg:w-44">
+                    <Image src="/resources/m-logo.png" alt="bitforum" width={256} height={64} className="w-full h-auto" priority />
+                </Link>
+                {children}
+            </div>
             <address>
                 <hr />
                 <small>© 2025 PLAYR. All rights reserved.</small>

+ 0 - 6
app/(auth)/login/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 104 - 10
app/(auth)/login/page.tsx

@@ -1,15 +1,109 @@
-'use server';
+'use client';
 
-import { redirect } from 'next/navigation';
-import { isAuthenticated } from '@/lib/api/auth';
-import View from './view';
+import './style.scss';
+import Link from 'next/link';
+import { Checkbox } from '@/components/ui/checkbox';
+import { useState, useEffect, useRef } from 'react';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import { LoginRequest } from '@/dtos/request/auth';
+import { LoginResponse } from '@/dtos/response/auth';
+import useAuth from '@/hooks/useAuth';
 
-export default async function Page()
+export default function Page()
 {
-	// 로그인 여부 확인
-	if (await isAuthenticated()) {
-		redirect('/');
-	}
+    const { login } = useAuth();
+    const [error, setError] = useState<string>('');
+    const [loading, setLoading] = useState<boolean>(false);
+    const [email, setEmail] = useState<string>('');
+    const [password, setPassword] = useState<string>('');
+    const [rememberMe, setRememberMe] = useState<boolean>(false);
+    const emailRef = useRef<HTMLInputElement>(null);
+    const passwordRef = useRef<HTMLInputElement>(null);
 
-	return <View />;
+    useEffect(() => {
+        if (error) {
+            alert(error);
+            setError('');
+        }
+    }, [error]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+            if (email.length < 1) {
+                emailRef.current?.focus();
+                throw new Error('이메일을 입력하세요.');
+            }
+
+            if (password.length < 1) {
+                passwordRef.current?.focus();
+                throw new Error('비밀번호를 입력하세요.');
+            }
+
+            const res = await fetchApi<LoginResponse>('/api/auth/login', {
+                method: 'POST',
+                body: { Email: email, Password: password } as LoginRequest
+            });
+
+            throwError(res);
+
+            login(rememberMe);
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+			<div id="loginForm" className="row-start-2 flex flex-row flex-wrap gap-2">
+				<fieldset className="grow">
+					<legend>로그인</legend>
+					<form method="post" acceptCharset="utf-8" autoComplete="off" className="grid p-4" onSubmit={handleSubmit}>
+						<label htmlFor="email">이메일</label>
+						<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
+
+						<label htmlFor="password">비밀번호</label>
+						<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
+
+						<button type="submit" className="btn btn-submit" disabled={loading}>
+							{loading ? "로그인 중..." : "로그인"}
+						</button>
+
+						<section className="mt-3 text-center">
+							<Checkbox name="remember_me" id="rememberMe" checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
+							<label htmlFor="rememberMe">로그인 상태 유지</label>
+						</section>
+					</form>
+                    <hr hidden/>
+				</fieldset>
+				<fieldset className="grow basis-1/2">
+					<dl>
+						<dt>아직 회원이 아니신가요?</dt>
+						<dd>회원가입 한번으로 커뮤니티에 참여하세요!</dd>
+						<dd>
+							<Link href="/register">
+								<small>></small> 회원가입
+							</Link>
+						</dd>
+					</dl>
+					<hr />
+					<dl>
+						<dt>비밀번호를 잊으셨나요?</dt>
+						<dd>비밀번호를 깜박했다면 다시 설정할 수 있어요!</dd>
+						<dd>
+							<Link href="/forgot-password">
+								<small>></small> 비밀번호 재설정
+							</Link>
+						</dd>
+					</dl>
+				</fieldset>
+			</div>
+		</>
+    );
 }

+ 25 - 0
app/(auth)/login/style.scss

@@ -44,6 +44,7 @@
         dl {
             dt {
                 font-weight: bold;
+                padding-bottom: 4px;
             }
 
             dd {
@@ -75,6 +76,30 @@
     }
 }
 
+@media (max-width: 420px) {
+    #loginForm {
+        fieldset {
+            padding: 1rem 0 0 0;
+            border: none;
+
+            > legend {
+                position: initial;
+                padding: initial;
+                font-size: 1rem;
+                font-weight: bold;
+            }
+
+            > form {
+                padding: 0;
+            }
+
+            > hr {
+                display: block;
+            }
+        }
+    }
+}
+
 @media (max-width: 640px) {
     #loginForm {
         fieldset {

+ 0 - 111
app/(auth)/login/view.tsx

@@ -1,111 +0,0 @@
-'use client';
-
-import './style.scss';
-import Link from 'next/link';
-import { Checkbox } from '@/components/ui/checkbox';
-import { useState, useEffect, useRef } from 'react';
-import { fetchLogin } from '@/lib/api/auth';
-import { throwError } from '@/lib/utils/client';
-import { LoginRequest } from '@/dtos/request/auth';
-import useAuth from '@/hooks/useAuth';
-
- export default function View()
- {
-    const { login } = useAuth();
-    const [error, setError] = useState<string>('');
-    const [loading, setLoading] = useState<boolean>(false);
-    const [email, setEmail] = useState<string>('');
-    const [password, setPassword] = useState<string>('');
-    const [rememberMe, setRememberMe] = useState<boolean>(false);
-    const emailRef = useRef<HTMLInputElement>(null);
-    const passwordRef = useRef<HTMLInputElement>(null);
-
-    useEffect(() => {
-        if (error) {
-            alert(error);
-            setError('');
-        }
-    }, [error]);
-
-    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-        e.preventDefault();
-
-        try {
-
-            if (email.length < 1) {
-                emailRef.current?.focus();
-                throw new Error('이메일을 입력하세요.');
-            }
-
-            if (password.length < 1) {
-                passwordRef.current?.focus();
-                throw new Error('비밀번호를 입력하세요.');
-            }
-
-            const res = await fetchLogin({
-                Email: email,
-                Password: password
-            } as LoginRequest);
-
-            if (!res.ok) {
-                throwError(res);
-            }
-
-            login(rememberMe);
-
-        } catch (err) {
-            if (err instanceof Error) {
-                setError(err.message);
-            }
-        } finally {
-            setLoading(false);
-        }
-    }
-
-    return (
-		<>
-			<div id="loginForm" className="row-start-2 flex flex-row flex-wrap gap-2">
-				<fieldset className="grow">
-					<legend>로그인</legend>
-					<form id="fLogin" method="post" acceptCharset="utf-8" autoComplete="off" className="grid p-4" onSubmit={handleSubmit}>
-						<label htmlFor="email">이메일</label>
-						<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
-
-						<label htmlFor="password">비밀번호</label>
-						<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
-
-						<button type="submit" className="btn btn-submit" disabled={loading}>
-							{loading ? "로그인 중..." : "로그인"}
-						</button>
-
-						<section className="mt-3 text-center">
-							<Checkbox name="remember_me" id="rememberMe" checked={rememberMe} onCheckedChange={(checked) => setRememberMe(checked === true)} />
-							<label htmlFor="rememberMe">로그인 상태 유지</label>
-						</section>
-					</form>
-				</fieldset>
-				<fieldset className="grow basis-1/2">
-					<dl>
-						<dt>아직 회원이 아니신가요?</dt>
-						<dd>회원가입 한번으로 커뮤니티에 참여하세요!</dd>
-						<dd>
-							<Link href="/register">
-								<small>></small> 회원가입
-							</Link>
-						</dd>
-					</dl>
-					<hr />
-					<dl>
-						<dt>비밀번호를 잊으셨나요?</dt>
-						<dd>비밀번호를 깜박했다면 다시 설정할 수 있어요!</dd>
-						<dd>
-							<Link href="/forgot-password">
-								<small>></small> 비밀번호 재설정
-							</Link>
-						</dd>
-					</dl>
-				</fieldset>
-			</div>
-		</>
-    );
-}

+ 0 - 6
app/(auth)/register/loading.tsx

@@ -1,6 +0,0 @@
-import LoadHtml from '@/app/component/Loading';
-
-export default function Loading()
-{
-    return <LoadHtml />;
-}

+ 178 - 11
app/(auth)/register/page.tsx

@@ -1,15 +1,182 @@
-'use server';
+'use client';
 
-import { redirect } from 'next/navigation';
-import { isAuthenticated } from '@/lib/api/auth';
-import View from './view';
+import './style.scss';
+import Link from 'next/link';
+import { useRouter } from 'next/navigation';
+import { useState, useEffect, useRef } from 'react';
+import { Checkbox } from '@/components/ui/checkbox';
+import { Dialog, DialogTrigger } from '@/components/ui/dialog';
+import TermsDialog from '@/app/component/TermsDialog';
+import { VerificationType } from '@/constants/common';
+import { fetchApi, throwError } from '@/lib/utils/client';
+import { RegisterRequest } from '@/dtos/request/auth';
+import { RegisterResponse } from '@/dtos/response/auth';
 
-export default async function Page()
+export default function Page()
 {
-	// 로그인 여부 확인
-	if (await isAuthenticated()) {
-		redirect('/');
-	}
+    const router = useRouter();
+    const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(false);
+    const [email, setEmail] = useState<string>('');
+    const [password, setPassword] = useState<string>('');
+    const [rePassword, setRePassword] = useState<string>('');
+    const [agree1, setAgree1] = useState<boolean>(false);
+    const [agree2, setAgree2] = useState<boolean>(false);
+	const emailRef = useRef<HTMLInputElement>(null);
+	const passwordRef = useRef<HTMLInputElement>(null);
+	const rePasswordRef = useRef<HTMLInputElement>(null);
 
-	return <View />;
-}
+    useEffect(() => {
+        if (error) {
+            alert(error);
+			setError('');
+        }
+    }, [error]);
+
+    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
+        e.preventDefault();
+
+        try {
+
+            if (email.length < 1) {
+				emailRef.current?.focus();
+                throw new Error('이메일을 입력해주세요.');
+            }
+
+			if (password.length < 1) {
+				passwordRef.current?.focus();
+                throw new Error('비밀번호를 입력해주세요.');
+            }
+
+			if (rePassword.length < 1) {
+				rePasswordRef.current?.focus();
+                throw new Error('비밀번호 확인을 입력해주세요.');
+            }
+
+			if (password !== rePassword) {
+                throw new Error('비밀번호가 서로 일치하지 않습니다.');
+            }
+
+            if (!agree1 || !agree2) {
+                throw new Error('모든 필수 약관에 동의해주세요.');
+            }
+
+            await new Promise(resolve => setTimeout(resolve, 500));
+
+            const res = await fetchApi<RegisterResponse>('/api/auth/register', {
+                method: 'POST',
+                body: {
+                    Email: email,
+                    Password: password,
+                    IsPolicyAgree: agree1,
+                    IsPrivacyAgree: agree2
+                } as RegisterRequest
+            });
+
+			throwError(res);
+
+			// 시간 제한 생성
+			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
+			const callbackURL: string = location.pathname;
+
+			sessionStorage.setItem('type', VerificationType.Registration.toString());
+			sessionStorage.setItem('expiration', expiration);
+			sessionStorage.setItem('callbackURL', callbackURL);
+			sessionStorage.setItem('email', email);
+
+			if (res.data!.isRegisterEmailAuth) {
+				// 이메일 인증 필요
+				router.push('/approval');
+			} else {
+				// 회원가입 완료
+				router.push('/welcome');
+			}
+
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
+        } finally {
+            setLoading(false);
+        }
+    }
+
+    return (
+		<>
+			<div id="registForm" className="row-start-2">
+				<fieldset>
+					<legend>회원가입</legend>
+					<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
+						<div className="flex flex-row flex-wrap">
+							<section className="grow sm:pt-4 sm:pb-4">
+								<dl>
+									<dt hidden>&nbsp;</dt>
+									<dd>{ process.env.NEXT_PUBLIC_SITE_NAME } 에 오신 것을 환영합니다.</dd>
+									<dd>빠르고 간단하게 회원가입을 진행하세요.</dd>
+								</dl>
+								<br/>
+								<dl>
+									<dt hidden>&nbsp;</dt>
+									<dd>가입 확인을 위해 유효한 이메일을 입력해주세요.</dd>
+									<dd>비밀번호는 최소 8자 이상 입력해주세요.</dd>
+								</dl>
+							</section>
+							<section className="grow sm:pt-4">
+								<article className="grid">
+									<label htmlFor="email">이메일</label>
+									<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
+
+									<label htmlFor="password">비밀번호</label>
+									<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
+
+									<label htmlFor="rePassword">비밀번호 확인</label>
+									<input type="password" name="re_password" id="rePassword" ref={rePasswordRef} maxLength={20} onChange={e => setRePassword(e.target.value)} />
+								</article>
+							</section>
+						</div>
+
+						<hr />
+
+						<div className="flex flex-row flex-wrap">
+							<section className="grow">
+								<dl>
+									<dt>회원가입 약관</dt>
+									<dd>원활한 서비스 이용을 위해 약관 동의가 필요합니다.</dd>
+									<dd>약관 내용을 자세히 확인하신 후 동의해주세요.</dd>
+								</dl>
+							</section>
+							<section className="grow pt-4 sm:pt-0">
+								<p>
+									<Checkbox name="agree_1" id="agree_1" onCheckedChange={(checked) => setAgree1(Boolean(checked))} />
+									<label htmlFor="agree_1">
+										<Dialog>
+											<DialogTrigger>이용약관</DialogTrigger>
+											<TermsDialog subject="이용약관" description="" />
+										</Dialog>
+										에 동의합니다.
+									</label>
+								</p>
+								<p>
+									<Checkbox name="agree_2" id="agree_2" onCheckedChange={(checked) => setAgree2(Boolean(checked))} />
+									<label htmlFor="agree_2">
+										<Dialog>
+											<DialogTrigger>개인정보처리방침</DialogTrigger>
+											<TermsDialog subject="개인정보처리방침" description="" />
+										</Dialog>
+										에 동의합니다.
+									</label>
+								</p>
+								<div className="grid grid-cols-2 gap-2 mt-3">
+									<button type="submit" className="btn btn-submit" disabled={loading}>
+										{loading ? "회원가입 중..." : "회원가입"}
+									</button>
+									<Link href="/login" className="btn btn-default">취소</Link>
+								</div>
+							</section>
+						</div>
+					</form>
+				</fieldset>
+			</div>
+		</>
+    );
+}

+ 21 - 2
app/(auth)/register/style.scss

@@ -88,16 +88,35 @@
     }
 }
 
+@media (max-width: 420px) {
+    #registForm {
+        fieldset {
+            padding: 1rem 0 0 0;
+            border: none;
+
+            > legend {
+                position: initial;
+                padding: initial;
+                font-size: 1rem;
+                font-weight: bold;
+            }
+
+            > form {
+                padding: 0;
+            }
+        }
+    }
+}
+
 @media (max-width: 660px) {
     #registForm {
         fieldset {
             form {
                 section {
-
                     &:nth-of-type(2n+1) {
                         flex-basis: 100%;
                     }
-    
+
                     &:nth-of-type(2n) {
                         flex-basis: 100%;
                     }

+ 0 - 183
app/(auth)/register/view.tsx

@@ -1,183 +0,0 @@
-'use client';
-
-import './style.scss';
-import Link from 'next/link';
-import { useRouter } from 'next/navigation';
-import { useState, useEffect, useRef } from 'react';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Dialog, DialogTrigger } from '@/components/ui/dialog';
-import TermsDialog from '@/app/component/TermsDialog';
-import { VerificationType } from '@/constants/common';
-import { fetchRegister } from '@/lib/api/auth';
-import { throwError } from '@/lib/utils/client';
-import { RegisterRequest } from '@/dtos/request/auth';
-
-export default function View()
-{
-    const router = useRouter();
-    const [error, setError] = useState<string>('');
-	const [loading, setLoading] = useState<boolean>(false);
-    const [email, setEmail] = useState<string>('');
-    const [password, setPassword] = useState<string>('');
-    const [rePassword, setRePassword] = useState<string>('');
-    const [agree1, setAgree1] = useState<boolean>(false);
-    const [agree2, setAgree2] = useState<boolean>(false);
-	const emailRef = useRef<HTMLInputElement>(null);
-	const passwordRef = useRef<HTMLInputElement>(null);
-	const rePasswordRef = useRef<HTMLInputElement>(null);
-
-    useEffect(() => {
-        if (error) {
-            alert(error);
-			setError('');
-        }
-    }, [error]);
-
-    const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-        e.preventDefault();
-
-        try {
-
-            if (email.length < 1) {
-				emailRef.current?.focus();
-                throw new Error('이메일을 입력해주세요.');
-            }
-
-			if (password.length < 1) {
-				passwordRef.current?.focus();
-                throw new Error('비밀번호를 입력해주세요.');
-            }
-
-			if (rePassword.length < 1) {
-				rePasswordRef.current?.focus();
-                throw new Error('비밀번호 확인을 입력해주세요.');
-            }
-
-			if (password !== rePassword) {
-                throw new Error('비밀번호가 서로 일치하지 않습니다.');
-            }
-
-            if (!agree1 || !agree2) {
-                throw new Error('모든 필수 약관에 동의해주세요.');
-            }
-
-            await new Promise(resolve => setTimeout(resolve, 500));
-
-            const res = await fetchRegister({
-				Email : email,
-                Password : password,
-                IsPolicyAgree : agree1,
-                IsPrivacyAgree : agree2
-			} as RegisterRequest);
-
-			if (!res.ok) {
-				throwError(res);
-			}
-
-			// 시간 제한 생성
-			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
-			const callbackURL: string = location.pathname;
-
-			sessionStorage.setItem('type', VerificationType.Registration.toString());
-			sessionStorage.setItem('expiration', expiration);
-			sessionStorage.setItem('callbackURL', callbackURL);
-			sessionStorage.setItem('email', email);
-
-			if (res.data.isRegisterEmailAuth) {
-				// 이메일 인증 필요
-				router.push('/approval');
-			} else {
-				// 회원가입 완료
-				router.push('/welcome');
-			}
-
-        } catch (err) {
-            if (err instanceof Error) {
-                setError(err.message);
-            }
-        } finally {
-            setLoading(false);
-        }
-    }
-
-    return (
-		<>
-			<div id="registForm" className="row-start-2">
-				<fieldset>
-					<legend>회원가입</legend>
-					<form id="fRegist" method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
-						<div className="flex flex-row flex-wrap">
-							<section className="grow sm:pt-4 sm:pb-4">
-								<dl>
-									<dt hidden>&nbsp;</dt>
-									<dd>{ process.env.NEXT_PUBLIC_SITE_NAME } 에 오신 것을 환영합니다.</dd>
-									<dd>빠르고 간단하게 회원가입을 진행하세요.</dd>
-								</dl>
-								<br/>
-								<dl>
-									<dt hidden>&nbsp;</dt>
-									<dd>가입 확인을 위해 유효한 이메일을 입력해주세요.</dd>
-									<dd>비밀번호는 최소 8자 이상 입력해주세요.</dd>
-								</dl>
-							</section>
-							<section className="grow sm:pt-4">
-								<article className="grid">
-									<label htmlFor="email">이메일</label>
-									<input type="email" name="email" id="email" ref={emailRef} maxLength={30} onChange={e => setEmail(e.target.value)} autoComplete="off" />
-
-									<label htmlFor="password">비밀번호</label>
-									<input type="password" name="password" id="password" ref={passwordRef} maxLength={20} onChange={e => setPassword(e.target.value)} />
-
-									<label htmlFor="rePassword">비밀번호 확인</label>
-									<input type="password" name="re_password" id="rePassword" ref={rePasswordRef} maxLength={20} onChange={e => setRePassword(e.target.value)} />
-								</article>
-							</section>
-						</div>
-
-						<hr />
-
-						<div className="flex flex-row flex-wrap">
-							<section className="grow">
-								<dl>
-									<dt>회원가입 약관</dt>
-									<dd>원활한 서비스 이용을 위해 약관 동의가 필요합니다.</dd>
-									<dd>약관 내용을 자세히 확인하신 후 동의해주세요.</dd>
-								</dl>
-							</section>
-							<section className="grow pt-4 sm:pt-0">
-								<p>
-									<Checkbox name="agree_1" id="agree_1" onCheckedChange={(checked) => setAgree1(Boolean(checked))} />
-									<label htmlFor="agree_1">
-										<small>(필수)</small>
-										<Dialog>
-											<DialogTrigger>이용약관</DialogTrigger>
-											<TermsDialog subject="이용약관" description="" />
-										</Dialog>
-										에 동의합니다.
-									</label>
-								</p>
-								<p>
-									<Checkbox name="agree_2" id="agree_2" onCheckedChange={(checked) => setAgree2(Boolean(checked))} />
-									<label htmlFor="agree_2">
-										<small>(필수)</small>
-										<Dialog>
-											<DialogTrigger>개인정보처리방침</DialogTrigger>
-											<TermsDialog subject="개인정보처리방침" description="" />
-										</Dialog>
-										에 동의합니다.
-									</label>
-								</p>
-								<div className="grid grid-cols-2 gap-2 mt-3">
-									<button type="submit" className="btn btn-submit" disabled={loading}>
-										{loading ? "회원가입 중..." : "회원가입"}
-									</button>
-									<Link href="/login" className="btn btn-default">취소</Link>
-								</div>
-							</section>
-						</div>
-					</form>
-				</fieldset>
-			</div>
-		</>
-    );
-}

+ 11 - 12
app/(auth)/reset-password/page.tsx

@@ -4,8 +4,8 @@ import './style.scss';
 import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
-import { fetchResetPassword } from '@/lib/api/auth';
-import { throwError } from '@/lib/utils/client';
+import { ResetPasswordRequest } from '@/dtos/request/auth';
+import { fetchApi, throwError } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ResetPassword()
@@ -67,22 +67,21 @@ export default function ResetPassword()
 
             await new Promise(resolve => setTimeout(resolve, 500));
 
-            const res = await fetchResetPassword({
-				Email: email,
-				Password: password,
-				RePassword: rePassword
-			});
+            const res = await fetchApi('/api/auth/reset-password', {
+                method: 'POST',
+                body: { Email: email, Password: password, RePassword: rePassword } as ResetPasswordRequest
+            });
 
-			if (!res.ok) {
-				throwError(res);
-			}
+            throwError(res);
 
 			sessionStorage.clear();
 			alert(res.message);
 			router.push('/login');
 
-        } catch (err: any) {
-            setError(err.message);
+        } catch (err) {
+            if (err instanceof Error) {
+                setError(err.message);
+            }
         } finally {
             setLoading(false);
         }

+ 20 - 0
app/(auth)/reset-password/style.scss

@@ -60,4 +60,24 @@
             margin: 20px 0;
         }
     }
+}
+
+@media (max-width: 420px) {
+    #resetPasswordForm {
+        fieldset {
+            padding: 1rem 0 0 0;
+            border: none;
+
+            > legend {
+                position: initial;
+                padding: initial;
+                font-size: 1rem;
+                font-weight: bold;
+            }
+
+            > form {
+                padding: 0;
+            }
+        }
+    }
 }

+ 1 - 1
app/(forum)/board/[code]/page.tsx

@@ -76,7 +76,7 @@ export default async function Board({ params, searchParams }: Props)
 		keyword: query.keyword as string|null|undefined
 	} as BoardPostsRequest);
 
-	if (!boardPosts.ok) {
+	if (!boardPosts.success) {
 		throwError(boardPosts);
 	}
 

+ 2 - 2
app/(forum)/board/_component/PostWriteButton.tsx

@@ -7,12 +7,12 @@ import useAuth from '@/hooks/useAuth';
 
 export default function PostWriteButton({ alwaysShowButton, boardCode }: { alwaysShowButton: boolean, boardCode: string }) {
 	const router = useRouter();
-	const { isAuthenticated, isLogined } = useAuth();
+	const { isAuthenticated, isLoggedIn } = useAuth();
 
 	const handleClick = useCallback(async (e: React.MouseEvent<HTMLButtonElement>) => {
 		const redirectUrl = e.currentTarget.value;
 
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 

+ 1 - 1
app/(forum)/comment/_component/EditForm.tsx

@@ -144,7 +144,7 @@ export default function EditForm({ board, post, comment, onSuccess, onCancel }:
 				});
 			}
 
-			const res = await fetchCommentUpdate(formData);
+			const res = await fetchCommentUpdate(comment.id, formData);
 			await throwError(res);
 			onSuccess();
 

+ 4 - 3
app/(forum)/comment/_component/Item.tsx

@@ -8,10 +8,10 @@ import { faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag,
 import { faThumbsUp as yThumbsUp, faFlag as yFlag, faArrowRotateRight, faEllipsisVertical } from '@fortawesome/free-solid-svg-icons';
 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
 import { throwError, loginCheck, formatDate } from '@/lib/utils/client';
+import useAuth from '@/hooks/useAuth';
 import { type CommentItem } from '@/types/forum/comment';
 import BoardResponse from '@/dtos/response/forum/board/boardResponse';
 import PostResponse from '@/dtos/response/forum/post/postResponse';
-import CommentDeleteRequest from '@/dtos/request/forum/comment/commentDeleteRequest';
 import EditForm from './EditForm';
 import { fetchCommentDelete } from '@/lib/api/forum/comment';
 
@@ -27,6 +27,7 @@ type Props = {
 
 export default function Item({ comment, isReplying, board, post, onReply, onSuccess, onDelete } : Props)
 {
+	const { isAuthenticated } = useAuth();
 	const [error, setError] = useState<string|null>(null);
 	const [loading, setLoading] = useState<boolean>(false);
 	const [isEditing, setIsEditing] = useState<boolean>(false);
@@ -38,7 +39,7 @@ export default function Item({ comment, isReplying, board, post, onReply, onSucc
     }, [comment]);
 
     const handleStartEdit = () => {
-		if (!loginCheck()) {
+		if (!loginCheck(isAuthenticated)) {
 			return;
 		}
         setIsEditing(true);
@@ -62,7 +63,7 @@ export default function Item({ comment, isReplying, board, post, onReply, onSucc
 			setLoading(true);
 
 			// 댓글 삭제 호출
-			fetchCommentDelete({ commentID: comment.id } as CommentDeleteRequest).then((res) => {
+			fetchCommentDelete(comment.id).then((res) => {
 				throwError(res);
 
 				// 삭제 성공 시 해당 댓글 영역 삭제

+ 5 - 5
app/(forum)/comment/_component/List.tsx

@@ -29,10 +29,10 @@ export default function List({ board, post, data, loading, replyTargetID, onRepl
             {data && data.total > 0 && (
                 <article className="comment-list">
                     <ol>
-                        {data.list.map((row, i) => {
+                        {data.list.map((row) => {
                             return (
-                                <React.Fragment key={i}>
-                                    <Item key={i}
+                                <React.Fragment key={row.id}>
+                                    <Item
                                         board={board}
                                         post={post}
                                         comment={row}
@@ -57,8 +57,8 @@ export default function List({ board, post, data, loading, replyTargetID, onRepl
 
                                     {Array.isArray(row.children) && row.children.length > 0 && (
                                         <ol className="pl-[81px]">
-                                            {row.children.map((ch, j) => (
-                                                <React.Fragment key={j}>
+                                            {row.children.map((ch) => (
+                                                <React.Fragment key={ch.id}>
                                                     <Item
                                                         board={board}
                                                         post={post}

+ 1 - 1
app/(forum)/comment/_component/WriteForm.tsx

@@ -214,7 +214,7 @@ export default function WriteForm({ board, post, comment, onSuccess, onCancel }:
 
 				<article className='write-form'>
 					<div>
-						<Image src={member?.photo ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
+						<Image src={member?.thumb ?? '/resources/thumb.gif'} alt={member?.name ?? member?.sid ?? ''} width={72} height={0} />
 					</div>
 					<div>
 						<textarea

+ 3 - 1
app/(forum)/comment/view.tsx

@@ -10,6 +10,7 @@ import CommentListRequest from '@/dtos/request/forum/comment/commentListRequest'
 import CommentListResponse from '@/dtos/response/forum/comment/commentListResponse';
 import { fetchCommentList } from '@/lib/api/forum/comment';
 import { throwError, loginCheck } from '@/lib/utils/client';
+import useAuth from '@/hooks/useAuth';
 import WriteForm from './_component/WriteForm';
 import List from './_component/List';
 import { type CommentSort, CommentConst } from '@/constants/forum';
@@ -22,6 +23,7 @@ type Props = {
 
 export default function View({ board, post } : Props)
 {
+	const { isAuthenticated } = useAuth();
 	const [error, setError] = useState<string|null>(null);
 	const [loading, setLoading] = useState<boolean>(false);
 	const [page, setPage] = useState<number>(1);
@@ -63,7 +65,7 @@ export default function View({ board, post } : Props)
 	};
 
 	const handleReply = (commentID: number) => {
-		if (!loginCheck()) {
+		if (!loginCheck(isAuthenticated)) {
 			return;
 		}
 		setReplyTargetID((prev) => (prev === commentID ? null : commentID)); // toggle

+ 1 - 1
app/(forum)/post/[id]/page.tsx

@@ -18,7 +18,7 @@ export default async function PostView({ params }: { params: Promise<{ id: strin
 	// 게시글 정보 조회
 	const post = await fetchPostData(Number(id));
 
-	if (!post.ok || !post.data) {
+	if (!post.success || !post.data) {
 		return notFound();
 	}
 

+ 10 - 10
app/(forum)/post/[id]/view.tsx

@@ -45,7 +45,7 @@ export default function View({ _board, _post }: Props)
 	}, []);
 
 	const router = useRouter();
-	const { member, isLogined } = useAuth();
+	const { member, isLoggedIn } = useAuth();
 	const [error, setError] = useState<string>('');
 	const [loading, setLoading] = useState<boolean>(false);
 	const [qrCode, setQrCode] = useState<boolean>(false);
@@ -87,12 +87,12 @@ export default function View({ _board, _post }: Props)
 	const handleReaction = useCallback(async (e: MouseEvent<HTMLButtonElement>) => {
 		const reaction = Number(e.currentTarget.value);
 
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 
 		fetchPostReaction({ postID: _post.id, reaction: reaction } as PostReactionRequest).then(res => {
-			if (res.ok) {
+			if (res.success) {
 				switch (reaction) {
 					case Reaction.Like:
 						setHasLike(!hasLike);
@@ -116,12 +116,12 @@ export default function View({ _board, _post }: Props)
 
 	// 즐겨찾기
 	const handleBookmark = useCallback(async () => {
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 
 		fetchPostBookmark({ postID: _post.id } as PostBookmarkRequest).then(res => {
-			if (res.ok) {
+			if (res.success) {
 				setHasBookmark(!hasBookmark);
 			} else {
 				throwError(res);
@@ -141,7 +141,7 @@ export default function View({ _board, _post }: Props)
 			return;
 		}
 
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 
@@ -150,7 +150,7 @@ export default function View({ _board, _post }: Props)
 
 	// 수정하기
 	const handleEdit = useCallback(async () => {
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 
@@ -166,7 +166,7 @@ export default function View({ _board, _post }: Props)
 
 	// 게시글 삭제
 	const handleDelete = useCallback(async () => {
-		if (!await isLogined()) {
+		if (!await isLoggedIn()) {
 			return;
 		}
 
@@ -179,7 +179,7 @@ export default function View({ _board, _post }: Props)
 
 		if (confirm('정말 삭제하시겠습니까?')) {
 			fetchPostDelete(_post.id).then(res => {
-				if (res.ok) {
+				if (res.success) {
 					alert('게시글이 삭제되었습니다.');
 					router.push(`/board/${_post.boardCode}`);
 				} else {
@@ -211,7 +211,7 @@ export default function View({ _board, _post }: Props)
 
 			{/* 글 작성자/작성일시/부가기능들 */}
 			<section className='attribution'>
-				{_board.boardMeta.view.showMemberPhoto && (
+				{_board.boardMeta.view.showMemberThumb && (
 				<div>
 					<article className='writer-thumb'>
 						<Image src='/resources/thumb.gif' alt='회원 사진' width={84} height={0} />

+ 1 - 1
app/(forum)/post/_component/Report.tsx

@@ -91,7 +91,7 @@ export default function Report({ isEnable, open, onChange, onComplete, postID, m
 				reason: form.reason
 			} as PostReportRequest);
 
-			if (res.ok) {
+			if (res.success) {
 				alert('신고가 접수되었습니다.');
 				setForm({ type: '', reason: '' });
 				onComplete(true);

+ 3 - 3
app/(forum)/post/edit/[id]/page.tsx

@@ -41,20 +41,20 @@ export default async function PostEdit({ params }: { params: Promise<{ id: strin
 		// 게시글 정보 조회
 		const post = await fetchPostData(Number(id));
 
-		if (!post.ok || !post.data) {
+		if (!post.success || !post.data) {
 			throw Error;
 		}
 
 		// 게시판 상세 조회
 		const board = await fetchBoard(post.data.boardCode);
-		if (!board.ok || !board.data) {
+		if (!board.success || !board.data) {
 			throw Error;
 		}
 
 		// 게시판 목록 조회
 		const boardList = await fetchBoardList(board.data.boardGroup.code);
 
-		if (!boardList.ok || !boardList.data) {
+		if (!boardList.success || !boardList.data) {
 			return notFound();
 		}
 

+ 3 - 3
app/(forum)/post/edit/[id]/view.tsx

@@ -87,7 +87,7 @@ export default function View({ _boardList, _board, _post }: Props)
 		setLoading(true);
 
 		fetchBoard(boardCode).then((res) => {
-			if (res.ok) {
+			if (res.success) {
 				setBoardCode(code);
 				setBoard(res.data);
 				setIsChanged(false);
@@ -246,9 +246,9 @@ export default function View({ _boardList, _board, _post }: Props)
 				});
 			}
 
-			const res = await fetchPostUpdate(formData);
+			const res = await fetchPostUpdate(_post.id, formData);
 
-			if (res.ok) {
+			if (res.success) {
 				resetForm();
 				router.push(redirectUrl);
 			} else {

+ 3 - 3
app/(forum)/post/write/view.tsx

@@ -59,7 +59,7 @@ export default function View()
 
 			// 게시판 상세 정보 호출
 			fetchBoard(boardCode).then((res) => {
-				if (res.ok) {
+				if (res.success) {
 					setBoard(res.data);
 				} else {
 					throw new Error('게시판을 조회할 수 없습니다.');
@@ -80,7 +80,7 @@ export default function View()
 			// 게시판 목록 호출
 			if (boardList.length <= 0) {
 				fetchBoardList(board.boardGroup.code).then((res) => {
-					if (res.ok) {
+					if (res.success) {
 						if (res.data && res.data.length >= 0) {
 							setBoardList(res.data);
 						}
@@ -270,7 +270,7 @@ export default function View()
 
 			const res = await fetchPostCreate(formData);
 
-			if (res.ok) {
+			if (res.success) {
 				resetForm();
 				router.push(`/post/${res.data!.id}`);
 			} else {

+ 27 - 0
app/api/auth/[...path]/route.ts

@@ -0,0 +1,27 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/dtos/response/common';
+import { LoginResponse } from '@/dtos/response/auth';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/auth/${path.join('/')}`;
+	const body = await request.json();
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'POST',
+		body: JSON.stringify(body)
+	});
+
+	const response = NextResponse.json(res);
+
+	// 로그인 성공 시 쿠키 설정
+	if (path[0] === 'login' && res.success && res.data) {
+		const data = res.data as LoginResponse;
+		const cookieOptions = { httpOnly: true, path: '/' };
+		response.cookies.set('accessToken', data.accessToken, cookieOptions);
+		response.cookies.set('refreshToken', data.refreshToken, cookieOptions);
+	}
+
+	return response;
+}

+ 0 - 70
app/api/auth/[slug]/route.ts

@@ -1,70 +0,0 @@
-
-import { type NextRequest, NextResponse } from 'next/server';
-import { fetchJson } from '@/lib/utils/server';
-
-export async function GET(request: NextRequest, { params }: { params: { slug: string } }) {
-	const { slug } = await params;
-
-	switch (slug) {
-		default:
-            return new Response(JSON.stringify({ message: '잘못된 요청입니다.' }), { status: 400 });
-
-		// accessToken 검증
-		case 'verify-token': {
-			const res = await fetchJson('/api/auth/verify-token', {
-				method: 'GET',
-				headers: {'Content-Type': 'application/json'},
-				data: request,
-				withCredentials: true
-			});
-
-			return new NextResponse(JSON.stringify(res), {
-				status: 200
-			});
-		}
-
-		// refreshToken 검증
-		case 'refresh-token': {
-			const res = await fetchJson('/api/auth/refresh-token', {
-				method: 'GET',
-				headers: {'Content-Type': 'application/json'},
-				data: request,
-				withCredentials: true
-			});
-
-			return new NextResponse(JSON.stringify(res), {
-				status: 200
-			});
-		}
-	}
-}
-
-export async function POST(request: NextRequest, { params }: { params: { slug: string } }) {
-	const { slug } = await params;
-
-	switch (slug) {
-		default:
-            return new Response(JSON.stringify({ message: '잘못된 요청입니다.' }), { status: 400 });
-
-		// 로그아웃
-		case 'logout': {
-			const res = await fetchJson('/api/auth/logout', {
-				method: 'GET',
-				headers: {'Content-Type': 'application/json'},
-				withCredentials: true
-			});
-
-			const headers = new Headers();
-
-			if (res.ok) {
-				headers.append('Set-Cookie', 'accessToken=; HttpOnly; Path=/; Max-Age=0;');
-				headers.append('Set-Cookie', 'refreshToken=; HttpOnly; Path=/; Max-Age=0;');
-			}
-
-			return new NextResponse(null, {
-				status: 200,
-				headers: headers
-			});
-		}
-	}
-}

+ 47 - 0
app/api/mypage/[...path]/route.ts

@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/dtos/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/mypage/${path.join('/')}`;
+	const url = new URL(request.url);
+
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, {
+		method: 'GET'
+	});
+
+	return NextResponse.json(res);
+}
+
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/mypage/${path.join('/')}`;
+
+	const contentType = request.headers.get('content-type') || '';
+	let body: string | FormData | undefined;
+
+	if (contentType.includes('multipart/form-data')) {
+		body = await request.formData();
+	} else {
+		body = JSON.stringify(await request.json());
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'POST',
+		body
+	});
+
+	return NextResponse.json(res);
+}
+
+export async function DELETE(_: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/mypage/${path.join('/')}`;
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'DELETE'
+	});
+
+	return NextResponse.json(res);
+}

+ 0 - 10
app/api/ping/route.ts

@@ -1,10 +0,0 @@
-import { fetchJson } from '@/lib/utils/server';
-
-export async function GET() {
-
-	const res = await fetch('https://localhost:4000/api/ping', {
-		method: 'GET',
-	}).then(r => r.json());
-
-	return new Response(res.message);
-}

+ 36 - 0
app/api/uploads/[...path]/route.ts

@@ -0,0 +1,36 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+const API_URL = process.env.API_URL;
+
+export async function GET(_: NextRequest, { params }: { params: Promise<{ path: string[] }> })
+{
+	const { path } = await params;
+	const filePath = `/uploads/${path.join('/')}`;
+
+	try {
+		const res = await fetch(`${API_URL}${filePath}`, {
+			cache: 'no-store'
+		});
+
+		if (!res.ok) {
+			return new NextResponse(null, {
+				status: res.status
+			});
+		}
+
+		const contentType = res.headers.get('content-type') || 'application/octet-stream';
+		const buffer = await res.arrayBuffer();
+
+		return new NextResponse(buffer, {
+			status: 200,
+			headers: {
+				'Content-Type': contentType,
+				'Cache-Control': 'public, max-age=31536000, immutable'
+			}
+		});
+	} catch {
+		return new NextResponse(null, {
+			status: 502
+		});
+	}
+}

+ 8 - 7
app/globals.scss

@@ -78,7 +78,7 @@ body {
     * {
         @apply border-border;
     }
-    
+
     body {
         @apply bg-background text-foreground;
     }
@@ -134,7 +134,7 @@ select, input, textarea {
     border-radius: 3px;
     transition: border-color 0.3s ease;
 	line-height: inherit;
-	
+
     &:focus {
         outline: none;
         border-color: #9c9c9c;
@@ -169,12 +169,13 @@ select, input, textarea {
 // 제출 버튼
 .btn-submit {
     color: #fff;
-    background: #d51b28;
-	border: 1px solid #a32121;
-    -webkit-box-shadow: inset 0 -2px 0 0 #95131c;
-    box-shadow: inset 0 -2px 0 0 #95131c;
+    background: #F7931A;
+	border: 1px solid #b96606;
+    -webkit-box-shadow: inset 0 -2px 0 0 #d38817;
+    box-shadow: inset 0 -2px 0 0 #d38817;
 
     &:hover {
-        background: #ad1520;
+        background: #E07D0A;
+        border-color: #E07D0A;
     }
 }

+ 5 - 4
app/layout.tsx

@@ -6,7 +6,7 @@ import { SignalRProvider } from '@/contexts/signalrProvider';
 import { AuthProvider } from "@/contexts/authProvider";
 import { MemberProvider } from "@/contexts/memberProvider";
 import { ConfigProvider } from "@/contexts/configProvider";
-import { getAccessToken, getSignalRUrl } from "@/lib/utils/server";
+import { getAccessToken, getSignalRCryptoUrl, getSignalRChatUrl } from "@/lib/utils/server";
 
 const geistSans = Geist({
     variable: "--font-geist-sans",
@@ -19,7 +19,7 @@ const geistMono = Geist_Mono({
 });
 
 export const metadata: Metadata = {
-    title: "DPOT.LIVE",
+    title: "bitforum",
     description: "Generated by create next app",
     keywords: "nextjs, typescript, tailwindcss",
     robots: {
@@ -36,12 +36,13 @@ export default async function RootLayout({
 }>) {
 
 	const accessToken = await getAccessToken();
-	const signalRUrl = await getSignalRUrl();
+	const signalRCryptoUrl = await getSignalRCryptoUrl();
+	const signalRChatUrl = await getSignalRChatUrl();
 
     return (
         <html lang="ko">
             <body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
-                <SignalRProvider accessToken={accessToken} signalRUrl={signalRUrl}>
+                <SignalRProvider accessToken={accessToken} signalRCryptoUrl={signalRCryptoUrl} signalRChatUrl={signalRChatUrl}>
                     <AuthProvider>
                         <MemberProvider>
                             <ConfigProvider>

+ 5 - 0
app/loading.tsx

@@ -0,0 +1,5 @@
+import LoadHtml from '@/app/component/Loading';
+
+export default function Loading() {
+	return <LoadHtml />;
+}

+ 0 - 251
components/ChatWindow.tsx

@@ -1,251 +0,0 @@
-'use client';
-
-import { useState, useEffect, useRef } from 'react';
-import type { BroadcastInfo } from '@/types/broadcast';
-import DonationModal from './DonationModal';
-
-interface ChatMessage {
-  id: string;
-  username: string;
-  message: string;
-  timestamp: Date;
-  isStreamer?: boolean;
-  isDonation?: boolean;
-  donationAmount?: number;
-}
-
-interface ChatWindowProps {
-  broadcast: BroadcastInfo;
-}
-
-export default function ChatWindow({ broadcast }: ChatWindowProps) {
-  const [messages, setMessages] = useState<ChatMessage[]>([]);
-  const [inputMessage, setInputMessage] = useState('');
-  const [isConnected, setIsConnected] = useState(true);
-  const [isDonationModalOpen, setIsDonationModalOpen] = useState(false);
-  const chatEndRef = useRef<HTMLDivElement>(null);
-
-  // 채팅 메시지 더미 데이터
-  const dummyMessages: ChatMessage[] = [
-    {
-      id: '1',
-      username: broadcast.channel,
-      message: '안녕하세요! 방송 시작합니다 🎉',
-      timestamp: new Date(Date.now() - 300000),
-      isStreamer: true
-    },
-    {
-      id: '2',
-      username: '시청자123',
-      message: '안녕하세요~!',
-      timestamp: new Date(Date.now() - 250000)
-    },
-    {
-      id: '3',
-      username: '라이브러버',
-      message: '오늘도 재밌는 방송 부탁드려요!',
-      timestamp: new Date(Date.now() - 200000)
-    },
-    {
-      id: '4',
-      username: '후원왕',
-      message: '응원합니다! 화이팅!',
-      timestamp: new Date(Date.now() - 150000),
-      isDonation: true,
-      donationAmount: 5000
-    },
-    {
-      id: '5',
-      username: '열심팬',
-      message: 'ㅋㅋㅋㅋㅋ 재밌네요',
-      timestamp: new Date(Date.now() - 100000)
-    }
-  ];
-
-  // 실시간 채팅 시뮬레이션
-  useEffect(() => {
-    setMessages(dummyMessages);
-
-    const interval = setInterval(() => {
-      const randomMessages = [
-        '와 대박',
-        'ㅋㅋㅋㅋㅋ',
-        '재밌어요!',
-        '최고!',
-        '👏👏👏',
-        '하이~',
-        '안녕하세요',
-        '오늘 방송 재밌네요',
-        '팔로우 했어요!',
-        '응원합니다'
-      ];
-
-      const randomUsernames = [
-        '시청자' + Math.floor(Math.random() * 999),
-        '라이브팬' + Math.floor(Math.random() * 999),
-        '스트림러버' + Math.floor(Math.random() * 999),
-        '채팅왕' + Math.floor(Math.random() * 999)
-      ];
-
-      const newMessage: ChatMessage = {
-        id: Date.now().toString(),
-        username: randomUsernames[Math.floor(Math.random() * randomUsernames.length)],
-        message: randomMessages[Math.floor(Math.random() * randomMessages.length)],
-        timestamp: new Date(),
-        isDonation: Math.random() > 0.9,
-        donationAmount: Math.random() > 0.9 ? (Math.floor(Math.random() * 10) + 1) * 1000 : undefined
-      };
-
-      setMessages(prev => [...prev.slice(-20), newMessage]);
-    }, 3000 + Math.random() * 5000);
-
-    return () => clearInterval(interval);
-  }, []);
-
-  // 자동 스크롤
-  useEffect(() => {
-    chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
-  }, [messages]);
-
-  const handleSendMessage = () => {
-    if (!inputMessage.trim()) return;
-
-    const newMessage: ChatMessage = {
-      id: Date.now().toString(),
-      username: '나',
-      message: inputMessage,
-      timestamp: new Date()
-    };
-
-    setMessages(prev => [...prev, newMessage]);
-    setInputMessage('');
-  };
-
-  const handleKeyPress = (e: React.KeyboardEvent) => {
-    if (e.key === 'Enter' && !e.shiftKey) {
-      e.preventDefault();
-      handleSendMessage();
-    }
-  };
-
-  const handleDonationComplete = (donationData: {
-    amount: number;
-    message: string;
-    isAnonymous: boolean;
-    username: string;
-  }) => {
-    const donationMessage: ChatMessage = {
-      id: Date.now().toString(),
-      username: donationData.username,
-      message: donationData.message,
-      timestamp: new Date(),
-      isDonation: true,
-      donationAmount: donationData.amount
-    };
-
-    setMessages(prev => [...prev, donationMessage]);
-  };
-
-  return (
-    <div className="h-full flex flex-col bg-white border-l border-gray-200">
-      {/* 채팅 헤더 */}
-      <div className="bg-white p-4 border-b border-gray-200">
-        <div className="flex items-center justify-between">
-          <h3 className="text-gray-900 font-bold text-sm">채팅</h3>
-          <div className="flex items-center space-x-2">
-            <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`}></div>
-            <span className="text-xs text-gray-500">
-              {isConnected ? '연결됨' : '연결 끊김'}
-            </span>
-          </div>
-        </div>
-      </div>
-
-      {/* 채팅 메시지 영역 */}
-      <div className="flex-1 overflow-y-auto p-3 space-y-2 bg-gray-50">
-        {messages.map((message) => (
-          <div
-            key={message.id}
-            className={`${
-              message.isDonation
-                ? 'bg-amber-50 border border-amber-200 rounded-lg p-2'
-                : ''
-            }`}
-          >
-            {/* 후원 메시지 */}
-            {message.isDonation && (
-              <div className="flex items-center space-x-2 mb-1">
-                <span className="text-amber-600 text-xs">💰 후원</span>
-                <span className="text-amber-700 text-xs font-bold">
-                  {message.donationAmount?.toLocaleString()}원
-                </span>
-              </div>
-            )}
-
-            {/* 메시지 내용 */}
-            <div className="flex flex-col space-y-1">
-              <div className="flex items-start space-x-1">
-                <span
-                  className={`text-xs font-bold flex-shrink-0 ${
-                    message.isStreamer
-                      ? 'text-purple-600'
-                      : message.isDonation
-                      ? 'text-amber-700'
-                      : 'text-blue-600'
-                  }`}
-                >
-                  {message.username}
-                  {message.isStreamer && (
-                    <span className="ml-1 text-xs bg-purple-100 text-purple-600 px-1 rounded">스트리머</span>
-                  )}
-                </span>
-              </div>
-              <p className="text-gray-800 text-sm break-words leading-relaxed">{message.message}</p>
-            </div>
-          </div>
-        ))}
-        <div ref={chatEndRef} />
-      </div>
-
-      {/* 채팅 입력 영역 */}
-      <div className="bg-white p-3 border-t border-gray-200">
-        <div className="flex space-x-2 mb-3">
-          <input
-            type="text"
-            value={inputMessage}
-            onChange={(e) => setInputMessage(e.target.value)}
-            onKeyPress={handleKeyPress}
-            placeholder="채팅을 입력하세요..."
-            className="flex-1 bg-white border border-gray-300 text-gray-900 px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
-            maxLength={200}
-          />
-          <button
-            onClick={handleSendMessage}
-            disabled={!inputMessage.trim()}
-            className="bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-4 py-2 rounded-lg transition-colors text-sm font-medium"
-          >
-            <svg width="16" height="16" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z"/>
-            </svg>
-          </button>
-        </div>
-
-        {/* 후원하기 버튼 */}
-        <button
-          onClick={() => setIsDonationModalOpen(true)}
-          className="w-full bg-black hover:bg-gray-800 text-white font-medium py-3 px-4 rounded-lg transition-colors text-sm"
-        >
-          후원하기
-        </button>
-      </div>
-
-      {/* 후원 모달 */}
-      <DonationModal
-        isOpen={isDonationModalOpen}
-        onClose={() => setIsDonationModalOpen(false)}
-        broadcast={broadcast}
-        onDonationComplete={handleDonationComplete}
-      />
-    </div>
-  );
-}

+ 0 - 204
components/DonationModal.tsx

@@ -1,204 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import type { BroadcastInfo } from '@/types/broadcast';
-
-interface DonationModalProps {
-  isOpen: boolean;
-  onClose: () => void;
-  broadcast: BroadcastInfo;
-  onDonationComplete?: (donationData: {
-    amount: number;
-    message: string;
-    isAnonymous: boolean;
-    username: string;
-  }) => void;
-}
-
-export default function DonationModal({ isOpen, onClose, broadcast, onDonationComplete }: DonationModalProps) {
-  const [selectedAmount, setSelectedAmount] = useState<number | null>(null);
-  const [customAmount, setCustomAmount] = useState('');
-  const [message, setMessage] = useState('');
-  const [isAnonymous, setIsAnonymous] = useState(false);
-
-  const presetAmounts = [1000, 3000, 5000, 10000, 20000, 50000];
-
-  if (!isOpen) return null;
-
-  const handleDonation = () => {
-    const amount = selectedAmount || parseInt(customAmount);
-
-    // 금액 유효성 검사 강화
-    if (!amount || isNaN(amount) || amount < 1000) {
-      alert('최소 후원 금액은 1,000원입니다.');
-      return;
-    }
-
-    // 1,000원 단위로 반올림 (999원 입력 시 1,000원으로 조정)
-    const roundedAmount = Math.max(1000, Math.round(amount / 1000) * 1000);
-
-    if (roundedAmount !== amount) {
-      alert(`입력하신 금액이 ${roundedAmount.toLocaleString()}원으로 조정되었습니다.`);
-    }
-
-    // 후원 처리 로직 (실제로는 결제 API 연동)
-    const donationData = {
-      broadcastId: broadcast.id,
-      channel: broadcast.channel,
-      amount: roundedAmount,
-      message: message.trim(),
-      isAnonymous,
-      timestamp: new Date().toISOString()
-    };
-
-    console.log('후원 정보:', donationData);
-
-    // 채팅창에 후원 메시지 추가
-    if (onDonationComplete) {
-      onDonationComplete({
-        amount: roundedAmount,
-        message: message.trim() || '후원해주셔서 감사합니다!',
-        isAnonymous,
-        username: isAnonymous ? '익명' : '나'
-      });
-    }
-
-    alert(`${roundedAmount.toLocaleString()}원 후원이 완료되었습니다! 감사합니다.`);
-    onClose();
-
-    // 폼 초기화
-    setSelectedAmount(null);
-    setCustomAmount('');
-    setMessage('');
-    setIsAnonymous(false);
-  };
-
-  return (
-    <div className="fixed inset-0 bg-black/50 backdrop-blur-sm flex items-center justify-center z-50 p-4">
-      <div className="bg-gray-800 rounded-2xl max-w-md w-full max-h-[90vh] overflow-y-auto">
-        {/* 헤더 */}
-        <div className="bg-gradient-to-r from-yellow-500 to-orange-500 p-6 rounded-t-2xl">
-          <div className="flex items-center justify-between">
-            <div>
-              <h2 className="text-white text-xl font-bold">후원하기</h2>
-              <p className="text-white/80 text-sm">{broadcast.channel}님을 응원해주세요!</p>
-            </div>
-            <button
-              onClick={onClose}
-              className="text-white/80 hover:text-white transition-colors"
-            >
-              <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
-              </svg>
-            </button>
-          </div>
-        </div>
-
-        <div className="p-6 space-y-6">
-          {/* 금액 선택 */}
-          <div>
-            <h3 className="text-white font-semibold mb-3">후원 금액 선택</h3>
-            <div className="grid grid-cols-3 gap-2 mb-4">
-              {presetAmounts.map((amount) => (
-                <button
-                  key={amount}
-                  onClick={() => {
-                    setSelectedAmount(amount);
-                    setCustomAmount('');
-                  }}
-                  className={`p-3 rounded-lg border text-center transition-all ${
-                    selectedAmount === amount
-                      ? 'bg-yellow-500 border-yellow-500 text-white'
-                      : 'bg-gray-700 border-gray-600 text-gray-300 hover:border-yellow-500'
-                  }`}
-                >
-                  {amount.toLocaleString()}원
-                </button>
-              ))}
-            </div>
-
-            {/* 직접 입력 */}
-            <div>
-              <label className="block text-gray-300 text-sm mb-2">직접 입력 (1,000원 단위)</label>
-              <input
-                type="number"
-                value={customAmount}
-                onChange={(e) => {
-                  const value = e.target.value;
-                  // 1,000원 단위로만 입력 허용
-                  if (value === '' || (parseInt(value) >= 1000 && parseInt(value) % 1000 === 0)) {
-                    setCustomAmount(value);
-                    setSelectedAmount(null);
-                  }
-                }}
-                placeholder="1,000원 이상 (천원 단위)"
-                min="1000"
-                step="1000"
-                className="w-full bg-gray-700 text-white px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-yellow-500"
-              />
-              <div className="text-xs text-gray-400 mt-1">
-                * 1,000원 단위로만 입력 가능합니다
-              </div>
-            </div>
-          </div>
-
-          {/* 후원 메시지 */}
-          <div>
-            <h3 className="text-white font-semibold mb-3">후원 메시지 (선택)</h3>
-            <textarea
-              value={message}
-              onChange={(e) => setMessage(e.target.value)}
-              placeholder="스트리머에게 전할 메시지를 입력해주세요..."
-              maxLength={100}
-              rows={3}
-              className="w-full bg-gray-700 text-white px-3 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-yellow-500 resize-none"
-            />
-            <div className="text-right text-xs text-gray-400 mt-1">
-              {message.length}/100
-            </div>
-          </div>
-
-          {/* 익명 후원 옵션 */}
-          <div className="flex items-center space-x-3">
-            <input
-              type="checkbox"
-              id="anonymous"
-              checked={isAnonymous}
-              onChange={(e) => setIsAnonymous(e.target.checked)}
-              className="w-4 h-4 text-yellow-500 bg-gray-700 border-gray-600 rounded focus:ring-yellow-500"
-            />
-            <label htmlFor="anonymous" className="text-gray-300 text-sm">
-              익명으로 후원하기
-            </label>
-          </div>
-
-          {/* 후원 금액 요약 */}
-          <div className="bg-gray-700 rounded-lg p-4">
-            <div className="flex justify-between items-center text-white">
-              <span>후원 금액:</span>
-              <span className="text-xl font-bold text-yellow-400">
-                {(selectedAmount || parseInt(customAmount) || 0).toLocaleString()}원
-              </span>
-            </div>
-          </div>
-
-          {/* 후원 버튼 */}
-          <button
-            onClick={handleDonation}
-            disabled={!selectedAmount && !customAmount}
-            className="w-full bg-gradient-to-r from-yellow-500 to-orange-500 hover:from-yellow-600 hover:to-orange-600 disabled:from-gray-600 disabled:to-gray-600 text-white font-bold py-4 px-6 rounded-lg transition-all duration-200 transform hover:scale-105 disabled:transform-none disabled:cursor-not-allowed flex items-center justify-center space-x-2"
-          >
-            <span>후원하기</span>
-          </button>
-
-          {/* 주의사항 */}
-          <div className="text-xs text-gray-400 text-center space-y-1">
-            <p>• 후원은 취소가 불가능합니다.</p>
-            <p>• 최소 후원 금액은 1,000원입니다.</p>
-            <p>• 후원 메시지는 스트리머와 모든 시청자에게 공개됩니다.</p>
-          </div>
-        </div>
-      </div>
-    </div>
-  );
-}

+ 0 - 258
components/LivePlayer.module.scss

@@ -1,258 +0,0 @@
-// 라이브 플레이어 스타일
-.playerContainer {
-  width: 100%;
-  height: 100%;
-  min-height: 200px;
-  background: #f3f4f6;
-  position: relative;
-  overflow: hidden;
-
-  // 모바일: 16:9 비율 유지
-  @media (max-width: 768px) {
-    aspect-ratio: 16/9;
-    height: auto;
-  }
-
-  // 태블릿 이상: 전체 높이 사용
-  @media (min-width: 769px) {
-    height: 100%;
-  }
-}
-
-.videoPlayer {
-  width: 100%;
-  height: 100%;
-  position: relative;
-  background: #e5e7eb;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-}
-
-.playerBackground {
-  width: 100%;
-  height: 100%;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #e5e7eb 0%, #d1d5db 100%);
-  position: relative;
-}
-
-.centerIcon {
-  color: #6b7280;
-  opacity: 0.5;
-  transition: opacity 0.3s ease;
-
-  &:hover {
-    opacity: 0.7;
-  }
-}
-
-.playerOverlay {
-  position: absolute;
-  top: 0;
-  left: 0;
-  right: 0;
-  bottom: 0;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  background: transparent;
-  opacity: 0;
-  transition: opacity 0.3s ease;
-
-  &:hover {
-    opacity: 1;
-  }
-}
-
-.playButton {
-  position: absolute;
-  top: 50%;
-  left: 50%;
-  transform: translate(-50%, -50%);
-  background: rgba(0, 0, 0, 0.7);
-  border: none;
-  border-radius: 50%;
-  width: 60px;
-  height: 60px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: white;
-  cursor: pointer;
-  transition: all 0.3s ease;
-  backdrop-filter: blur(4px);
-
-  &:hover {
-    background: rgba(0, 0, 0, 0.8);
-    transform: translate(-50%, -50%) scale(1.1);
-  }
-
-  // 모바일에서 크기 조정
-  @media (max-width: 768px) {
-    width: 50px;
-    height: 50px;
-  }
-}
-
-.controlBar {
-  position: absolute;
-  bottom: 0;
-  left: 0;
-  right: 0;
-  background: linear-gradient(to top, rgba(0, 0, 0, 0.8) 0%, transparent 100%);
-  padding: 20px 16px 16px;
-  color: white;
-}
-
-.progressBar {
-  width: 100%;
-  height: 4px;
-  background: rgba(255, 255, 255, 0.3);
-  border-radius: 2px;
-  margin-bottom: 12px;
-  overflow: hidden;
-}
-
-.progressFill {
-  height: 100%;
-  background: #3b82f6;
-  border-radius: 2px;
-  transition: width 0.3s ease;
-}
-
-.controls {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  gap: 12px;
-}
-
-.leftControls,
-.rightControls {
-  display: flex;
-  align-items: center;
-  gap: 8px;
-}
-
-.controlBtn {
-  background: none;
-  border: none;
-  color: white;
-  cursor: pointer;
-  padding: 6px;
-  border-radius: 4px;
-  transition: background-color 0.2s;
-
-  &:hover {
-    background: rgba(255, 255, 255, 0.2);
-  }
-}
-
-.timeDisplay {
-  font-size: 0.875rem;
-  font-weight: 500;
-  background: rgba(220, 38, 38, 0.9);
-  padding: 2px 8px;
-  border-radius: 4px;
-  font-size: 0.8rem;
-}
-
-.topRightActions {
-  position: absolute;
-  top: 16px;
-  right: 16px;
-  display: flex;
-  gap: 8px;
-  opacity: 0;
-  transition: opacity 0.3s ease;
-
-  .playerContainer:hover & {
-    opacity: 1;
-  }
-}
-
-.actionBtn {
-  background: rgba(0, 0, 0, 0.6);
-  border: none;
-  border-radius: 50%;
-  width: 40px;
-  height: 40px;
-  display: flex;
-  align-items: center;
-  justify-content: center;
-  color: white;
-  cursor: pointer;
-  transition: all 0.2s ease;
-  backdrop-filter: blur(4px);
-
-  &:hover {
-    background: rgba(0, 0, 0, 0.8);
-    transform: scale(1.1);
-  }
-
-  // 모바일에서 크기 조정
-  @media (max-width: 768px) {
-    width: 36px;
-    height: 36px;
-  }
-}
-
-// 모바일 터치 인터페이스 최적화
-@media (max-width: 768px) {
-  .playerOverlay {
-    opacity: 1; // 모바일에서는 항상 표시
-  }
-
-  .controlBar {
-    padding: 16px 12px 12px;
-  }
-
-  .controls {
-    gap: 8px;
-  }
-
-  .leftControls,
-  .rightControls {
-    gap: 6px;
-  }
-
-  .controlBtn {
-    padding: 8px; // 터치하기 쉽게 더 큰 터치 영역
-  }
-
-  .topRightActions {
-    opacity: 1; // 모바일에서는 항상 표시
-    top: 12px;
-    right: 12px;
-    gap: 6px;
-  }
-}
-
-// 태블릿 최적화
-@media (min-width: 481px) and (max-width: 768px) {
-  .playButton {
-    width: 70px;
-    height: 70px;
-  }
-
-  .actionBtn {
-    width: 44px;
-    height: 44px;
-  }
-}
-
-// 접근성 개선
-@media (prefers-reduced-motion: reduce) {
-  .playButton,
-  .actionBtn,
-  .progressFill {
-    transition: none;
-  }
-
-  .playerOverlay {
-    transition: none;
-  }
-}

+ 0 - 79
components/LivePlayer.tsx

@@ -1,79 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import type { BroadcastInfo } from '@/types/broadcast';
-import styles from './LivePlayer.module.scss';
-
-interface LivePlayerProps {
-  broadcast: BroadcastInfo;
-}
-
-export default function LivePlayer({ broadcast }: LivePlayerProps) {
-  const [isPlaying, setIsPlaying] = useState(true);
-
-  return (
-    <div className={styles.playerContainer}>
-      {/* 비디오 플레이어 영역 */}
-      <div className={styles.videoPlayer}>
-        {/* 플레이어 배경 */}
-        <div className={styles.playerBackground}>
-          {/* 중앙 비디오 아이콘 */}
-          <div className={styles.centerIcon}>
-            <svg width="48" height="48" fill="currentColor" viewBox="0 0 24 24">
-              <path d="M15 8v8H5V8h10m1-2H4a1 1 0 00-1 1v10a1 1 0 001 1h12a1 1 0 001-1V7a1 1 0 00-1-1z"/>
-              <path d="M18 6h2a1 1 0 011 1v10a1 1 0 01-1 1h-2"/>
-            </svg>
-          </div>
-        </div>
-
-        {/* 플레이어 오버레이 컨트롤 */}
-        <div className={styles.playerOverlay}>
-          {/* 재생/일시정지 버튼 (중앙) */}
-          <button
-            className={styles.playButton}
-            onClick={() => setIsPlaying(!isPlaying)}
-          >
-            {isPlaying ? (
-              <svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
-                <path d="M6 4h4v16H6V4zm8 0h4v16h-4V4z"/>
-              </svg>
-            ) : (
-              <svg width="24" height="24" fill="currentColor" viewBox="0 0 24 24">
-                <path d="M8 5v14l11-7z"/>
-              </svg>
-            )}
-          </button>
-
-          {/* 하단 컨트롤 바 */}
-          <div className={styles.controlBar}>
-            {/* 진행 바 */}
-            <div className={styles.progressBar}>
-              <div className={styles.progressFill} style={{ width: '35%' }}></div>
-            </div>
-
-            {/* 컨트롤 버튼들 */}
-            <div className={styles.controls}>
-              <div className={styles.leftControls}>
-                <button className={styles.controlBtn}>
-                  <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
-                    <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/>
-                  </svg>
-                </button>
-                <span className={styles.timeDisplay}>LIVE</span>
-              </div>
-
-              <div className={styles.rightControls}>
-                <button className={styles.controlBtn}>
-                  <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
-                    <path d="M19 12h-2v3h-3v2h5v-5zM7 9h3V7H5v5h2V9zm14-6H3c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2z"/>
-                  </svg>
-                </button>
-              </div>
-            </div>
-          </div>
-        </div>
-
-      </div>
-    </div>
-  );
-}

+ 0 - 58
components/broadcast/BroadcastCard.tsx

@@ -1,58 +0,0 @@
-"use client";
-
-import Link from "next/link";
-import type { BroadcastInfo } from "@/types/broadcast";
-import HotIndicator from "@/components/ui/HotIndicator";
-import styles from "./styles/BroadcastCard.module.scss";
-
-interface BroadcastCardProps {
-    broadcast: BroadcastInfo;
-    variant?: "carousel" | "grid";
-}
-
-export default function BroadcastCard({ broadcast, variant = "grid" }: BroadcastCardProps) {
-    const cardClass = variant === "carousel" ? styles["carousel-card"] : styles["video-card"];
-    const thumbnailClass =
-        variant === "carousel" ? styles["carousel-thumbnail"] : styles["video-thumbnail"];
-    const infoClass = variant === "carousel" ? styles["carousel-info"] : styles["video-info"];
-    const titleClass = variant === "carousel" ? styles["carousel-title"] : styles["video-title"];
-    const channelClass =
-        variant === "carousel" ? styles["carousel-channel"] : styles["video-channel"];
-
-    return (
-        <Link href={`/live/${broadcast.id}`} className={cardClass}>
-            <div className={thumbnailClass}>
-                <div className={styles["placeholder-thumbnail"]}>{broadcast.title}</div>
-                <div className={styles["live-indicator"]}>🔴 LIVE</div>
-                {variant === "carousel" && (
-                    <>
-                        <div className={styles["viewer-count"]}>
-                            👥 {broadcast.viewerCount.toLocaleString()}
-                        </div>
-                        {/* <div className={styles['hot-indicator']}>
-              <HotIndicator />
-            </div> */}
-                    </>
-                )}
-            </div>
-
-            <div className={infoClass}>
-                <h3 className={titleClass}>{broadcast.title}</h3>
-                {variant === "carousel" ? (
-                    <p className={channelClass}>{broadcast.channel}</p>
-                ) : (
-                    <div className={styles["video-meta"]}>
-                        <div className={channelClass}>{broadcast.channel}</div>
-                        <div className={styles["video-stats"]}>
-                            <span className={styles["viewers"]}>
-                                👥 {broadcast.viewerCount.toLocaleString()}명 시청중
-                            </span>
-                            <span className={styles["category"]}>#{broadcast.category}</span>
-                            <span className={styles["live-status"]}>방송 중</span>
-                        </div>
-                    </div>
-                )}
-            </div>
-        </Link>
-    );
-}

+ 0 - 40
components/broadcast/LiveSection.tsx

@@ -1,40 +0,0 @@
-'use client';
-
-import type { BroadcastInfo } from '@/types/broadcast';
-import BroadcastCard from './BroadcastCard';
-import styles from './styles/BroadcastSection.module.scss';
-
-interface LiveSectionProps {
-  broadcasts: BroadcastInfo[];
-  isLoading?: boolean;
-}
-
-export default function LiveSection({ broadcasts, isLoading = false }: LiveSectionProps) {
-  return (
-    <section className={styles['live-section']}>
-      <h2 className={styles['section-title']}>📺 실시간 LIVE</h2>
-      <div className={styles['video-grid']}>
-        {isLoading ? (
-          // 로딩 스켈레톤 (8개)
-          Array.from({ length: 8 }).map((_, index) => (
-            <div key={`skeleton-${index}`} className={styles['skeleton-card']}>
-              <div className={styles['skeleton-thumbnail']}></div>
-              <div className={styles['skeleton-info']}>
-                <div className={styles['skeleton-text']}></div>
-                <div className={styles['skeleton-text-small']}></div>
-              </div>
-            </div>
-          ))
-        ) : (
-          broadcasts.map((broadcast) => (
-            <BroadcastCard
-              key={broadcast.id}
-              broadcast={broadcast}
-              variant="grid"
-            />
-          ))
-        )}
-      </div>
-    </section>
-  );
-}

+ 0 - 42
components/broadcast/PopularSection.tsx

@@ -1,42 +0,0 @@
-'use client';
-
-import type { BroadcastInfo } from '@/types/broadcast';
-import BroadcastCard from './BroadcastCard';
-import styles from './styles/BroadcastSection.module.scss';
-
-interface PopularSectionProps {
-  broadcasts: BroadcastInfo[];
-  isLoading?: boolean;
-}
-
-export default function PopularSection({ broadcasts, isLoading = false }: PopularSectionProps) {
-  return (
-    <section className={styles['popular-section']}>
-      <h2 className={styles['section-title']}>🔥 인기 LIVE</h2>
-      <div className={styles['carousel-container']}>
-        <div className={styles['carousel-track']}>
-          {isLoading ? (
-            // 로딩 스켈레톤 (5개)
-            Array.from({ length: 5 }).map((_, index) => (
-              <div key={`skeleton-${index}`} className={styles['skeleton-card']}>
-                <div className={styles['skeleton-thumbnail']}></div>
-                <div className={styles['skeleton-info']}>
-                  <div className={styles['skeleton-text']}></div>
-                  <div className={styles['skeleton-text-small']}></div>
-                </div>
-              </div>
-            ))
-          ) : (
-            broadcasts.map((broadcast) => (
-              <BroadcastCard
-                key={broadcast.id}
-                broadcast={broadcast}
-                variant="carousel"
-              />
-            ))
-          )}
-        </div>
-      </div>
-    </section>
-  );
-}

+ 0 - 3
components/broadcast/index.ts

@@ -1,3 +0,0 @@
-export { default as BroadcastCard } from './BroadcastCard';
-export { default as PopularSection } from './PopularSection';
-export { default as LiveSection } from './LiveSection';

+ 0 - 251
components/broadcast/styles/BroadcastCard.module.scss

@@ -1,251 +0,0 @@
-// 캐러셀 카드
-.carousel-card {
-  flex: 0 0 300px;
-  background: white;
-  border-radius: 12px;
-  overflow: hidden;
-  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-  transition: transform 0.3s ease, box-shadow 0.3s ease;
-  cursor: pointer;
-  display: block;
-  text-decoration: none;
-  color: inherit;
-
-  &:hover {
-    transform: translateY(-4px);
-    box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
-  }
-
-  // 태블릿에서 캐러셀 카드 크기 조정
-  @media (min-width: 481px) and (max-width: 768px) {
-    flex: 0 0 280px;
-  }
-
-  // 데스크톱에서 캐러셀 카드 크기 조정
-  @media (min-width: 769px) {
-    flex: 0 0 320px;
-  }
-
-  @media (max-width: 480px) {
-    flex: 0 0 250px;
-  }
-}
-
-.carousel-thumbnail {
-  position: relative;
-  width: 100%;
-  height: 0;
-  padding-bottom: 56.25%;
-  overflow: hidden;
-  background: #f8fafc;
-
-  .viewer-count {
-    position: absolute;
-    top: 0.5rem;
-    right: 0.5rem;
-    background: rgba(0, 0, 0, 0.7);
-    color: white;
-    padding: 0.25rem 0.5rem;
-    border-radius: 4px;
-    font-size: 0.7rem;
-    font-weight: bold;
-  }
-
-  .hot-indicator {
-    position: absolute;
-    bottom: 0.5rem;
-    right: 0.5rem;
-
-    @media (max-width: 480px) {
-      bottom: 0.4rem;
-      right: 0.4rem;
-      transform: scale(0.8);
-    }
-  }
-}
-
-.carousel-info {
-  padding: 1rem;
-}
-
-.carousel-title {
-  font-size: 0.9rem;
-  font-weight: 600;
-  color: #1f2937;
-  margin: 0 0 0.5rem 0;
-  line-height: 1.3;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-}
-
-.carousel-channel {
-  font-size: 0.8rem;
-  color: #6b7280;
-  margin: 0;
-  font-weight: 500;
-}
-
-// 비디오 카드
-.video-card {
-  border: 1px solid #e5e7eb;
-  border-radius: 12px;
-  overflow: hidden;
-  transition: all 0.3s ease;
-  background: white;
-  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
-  cursor: pointer;
-  display: block;
-  text-decoration: none;
-  color: inherit;
-
-  &:hover {
-    transform: translateY(-6px);
-    box-shadow: 0 12px 32px rgba(0, 0, 0, 0.15);
-    border-color: #d1d5db;
-  }
-
-  // 모바일에서 전체 너비 사용
-  @media (max-width: 479px) {
-    width: 100%;
-    max-width: 100%;
-    box-sizing: border-box;
-  }
-}
-
-.video-thumbnail {
-  position: relative;
-  width: 100%;
-  height: 0;
-  padding-bottom: 56.25%; // 16:9 비율
-  overflow: hidden;
-  background: #f8fafc;
-}
-
-// 공통 placeholder 스타일
-.placeholder-thumbnail {
-  position: absolute;
-  top: 0;
-  left: 0;
-  width: 100%;
-  height: 100%;
-  display: flex;
-  flex-direction: column;
-  align-items: center;
-  justify-content: center;
-  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
-  color: white;
-  font-weight: 600;
-  font-size: 0.9rem;
-  text-align: center;
-  padding: 1rem;
-
-  &::before {
-    content: "▶";
-    font-size: 1.5rem;
-    margin-bottom: 0.5rem;
-    opacity: 0.8;
-  }
-}
-
-// 공통 LIVE 인디케이터
-.live-indicator {
-  position: absolute;
-  top: 0.5rem;
-  left: 0.5rem;
-  background: rgba(255, 0, 0, 0.9);
-  color: white;
-  padding: 0.25rem 0.5rem;
-  border-radius: 4px;
-  font-size: 0.7rem;
-  font-weight: bold;
-  animation: livePulse 2s infinite;
-
-  @media (min-width: 481px) {
-    top: 0.75rem;
-    left: 0.75rem;
-    font-size: 0.75rem;
-  }
-}
-
-.video-info {
-  padding: 1.2rem;
-
-  // 모바일에서 패딩 조정
-  @media (max-width: 480px) {
-    padding: 1rem;
-  }
-}
-
-.video-title {
-  margin: 0 0 0.5rem 0;
-  font-size: 1.1rem;
-  font-weight: 600;
-  color: #1f2937;
-  line-height: 1.4;
-  display: -webkit-box;
-  -webkit-line-clamp: 2;
-  -webkit-box-orient: vertical;
-  overflow: hidden;
-
-  // 모바일에서 폰트 크기 조정
-  @media (max-width: 480px) {
-    font-size: 1rem;
-    line-height: 1.3;
-  }
-}
-
-.video-meta {
-  display: flex;
-  flex-direction: column;
-  gap: 0.25rem;
-  color: #6b7280;
-  font-size: 0.875rem;
-
-  // 모바일에서 폰트 크기 조정
-  @media (max-width: 480px) {
-    font-size: 0.8rem;
-    gap: 0.2rem;
-  }
-}
-
-.video-channel {
-  font-weight: 500;
-  color: #4b5563;
-}
-
-.video-stats {
-  display: flex;
-  flex-direction: column;
-  gap: 0.25rem;
-
-  .viewers {
-    display: flex;
-    align-items: center;
-    gap: 0.25rem;
-    color: #dc2626;
-    font-weight: 600;
-  }
-
-  .category {
-    color: #7c3aed;
-    font-weight: 500;
-    font-size: 0.8rem;
-  }
-
-  .live-status {
-    color: #16a34a;
-    font-weight: 500;
-    font-size: 0.8rem;
-  }
-}
-
-@keyframes livePulse {
-  0%, 100% {
-    opacity: 1;
-  }
-  50% {
-    opacity: 0.7;
-  }
-}

+ 0 - 151
components/broadcast/styles/BroadcastSection.module.scss

@@ -1,151 +0,0 @@
-// 섹션 제목 스타일
-.section-title {
-  font-size: 1.5rem;
-  font-weight: bold;
-  margin-bottom: 1.5rem;
-  color: #1f2937;
-  display: flex;
-  align-items: center;
-  gap: 0.5rem;
-}
-
-// 인기 LIVE 섹션
-.popular-section {
-  margin-bottom: 3rem;
-}
-
-// 캐러셀 컨테이너
-.carousel-container {
-  position: relative;
-  overflow: visible;
-  border-radius: 12px;
-
-  @media (max-width: 480px) {
-    overflow: hidden;
-  }
-}
-
-.carousel-track {
-  display: flex;
-  gap: 1rem;
-  overflow-x: auto;
-  scroll-behavior: smooth;
-  padding: 0.5rem 0;
-
-  // 스크롤바 숨기기
-  &::-webkit-scrollbar {
-    display: none;
-  }
-  -ms-overflow-style: none;
-  scrollbar-width: none;
-
-  // 모바일에서 적절한 패딩 추가
-  @media (max-width: 480px) {
-    padding: 0.5rem;
-    gap: 0.8rem;
-    overflow-x: scroll;
-  }
-}
-
-// 실시간 LIVE 섹션
-.live-section {
-  margin-top: 2rem;
-}
-
-// 비디오 그리드 스타일 (무한스크롤 대응)
-.video-grid {
-  display: flex;
-  flex-direction: column;
-  gap: 1.5rem;
-  margin: 0 auto;
-  width: 100%;
-  max-width: 100%;
-  box-sizing: border-box;
-
-  // 태블릿: 2개씩 (481px ~ 768px)
-  @media (min-width: 481px) and (max-width: 768px) {
-    display: grid;
-    grid-template-columns: repeat(2, 1fr);
-    gap: 1.2rem;
-    padding: 0;
-    max-width: 100%;
-  }
-
-  // 데스크톱: 3개씩 (769px 이상)
-  @media (min-width: 769px) {
-    display: grid;
-    grid-template-columns: repeat(3, 1fr);
-    gap: 1.5rem;
-    padding: 0;
-    max-width: 1000px;
-  }
-
-  // 모바일: 세로 스크롤 (480px 미만)
-  @media (max-width: 479px) {
-    gap: 1rem;
-    padding: 0;
-  }
-}
-
-// 스켈레톤 로딩 스타일
-.skeleton-card {
-  display: flex;
-  flex-direction: column;
-  background: #f8f9fa;
-  border-radius: 12px;
-  overflow: hidden;
-  min-width: 280px;
-  height: 220px;
-  position: relative;
-
-  @media (max-width: 480px) {
-    min-width: 250px;
-    height: 200px;
-  }
-}
-
-.skeleton-thumbnail {
-  width: 100%;
-  height: 160px;
-  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
-  background-size: 200% 100%;
-  animation: skeleton-loading 1.5s infinite;
-
-  @media (max-width: 480px) {
-    height: 140px;
-  }
-}
-
-.skeleton-info {
-  padding: 12px;
-  display: flex;
-  flex-direction: column;
-  gap: 8px;
-}
-
-.skeleton-text {
-  height: 16px;
-  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
-  background-size: 200% 100%;
-  animation: skeleton-loading 1.5s infinite;
-  border-radius: 4px;
-  width: 80%;
-}
-
-.skeleton-text-small {
-  height: 12px;
-  background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
-  background-size: 200% 100%;
-  animation: skeleton-loading 1.5s infinite;
-  border-radius: 4px;
-  width: 60%;
-}
-
-@keyframes skeleton-loading {
-  0% {
-    background-position: -200% 0;
-  }
-  100% {
-    background-position: 200% 0;
-  }
-}

+ 0 - 186
components/cash/AlertModal.tsx

@@ -1,186 +0,0 @@
-"use client";
-
-import { useEffect } from "react";
-
-interface AlertModalProps {
-    isOpen: boolean;
-    onClose: () => void;
-    title?: string;
-    message: string;
-    type?: "info" | "success" | "warning" | "error";
-    confirmText?: string;
-    showCancel?: boolean;
-    cancelText?: string;
-    onConfirm?: () => void;
-    onCancel?: () => void;
-}
-
-export default function AlertModal({
-    isOpen,
-    onClose,
-    title,
-    message,
-    type = "info",
-    confirmText = "확인",
-    showCancel = false,
-    cancelText = "취소",
-    onConfirm,
-    onCancel,
-}: AlertModalProps) {
-    useEffect(() => {
-        const handleEscape = (event: KeyboardEvent) => {
-            if (event.key === "Escape") {
-                onClose();
-            }
-        };
-
-        if (isOpen) {
-            document.addEventListener("keydown", handleEscape);
-            document.body.style.overflow = "hidden";
-        }
-
-        return () => {
-            document.removeEventListener("keydown", handleEscape);
-            document.body.style.overflow = "unset";
-        };
-    }, [isOpen, onClose]);
-
-    const handleConfirm = () => {
-        if (onConfirm) {
-            onConfirm();
-        } else {
-            onClose();
-        }
-    };
-
-    const handleCancel = () => {
-        if (onCancel) {
-            onCancel();
-        } else {
-            onClose();
-        }
-    };
-
-    const getIcon = () => {
-        switch (type) {
-            case "success":
-                return (
-                    <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-green-100">
-                        <svg className="h-6 w-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7" />
-                        </svg>
-                    </div>
-                );
-            case "warning":
-                return (
-                    <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-yellow-100">
-                        <svg className="h-6 w-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                            <path
-                                strokeLinecap="round"
-                                strokeLinejoin="round"
-                                strokeWidth="2"
-                                d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"
-                            />
-                        </svg>
-                    </div>
-                );
-            case "error":
-                return (
-                    <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-red-100">
-                        <svg className="h-6 w-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                            <path
-                                strokeLinecap="round"
-                                strokeLinejoin="round"
-                                strokeWidth="2"
-                                d="M6 18L18 6M6 6l12 12"
-                            />
-                        </svg>
-                    </div>
-                );
-            default:
-                return (
-                    <div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-blue-100">
-                        <svg className="h-6 w-6 text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
-                            <path
-                                strokeLinecap="round"
-                                strokeLinejoin="round"
-                                strokeWidth="2"
-                                d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
-                            />
-                        </svg>
-                    </div>
-                );
-        }
-    };
-
-    const getButtonColor = () => {
-        switch (type) {
-            case "success":
-                return "bg-green-600 hover:bg-green-700 focus:ring-green-500";
-            case "warning":
-                return "bg-yellow-600 hover:bg-yellow-700 focus:ring-yellow-500";
-            case "error":
-                return "bg-red-600 hover:bg-red-700 focus:ring-red-500";
-            default:
-                return "bg-blue-600 hover:bg-blue-700 focus:ring-blue-500";
-        }
-    };
-
-    if (!isOpen) return null;
-
-    return (
-        <div
-            className="fixed inset-0 z-50 overflow-y-auto"
-            aria-labelledby="modal-title"
-            role="dialog"
-            aria-modal="true"
-        >
-            <div className="flex min-h-screen items-end justify-center px-4 pt-4 pb-20 text-center sm:block sm:p-0">
-                <div className="bg-opacity-75 fixed inset-0 bg-gray-500 transition-opacity" onClick={onClose}></div>
-
-                <span className="hidden sm:inline-block sm:h-screen sm:align-middle" aria-hidden="true">
-                    &#8203;
-                </span>
-
-                <div className="relative inline-block transform overflow-hidden rounded-lg bg-white text-left align-bottom shadow-xl transition-all sm:my-8 sm:w-full sm:max-w-lg sm:align-middle">
-                    <div className="bg-white px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
-                        <div className="sm:flex sm:items-start">
-                            <div className="mt-3 w-full text-center sm:mt-0 sm:text-left">
-                                {getIcon()}
-                                {title && (
-                                    <h3
-                                        className="mb-2 text-center text-lg leading-6 font-medium text-gray-900"
-                                        id="modal-title"
-                                    >
-                                        {title}
-                                    </h3>
-                                )}
-                                <div className="mt-2">
-                                    <p className="text-center text-sm whitespace-pre-line text-gray-500">{message}</p>
-                                </div>
-                            </div>
-                        </div>
-                    </div>
-                    <div className="bg-gray-50 px-4 py-3 sm:flex sm:flex-row-reverse sm:px-6">
-                        <button
-                            type="button"
-                            className={`inline-flex w-full justify-center rounded-md border border-transparent px-4 py-2 text-base font-medium text-white shadow-sm focus:ring-2 focus:ring-offset-2 focus:outline-none sm:ml-3 sm:w-auto sm:text-sm ${getButtonColor()}`}
-                            onClick={handleConfirm}
-                        >
-                            {confirmText}
-                        </button>
-                        {showCancel && (
-                            <button
-                                type="button"
-                                className="mt-3 inline-flex w-full justify-center rounded-md border border-gray-300 bg-white px-4 py-2 text-base font-medium text-gray-700 shadow-sm hover:bg-gray-50 focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 focus:outline-none sm:mt-0 sm:ml-3 sm:w-auto sm:text-sm"
-                                onClick={handleCancel}
-                            >
-                                {cancelText}
-                            </button>
-                        )}
-                    </div>
-                </div>
-            </div>
-        </div>
-    );
-}

+ 0 - 84
components/cash/AlertSystem.tsx

@@ -1,84 +0,0 @@
-"use client";
-
-import { useState, useCallback } from "react";
-import type { AlertOptions, AlertState } from "@/types/cash-charge";
-import AlertModal from "./AlertModal";
-
-export function useAlert() {
-    const [alertState, setAlertState] = useState<AlertState>({
-        isOpen: false,
-        message: "",
-        type: "info",
-    });
-
-    const showAlert = useCallback((options: AlertOptions) => {
-        setAlertState({
-            ...options,
-            isOpen: true,
-        });
-    }, []);
-
-    const hideAlert = useCallback(() => {
-        setAlertState(prev => ({ ...prev, isOpen: false }));
-    }, []);
-
-    const alert = useCallback(
-        (message: string, type: AlertOptions["type"] = "info") => {
-            showAlert({ message, type });
-        },
-        [showAlert]
-    );
-
-    const confirm = useCallback(
-        (message: string, onConfirm?: () => void, onCancel?: () => void) => {
-            return new Promise<boolean>(resolve => {
-                showAlert({
-                    message,
-                    type: "warning",
-                    showCancel: true,
-                    onConfirm: () => {
-                        if (onConfirm) onConfirm();
-                        resolve(true);
-                        hideAlert();
-                    },
-                    onCancel: () => {
-                        if (onCancel) onCancel();
-                        resolve(false);
-                        hideAlert();
-                    },
-                });
-            });
-        },
-        [showAlert, hideAlert]
-    );
-
-    return {
-        alertState,
-        showAlert,
-        hideAlert,
-        alert,
-        confirm,
-    };
-}
-
-interface AlertSystemProps {
-    alertState: AlertState;
-    onHideAlert: () => void;
-}
-
-export function AlertSystem({ alertState, onHideAlert }: AlertSystemProps) {
-    return (
-        <AlertModal
-            isOpen={alertState.isOpen}
-            onClose={onHideAlert}
-            title={alertState.title}
-            message={alertState.message}
-            type={alertState.type}
-            confirmText={alertState.confirmText}
-            showCancel={alertState.showCancel}
-            cancelText={alertState.cancelText}
-            onConfirm={alertState.onConfirm}
-            onCancel={alertState.onCancel}
-        />
-    );
-}

+ 0 - 30
components/live/LiveLayout.tsx

@@ -1,30 +0,0 @@
-'use client';
-
-import type { BroadcastInfo } from '@/types/broadcast';
-import LiveNavbar from './LiveNavbar';
-import LiveVideoSection from './LiveVideoSection';
-import ChatWindow from '@/components/ChatWindow';
-import styles from './styles/LiveLayout.module.scss';
-
-interface LiveLayoutProps {
-  broadcast: BroadcastInfo;
-}
-
-export default function LiveLayout({ broadcast }: LiveLayoutProps) {
-  return (
-    <div className={styles.liveContainer}>
-      <LiveNavbar />
-
-      {/* 메인 콘텐츠 영역 */}
-      <div className={styles.mainContent}>
-        {/* 영상 영역 */}
-        <LiveVideoSection broadcast={broadcast} />
-
-        {/* 채팅 영역 */}
-        <div className={styles.chatSection}>
-          <ChatWindow broadcast={broadcast} />
-        </div>
-      </div>
-    </div>
-  );
-}

+ 0 - 50
components/live/LiveNavbar.tsx

@@ -1,50 +0,0 @@
-'use client';
-
-import styles from './styles/LiveNavbar.module.scss';
-
-export default function LiveNavbar() {
-  return (
-    <header className={styles.navbar}>
-      <div className={styles.navLeft}>
-        <button
-          className={styles.hamburgerBtn}
-          onClick={() => window.history.back()}
-        >
-          <svg
-            width="24"
-            height="24"
-            fill="none"
-            stroke="currentColor"
-            viewBox="0 0 24 24"
-          >
-            <path
-              strokeLinecap="round"
-              strokeLinejoin="round"
-              strokeWidth={2}
-              d="M15 19l-7-7 7-7"
-            />
-          </svg>
-        </button>
-      </div>
-
-      <div className={styles.navCenter}>
-        <div className={styles.logo}>
-          <span>DPOT LIVE</span>
-        </div>
-      </div>
-
-      <div className={styles.navRight}>
-        <button className={styles.navIcon}>
-          <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
-            <path d="M12 22c1.1 0 2-.9 2-2h-4c0 1.1.89 2 2 2zm6-6v-5c0-3.07-1.64-5.64-4.5-6.32V4c0-.83-.67-1.5-1.5-1.5s-1.5.67-1.5 1.5v.68C7.63 5.36 6 7.92 6 11v5l-2 2v1h16v-1l-2-2z" />
-          </svg>
-        </button>
-        <button className={styles.navIcon}>
-          <svg width="20" height="20" fill="currentColor" viewBox="0 0 24 24">
-            <path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z" />
-          </svg>
-        </button>
-      </div>
-    </header>
-  );
-}

+ 0 - 27
components/live/LiveVideoSection.tsx

@@ -1,27 +0,0 @@
-'use client';
-
-import type { BroadcastInfo } from '@/types/broadcast';
-import LivePlayer from '@/components/LivePlayer';
-import StreamerInfoSection from './StreamerInfoSection';
-import styles from './styles/LiveVideoSection.module.scss';
-
-interface LiveVideoSectionProps {
-  broadcast: BroadcastInfo;
-}
-
-export default function LiveVideoSection({ broadcast }: LiveVideoSectionProps) {
-  return (
-    <div className={styles.videoSection}>
-      {/* 비디오 플레이어 (16:9 고정) */}
-      <div className={styles.videoPlayerWrapper}>
-        <LivePlayer broadcast={broadcast} />
-      </div>
-
-      {/* 스트리머 정보 섹션 */}
-      <StreamerInfoSection broadcast={broadcast} />
-
-      {/* 빈 공간 컨테이너 (태블릿 이상에서 남는 영역) */}
-      <div className={styles.emptySpace}></div>
-    </div>
-  );
-}

+ 0 - 75
components/live/StreamerInfoSection.tsx

@@ -1,75 +0,0 @@
-'use client';
-
-import type { BroadcastInfo } from '@/types/broadcast';
-import styles from './styles/StreamerInfoSection.module.scss';
-
-interface StreamerInfoSectionProps {
-  broadcast: BroadcastInfo;
-}
-
-export default function StreamerInfoSection({ broadcast }: StreamerInfoSectionProps) {
-  return (
-    <>
-      {/* 제목 영역 */}
-      <div className={styles.titleSection}>
-        <h1 className={styles.streamTitle}>{broadcast.title}</h1>
-      </div>
-
-      {/* 스트리머 정보 + 액션 버튼 영역 */}
-      <div className={styles.infoActionsContainer}>
-        <div className={styles.streamerInfo}>
-          <div className={styles.streamerAvatar}>
-            <div className={styles.avatarPlaceholder}>
-              {broadcast.channel.charAt(0).toUpperCase()}
-            </div>
-          </div>
-          <div className={styles.streamerDetails}>
-            <div className={styles.streamerName}>
-              bj {broadcast.channel}
-            </div>
-            <div className={styles.viewerCount}>
-              <span>👥 {broadcast.viewerCount.toLocaleString()}</span>
-            </div>
-          </div>
-        </div>
-
-        <div className={styles.videoActions}>
-          <div className={styles.actionButtons}>
-            <button className={styles.bookmarkBtn}>
-              <svg
-                width="20"
-                height="20"
-                fill="none"
-                stroke="currentColor"
-                viewBox="0 0 24 24"
-              >
-                <path
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                  strokeWidth={2}
-                  d="M5 5a2 2 0 012-2h10a2 2 0 012 2v16l-7-3.5L5 21V5z"
-                />
-              </svg>
-            </button>
-            <button className={styles.shareBtn}>
-              <svg
-                width="20"
-                height="20"
-                fill="none"
-                stroke="currentColor"
-                viewBox="0 0 24 24"
-              >
-                <path
-                  strokeLinecap="round"
-                  strokeLinejoin="round"
-                  strokeWidth={2}
-                  d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.367 2.684 3 3 0 00-5.367-2.684z"
-                />
-              </svg>
-            </button>
-          </div>
-        </div>
-      </div>
-    </>
-  );
-}

+ 0 - 4
components/live/index.ts

@@ -1,4 +0,0 @@
-export { default as LiveLayout } from './LiveLayout';
-export { default as LiveNavbar } from './LiveNavbar';
-export { default as LiveVideoSection } from './LiveVideoSection';
-export { default as StreamerInfoSection } from './StreamerInfoSection';

+ 0 - 53
components/live/styles/LiveLayout.module.scss

@@ -1,53 +0,0 @@
-// 라이브 상세보기 페이지 스타일
-.liveContainer {
-  display: flex;
-  flex-direction: column;
-  height: 100vh;
-  background-color: #f5f5f5;
-  overflow: hidden;
-}
-
-// 메인 콘텐츠 영역
-.mainContent {
-  display: flex;
-  flex: 1;
-  overflow: hidden;
-
-  // 모바일: 세로 스택
-  @media (max-width: 768px) {
-    flex-direction: column;
-  }
-
-  // 태블릿 이상: 가로 배치
-  @media (min-width: 769px) {
-    flex-direction: row;
-  }
-}
-
-// 채팅 섹션
-.chatSection {
-  // 모바일: 전체 너비, 남은 공간 차지
-  @media (max-width: 768px) {
-    flex: 1;
-    width: 100%;
-    min-height: 0;
-  }
-
-  // 태블릿 이상: 고정 너비 사이드바
-  @media (min-width: 769px) {
-    width: 350px;
-    flex-shrink: 0;
-  }
-
-  // 데스크톱: 더 넓은 채팅창
-  @media (min-width: 1200px) {
-    width: 400px;
-  }
-}
-
-// 다크 모드 대응 (옵션)
-@media (prefers-color-scheme: dark) {
-  .liveContainer {
-    background-color: #1f2937;
-  }
-}

+ 0 - 93
components/live/styles/LiveNavbar.module.scss

@@ -1,93 +0,0 @@
-// 상단 네비게이션 바
-.navbar {
-  display: flex;
-  align-items: center;
-  justify-content: space-between;
-  padding: 12px 16px;
-  background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%);
-  color: white;
-  max-height: 40px;
-  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
-
-  .navLeft {
-    display: flex;
-    align-items: center;
-    flex: 1;
-
-    .hamburgerBtn {
-      background: none;
-      border: none;
-      color: white;
-      cursor: pointer;
-      padding: 8px;
-      border-radius: 4px;
-      transition: background-color 0.2s;
-
-      &:hover {
-        background-color: rgba(255, 255, 255, 0.1);
-      }
-    }
-  }
-
-  .navCenter {
-    display: flex;
-    justify-content: center;
-    flex: 1;
-
-    .logo {
-      display: flex;
-      align-items: center;
-      gap: 8px;
-      font-weight: bold;
-      font-size: 1.1rem;
-
-      span:first-child {
-        font-size: 1.2rem;
-      }
-    }
-  }
-
-  .navRight {
-    display: flex;
-    align-items: center;
-    flex: 1;
-    justify-content: flex-end;
-
-    .navIcon {
-      background: none;
-      border: none;
-      color: white;
-      cursor: pointer;
-      padding: 8px;
-      border-radius: 4px;
-      transition: background-color 0.2s;
-
-      &:hover {
-        background-color: rgba(255, 255, 255, 0.1);
-      }
-    }
-  }
-}
-
-// 모바일 최적화
-@media (max-width: 480px) {
-  .navbar {
-    padding: 10px 12px;
-    max-height: 40px;
-
-    .logo {
-      font-size: 1rem;
-    }
-
-    .navIcon {
-      padding: 6px;
-    }
-  }
-}
-
-// 태블릿 최적화
-@media (min-width: 481px) and (max-width: 768px) {
-  .navbar {
-    padding: 14px 20px;
-  }
-}

+ 0 - 50
components/live/styles/LiveVideoSection.module.scss

@@ -1,50 +0,0 @@
-// 영상 섹션
-.videoSection {
-  flex: 1;
-  display: flex;
-  flex-direction: column;
-  background: white;
-
-  // 모바일
-  @media (max-width: 768px) {
-    flex: none;
-    height: auto;
-  }
-
-  // 태블릿 이상
-  @media (min-width: 769px) {
-    border-right: 1px solid #e5e7eb;
-  }
-}
-
-// 비디오 플레이어 래퍼
-.videoPlayerWrapper {
-  width: 100%;
-  aspect-ratio: 16/9;
-  background: #f3f4f6;
-
-  // 모바일: 자동 높이 (16:9 비율 유지)
-  @media (max-width: 768px) {
-    height: auto;
-  }
-
-  // 태블릿 이상: 16:9 비율로 고정
-  @media (min-width: 769px) {
-    flex-shrink: 0;
-  }
-}
-
-// 빈 공간 컨테이너
-.emptySpace {
-  background: #ffffff;
-  // 모바일에서는 표시하지 않음
-  @media (max-width: 768px) {
-    display: none;
-  }
-
-  // 태블릿 이상에서는 남은 공간 차지
-  @media (min-width: 769px) {
-    flex: 1;
-    min-height: 0;
-  }
-}

+ 0 - 139
components/live/styles/StreamerInfoSection.module.scss

@@ -1,139 +0,0 @@
-// 제목 영역
-.titleSection {
-  padding: 16px;
-  background: white;
-  border-bottom: 1px solid #f3f4f6;
-
-  .streamTitle {
-    font-size: 1.1rem;
-    font-weight: 600;
-    color: #111827;
-    line-height: 1.4;
-    margin: 0;
-
-    // 긴 제목 말줄임 (최대 2줄)
-    overflow: hidden;
-    text-overflow: ellipsis;
-    display: -webkit-box;
-    -webkit-line-clamp: 2;
-    -webkit-box-orient: vertical;
-
-    // 모바일에서는 약간 작게
-    @media (max-width: 768px) {
-      font-size: 1rem;
-      -webkit-line-clamp: 2;
-    }
-  }
-}
-
-// 스트리머 정보 + 액션 버튼 컨테이너
-.infoActionsContainer {
-  display: flex;
-  justify-content: space-between;
-  align-items: center;
-  padding: 16px;
-  background: white;
-  border-bottom: 1px solid #f3f4f6;
-}
-
-// 스트리머 정보
-.streamerInfo {
-  display: flex;
-  align-items: center;
-  gap: 12px;
-
-  .streamerAvatar {
-    width: 40px;
-    height: 40px;
-    border-radius: 50%;
-    overflow: hidden;
-    background: #f3f4f6;
-    flex-shrink: 0;
-
-    img {
-      width: 100%;
-      height: 100%;
-      object-fit: cover;
-    }
-
-    .avatarPlaceholder {
-      width: 100%;
-      height: 100%;
-      background: linear-gradient(135deg, #3b82f6, #1e40af);
-      display: flex;
-      align-items: center;
-      justify-content: center;
-      color: white;
-      font-weight: bold;
-      font-size: 0.875rem;
-    }
-  }
-
-  .streamerDetails {
-    flex: 1;
-    min-width: 0;
-
-    .streamerName {
-      font-size: 0.9rem;
-      font-weight: 500;
-      color: #111827;
-      margin-bottom: 4px;
-    }
-
-    .viewerCount {
-      font-size: 0.8rem;
-      color: #6b7280;
-    }
-  }
-}
-
-// 액션 버튼 영역
-.videoActions {
-  display: flex;
-  justify-content: flex-start;
-
-  .actionButtons {
-    display: flex;
-    gap: 12px;
-  }
-
-  .bookmarkBtn,
-  .shareBtn {
-    background: none;
-    border: none;
-    color: #6b7280;
-    cursor: pointer;
-    padding: 8px;
-    border-radius: 4px;
-    transition: all 0.2s;
-
-    &:hover {
-      background-color: #f3f4f6;
-      color: #374151;
-    }
-  }
-}
-
-// 모바일 최적화
-@media (max-width: 480px) {
-  .infoActionsContainer {
-    padding: 12px;
-  }
-
-  .streamerInfo {
-    .streamerAvatar {
-      width: 36px;
-      height: 36px;
-    }
-
-    .streamerDetails {
-      .streamerName {
-        font-size: 0.8rem;
-      }
-
-      .viewerCount {
-        font-size: 0.75rem;
-      }
-    }
-  }
-}

+ 4 - 4
constants/common.ts

@@ -6,10 +6,10 @@ export const enum VerificationType
     ChangedEmail = 2     // 이메일 변경
 }
 
-// ASP.NET Core에서 발급한 JWT Key
-export const CLAIM_NAME_IDENTIFIER = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier";
-export const CLAIM_EMAIL = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress";
-export const CLAIM_NAME = "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name";
+// JWT 클레임 키 (표준 JWT)
+export const CLAIM_NAME_IDENTIFIER = "sub";
+export const CLAIM_EMAIL = "email";
+export const CLAIM_NAME = "name";
 
 // 로그인 기록 구분 값
 export const enum LoginLogType {

+ 3 - 9
contexts/authProvider.tsx

@@ -1,7 +1,7 @@
 'use client';
 
 import { createContext, useContext, useEffect, useState, useCallback } from 'react';
-import { verifyAccessToken, refreshAccessToken } from '@/lib/api/auth';
+import { checkAuthServer } from '@/lib/api/auth';
 
 // 인증 상태 Context
 const AuthContext = createContext<{
@@ -22,16 +22,10 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
     const [isLoading, setIsLoading] = useState<boolean>(true);
 
 	const checkAuth = useCallback(async (): Promise<boolean> => {
-		try {
-
-			let ret = await verifyAccessToken();
-			if (!ret) {
-				ret = await refreshAccessToken();
-			}
-
+	    try {
+			const ret = await checkAuthServer();
 			setIsAuthenticated(ret);
 			setIsLoading(false);
-
 			return ret;
 		} catch {
 			console.error("인증 확인 중 오류 발생");

+ 0 - 154
contexts/broadcastProvider.tsx

@@ -1,154 +0,0 @@
-'use client';
-
-import { createContext, useContext, useEffect, useState, useCallback } from 'react';
-import { BroadcastInfo, BroadcastState } from '@/types/broadcast';
-import { liveStreams, generateNewBroadcast, getTopPopularStreams } from '@/data/mockBroadcasts';
-
-// 방송 정보 Context 타입
-interface BroadcastContextType {
-    // 상태
-    popularBroadcasts: BroadcastInfo[];
-    liveBroadcasts: BroadcastInfo[];
-    isLoading: boolean;
-    error: string | null;
-    currentPage: number;
-    hasMore: boolean;
-
-    // 액션
-    fetchPopularBroadcasts: () => void;
-    fetchLiveBroadcasts: () => void;
-    loadMoreBroadcasts: () => void;
-    refreshBroadcasts: () => void;
-    updateBroadcast: (broadcast: BroadcastInfo) => void;
-    removeBroadcast: (broadcastId: string) => void;
-}
-
-// Context 생성
-const BroadcastContext = createContext<BroadcastContextType>({
-    popularBroadcasts: [],
-    liveBroadcasts: [],
-    isLoading: false,
-    error: null,
-    currentPage: 1,
-    hasMore: true,
-    fetchPopularBroadcasts: () => {},
-    fetchLiveBroadcasts: () => {},
-    loadMoreBroadcasts: () => {},
-    refreshBroadcasts: () => {},
-    updateBroadcast: () => {},
-    removeBroadcast: () => {},
-});
-
-// Context Provider
-export function BroadcastProvider({ children }: { children: React.ReactNode }) {
-    const [state, setState] = useState<BroadcastState>({
-        popularBroadcasts: [],
-        liveBroadcasts: [],
-        isLoading: false,
-        error: null,
-        currentPage: 1,
-        hasMore: true,
-    });
-
-    // 인기 방송 목록 로드
-    const fetchPopularBroadcasts = useCallback(() => {
-        setState(prev => ({ ...prev, isLoading: true, error: null }));
-
-        // 실시간 LIVE에서 시청자수 기준 top5 가져오기
-        setState(prev => ({
-            ...prev,
-            popularBroadcasts: getTopPopularStreams(5),
-            isLoading: false,
-        }));
-    }, []);
-
-    // 실시간 방송 목록 로드
-    const fetchLiveBroadcasts = useCallback(() => {
-        setState(prev => ({ ...prev, isLoading: true, error: null }));
-
-        // 더미 데이터에서 실시간 방송 목록 가져오기
-        setState(prev => ({
-            ...prev,
-            liveBroadcasts: [...liveStreams],
-            isLoading: false,
-        }));
-    }, []);
-
-    // 더 많은 방송 로드 (무한스크롤용)
-    const loadMoreBroadcasts = useCallback(() => {
-        if (!state.hasMore || state.isLoading) return;
-
-        setState(prev => ({ ...prev, isLoading: true }));
-
-        // 헬퍼 함수를 사용하여 새로운 방송 생성
-        const newBroadcast = generateNewBroadcast(state.currentPage + 1);
-
-        setState(prev => ({
-            ...prev,
-            liveBroadcasts: [...prev.liveBroadcasts, newBroadcast],
-            currentPage: prev.currentPage + 1,
-            hasMore: prev.currentPage < 5,
-            isLoading: false,
-        }));
-    }, [state.hasMore, state.isLoading, state.currentPage]);
-
-    // 방송 목록 새로고침
-    const refreshBroadcasts = useCallback(() => {
-        setState(prev => ({ ...prev, currentPage: 1, hasMore: true }));
-        fetchPopularBroadcasts();
-        fetchLiveBroadcasts();
-    }, [fetchPopularBroadcasts, fetchLiveBroadcasts]);
-
-    // 방송 정보 업데이트
-    const updateBroadcast = useCallback((updatedBroadcast: BroadcastInfo) => {
-        setState(prev => ({
-            ...prev,
-            popularBroadcasts: prev.popularBroadcasts.map(broadcast =>
-                broadcast.id === updatedBroadcast.id ? updatedBroadcast : broadcast
-            ),
-            liveBroadcasts: prev.liveBroadcasts.map(broadcast =>
-                broadcast.id === updatedBroadcast.id ? updatedBroadcast : broadcast
-            ),
-        }));
-    }, []);
-
-    // 방송 제거
-    const removeBroadcast = useCallback((broadcastId: string) => {
-        setState(prev => ({
-            ...prev,
-            popularBroadcasts: prev.popularBroadcasts.filter(broadcast => broadcast.id !== broadcastId),
-            liveBroadcasts: prev.liveBroadcasts.filter(broadcast => broadcast.id !== broadcastId),
-        }));
-    }, []);
-
-    // 초기 데이터 로드
-    useEffect(() => {
-        fetchPopularBroadcasts();
-        fetchLiveBroadcasts();
-    }, [fetchPopularBroadcasts, fetchLiveBroadcasts]);
-
-    const value: BroadcastContextType = {
-        ...state,
-        fetchPopularBroadcasts,
-        fetchLiveBroadcasts,
-        loadMoreBroadcasts,
-        refreshBroadcasts,
-        updateBroadcast,
-        removeBroadcast,
-    };
-
-    return (
-        <BroadcastContext.Provider value={value}>
-            {children}
-        </BroadcastContext.Provider>
-    );
-}
-
-// Context 사용을 위한 커스텀 훅
-export function useBroadcastContext() {
-    const context = useContext(BroadcastContext);
-    if (!context) {
-        throw new Error('useBroadcastContext must be used within a BroadcastProvider');
-    }
-    return context;
-}

+ 83 - 54
contexts/signalrProvider.tsx

@@ -2,112 +2,141 @@
 
 import { createContext, useContext, useEffect, useState, useRef } from 'react';
 import * as signalR from '@microsoft/signalr';
-import useAuth from '@/hooks/useAuth';
+import { fetchLogout } from '@/lib/api/auth';
 
 const SignalRContext = createContext<{
-    connection: signalR.HubConnection | null;
-	connected: boolean;
-	stopConnection: () => void;
-	reConnection: () => void;
+    cryptoConnection: signalR.HubConnection | null;
+    chatConnection: signalR.HubConnection | null;
+	cryptoConnected: boolean;
+	chatConnected: boolean;
+	stopConnections: () => Promise<void>;
+	reconnectChat: (accessToken?: string | null) => Promise<void>;
 }>({
-    connection: null,
-	connected: false,
-	stopConnection: () => {},
-	reConnection: () => {}
+    cryptoConnection: null,
+    chatConnection: null,
+	cryptoConnected: false,
+	chatConnected: false,
+	stopConnections: async () => {},
+	reconnectChat: async () => {}
 });
 
 type Props = {
 	children: React.ReactNode;
 	accessToken: string|null;
-	signalRUrl: string;
+	signalRCryptoUrl: string;
+	signalRChatUrl: string;
 }
 
-export function SignalRProvider({ children, accessToken, signalRUrl }: Props) {
-	const connection = useRef<signalR.HubConnection|null>(null);
-	const [connected, setConnected] = useState<boolean>(false);
-	const { logout } = useAuth();
+export function SignalRProvider({ children, accessToken, signalRCryptoUrl, signalRChatUrl }: Props) {
+	const cryptoConnectionRef = useRef<signalR.HubConnection|null>(null);
+	const chatConnectionRef = useRef<signalR.HubConnection|null>(null);
+	const [cryptoConnected, setCryptoConnected] = useState<boolean>(false);
+	const [chatConnected, setChatConnected] = useState<boolean>(false);
 
+	// 초기 렌더 시에만 전달됨. 토큰 갱신 시에는 reconnectChat()을 통해 수동으로 재연결 처리
 	useEffect(() => {
-		initConnection();
+		initCryptoConnection();
+		initChatConnection(accessToken);
 
 		return () => {
-			if (connection.current) {
-				stopConnection();
-			}
+			stopConnections();
 		};
 	}, []);
 
 	useEffect(() => {
-		if (connected) {
-			console.info('SignalR Connected');
-		} else if(connection.current) {
-			console.info('SignalR Disconnected');
-		} else {
-			console.info('SignalR Waiting...');
+		if (cryptoConnected) {
+			console.info('SignalR Crypto Connected');
+		}
+	}, [cryptoConnected]);
+
+	useEffect(() => {
+		if (chatConnected) {
+			console.info('SignalR Chat Connected');
 		}
-	}, [connected]);
+	}, [chatConnected]);
 
-	const initConnection = async () => {
+	const initCryptoConnection = async () => {
 		try {
-			if (connection.current && connection.current.state !== signalR.HubConnectionState.Disconnected) {
+			if (cryptoConnectionRef.current && cryptoConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
 				return;
 			}
 
-			console.log('SignalR Connecting...');
+			const conn = new signalR.HubConnectionBuilder().withUrl(signalRCryptoUrl).withAutomaticReconnect().build();
+			await conn.start();
 
-			const connectionOptions = accessToken ? { accessTokenFactory: async () => accessToken, withCredentials: true } : {};
-			const conn = new signalR.HubConnectionBuilder().withUrl(signalRUrl, connectionOptions).build();
+			setCryptoConnected(true);
+			cryptoConnectionRef.current = conn;
+		} catch (error) {
+			console.error('SignalR Crypto Connect Failed:', error);
+		}
+	};
 
-			if (conn.state === signalR.HubConnectionState.Disconnected) {
-				console.warn('SignalR Connection is already disconnected');
-				return;
+	const initChatConnection = async (accessToken?: string|null) => {
+		try {
+			if (chatConnectionRef.current && chatConnectionRef.current.state !== signalR.HubConnectionState.Disconnected) {
+				await chatConnectionRef.current.stop();
 			}
 
+			const connectionOptions = accessToken ? { accessTokenFactory: async () => accessToken, withCredentials: true } : {};
+			const conn = new signalR.HubConnectionBuilder().withUrl(signalRChatUrl, connectionOptions).withAutomaticReconnect().build();
 			await conn.start();
-			setConnected(true);
+
+			setChatConnected(true);
 
 			conn.on('Connected', (message) => {
 				console.info(message);
 			});
+
 			conn.on('Logout', (message) => {
 				console.info(message);
 			});
+
 			conn.on('Kick', async () => {
-				await logout();
+				await fetchLogout();
+				alert('관리자에 의해 강제 종료되었습니다.');
+				localStorage.setItem('rememberMe', 'false');
+				localStorage.removeItem('member');
+				location.replace('/');
 			});
 
-			connection.current = conn;
+			chatConnectionRef.current = conn;
 		} catch (error) {
-			console.error('SignalR Connect Failed:', error);
+			console.error('SignalR Chat Connect Failed:', error);
 		}
 	};
 
-	const stopConnection = async () => {
-		if (connection.current && connection.current.state === signalR.HubConnectionState.Connected) {
+	const stopConnections = async () => {
+		if (chatConnectionRef.current && chatConnectionRef.current.state === signalR.HubConnectionState.Connected) {
 			try {
-                await connection.current.invoke('Logout');
-                setConnected(false);
-            } catch (error) {
-                console.error('SignalR Disconnect Failed:', error);
-            }
+				await chatConnectionRef.current.invoke('Logout');
+				setChatConnected(false);
+			} catch (error) {
+				console.error('SignalR Chat Disconnect Failed:', error);
+			}
 		}
-	};
-
-	const reConnection = async () => {
-		if (connection.current && connection.current.state === signalR.HubConnectionState.Connected) {
+		if (cryptoConnectionRef.current && cryptoConnectionRef.current.state === signalR.HubConnectionState.Connected) {
 			try {
-				console.log('SignalR ReConnecting...');
-				await connection.current.stop();
-				await initConnection();
-				console.log('SignalR ReConnected');
+				await cryptoConnectionRef.current.stop();
+				setCryptoConnected(false);
 			} catch (error) {
-				console.error("SignalR ReConnection Failed:", error);
+				console.error('SignalR Crypto Disconnect Failed:', error);
 			}
 		}
 	};
 
+	const reconnectChat = async (token?: string | null) => {
+		await initChatConnection(token);
+	};
+
 	return (
-		<SignalRContext.Provider value={{ connection: connection.current, connected, stopConnection, reConnection }}>
+		<SignalRContext.Provider value={{
+			cryptoConnection: cryptoConnectionRef.current,
+			chatConnection: chatConnectionRef.current,
+			cryptoConnected,
+			chatConnected,
+			stopConnections,
+			reconnectChat
+		}}>
 			{children}
 		</SignalRContext.Provider>
 	)

+ 1 - 1
dtos/request/account.ts

@@ -2,7 +2,7 @@
 
 // 이메일 변경
 export interface ChangeEmailRequest {
-    Email: string;
+    NewEmail: string;
 }
 
 // 별명 변경

+ 0 - 13
dtos/request/payment/danal/DanalConfirmRequest.ts

@@ -1,13 +0,0 @@
-// 다날 결제 승인 요청 DTO
-export default interface DanalConfirmRequest {
-    method: string;
-    transactionID: string;
-    orderID: string;
-    amount: number;
-    merchantID: string;
-    certificateToken: string;
-    orderName: string;
-    authType: string;
-    certificationNumber: string;
-    customerID: string;
-};

+ 0 - 5
dtos/request/payment/danal/DanalFailedRequest.ts

@@ -1,5 +0,0 @@
-// 다날 결제 실패 요청 DTO
-export default interface DanalFailedRequest {
-    code: string;
-    message: string;
-};

+ 11 - 0
dtos/response/account/loginLogs.ts

@@ -0,0 +1,11 @@
+// 로그인 기록
+export interface LoginLogsResponse {
+    total: number
+    list: {
+        id: number;
+        success: boolean;
+        ipAddress: string;
+        userAgent: string;
+        createdAt: string;
+    }[]
+}

+ 22 - 37
dtos/response/account/member.ts

@@ -1,12 +1,31 @@
 export interface MemberResponse {
+	// 회원 정보
+	id: number;
+	sid: string;
+	email: string;
+	name: string | null;
+	intro: string | null;
+	summary: string | null;
+	thumb: string | null;
+	icon: string | null;
+	gender: number | null;
+	isEmailVerified: boolean;
+	isAuthCertified: boolean;
+	isCreator: boolean;
+	isDenied: boolean;
+	lastLoginAt: string | null;
+	passwordUpdatedAt: string | null;
+	createdAt: string;
+	updatedAt: string | null;
+
 	// 회원 등급 정보
 	memberGrade: {
 		id: number;
 		korName: string;
 		engName: string;
 		order: number;
-		image: string|null;
-	};
+		image: string | null;
+	} | null;
 
 	// 알림/동의/수신 동의 여부
 	memberApprove: {
@@ -15,38 +34,4 @@ export interface MemberResponse {
 		isReceiveNote: boolean;
 		isDisclosureInvest: boolean;
 	};
-
-	// 회원 정보
-	id: number;
-	memberGradeID: number|null;
-	sid: string;
-	email: string;
-	name: string|null;
-	fullName: string|null;
-	firstName: string|null;
-	lastName: string|null;
-	intro: string|null;
-	summary: string|null;
-	coin: number;
-	exp: number;
-	gender: string|null;
-	photo: string|null;
-	icon: string|null;
-	isEmailVerified: boolean;
-	isAuthCertified: boolean;
-	isDenied: boolean;
-	isAdmin: boolean;
-	following: number;
-	followed: number;
-	signupIp: string;
-	lastLoginIp: string;
-	lastLoginAt: string;
-	authCertifiedAt: string|null;
-	passwordUpdatedAt: string;
-	createdAt: string;
-	updatedAt: string|null;
-	deniedAt: string|null;
-
-	posts: number;
-	comments: number;
-}
+}

+ 12 - 0
dtos/response/auth.ts

@@ -0,0 +1,12 @@
+// 로그인
+export interface LoginResponse {
+	accessToken: string;
+	refreshToken: string;
+	expiresAt: string;
+}
+
+// 회원가입
+export interface RegisterResponse {
+	id: number;
+	isRegisterEmailAuth: boolean;
+}

+ 6 - 38
dtos/response/common.ts

@@ -1,47 +1,15 @@
-/*
-export class ResultDto<T = any> {
-	ok: boolean;
-	status: number;
-	message?: string|null;
-	data: T|null;
-	errors?: Record<string, string[]>|null;
-
-	constructor({
-		ok = false,
-		status = 500,
-		message = null,
-		data = null,
-		errors = null
-	}: Partial<ResultDto<T>>) {
-		this.ok = ok;
-		this.status = status;
-		this.message = message;
-		this.data = data;
-		this.errors = errors;
-	}
-
-	public getError(index: number = 0): string|undefined {
-		if (this.errors) {
-			const errorKeys = Object.keys(this.errors);
-			if (errorKeys.length > 0) {
-				const key = errorKeys[index];
-				if (key) {
-					return this.errors[key][0];
-				}
-			}
-		}
-		return undefined;
-	}
-};
-*/
+export interface ApiError {
+    code: string;
+    description: string;
+}
 
 // eslint-disable-next-line @typescript-eslint/no-explicit-any
 export interface ResultDto<T = any> {
-    ok: boolean;
+    success: boolean;
     status: number;
     message: string|null;
     data: T|null;
-    errors: Record<string, string[]>|null;
+    errors: ApiError[]|null;
 }
 
 export interface TokenData {

+ 0 - 8
dtos/response/payment/danal/DanalConfirmResponse.ts

@@ -1,8 +0,0 @@
-// 다날 결제 승인 응답 DTO
-export default interface DanalConfirmResponse {
-    success: number;
-    orderID: string;
-    payMethod: string;
-    amount: number;
-    paidAt: string;
-};

+ 0 - 4
dtos/response/payment/danal/DanalFailedResponse.ts

@@ -1,4 +0,0 @@
-// 다날 결제 실패 응답 DTO
-export default interface DanalFailedResponse {
-    failedAt: string;
-};

+ 21 - 42
hooks/useAuth.ts

@@ -1,9 +1,8 @@
 'use client';
 
-import { useEffect, useState } from 'react';
 import { useAuthContext } from '@/contexts/authProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
-import { useSignalRContext } from '@/contexts/signalrProvider';
+import { fetchLogout } from '@/lib/api/auth';
 import { fetchMemberInfo } from '@/lib/api/account';
 import { getAccessToken } from '@/lib/utils/server';
 import { decodeAccessToken, throwError } from '@/lib/utils/client';
@@ -12,16 +11,6 @@ export default function useAuth()
 {
     const { isAuthenticated, isLoading, setIsAuthenticated, checkAuth } = useAuthContext();
 	const { member, setMember } = useMemberContext();
-	const { stopConnection } = useSignalRContext();
-	const [rememberMe, setRememberMe] = useState<boolean>(false);
-	const [memberLoaded, setMemberLoaded] = useState(<boolean>false);
-
-	useEffect(() => {
-		if (memberLoaded && member) {
-			setMemberState(true);
-			setMemberLoaded(false);
-		}
-	}, [memberLoaded]);
 
 	// 로그인
 	const login = async (rememberMe: boolean) => {
@@ -35,49 +24,39 @@ export default function useAuth()
 			return;
 		}
 
-		setRememberMe(rememberMe);
+		try {
+			const res = await fetchMemberInfo();
+			throwError(res);
 
-		fetchMemberInfo().then((res) => {
-			if (!res.ok) {
-				throwError(res);
-			}
+			setIsAuthenticated(true);
 			setMember(res.data);
-			setMemberLoaded(true);
-		});
+
+			localStorage.setItem('rememberMe', rememberMe.toString());
+			localStorage.setItem("member", JSON.stringify(res.data));
+
+			location.replace('/');
+		} catch (err) {
+			console.error('로그인 처리 중 오류:', err);
+		}
     };
 
 	// 로그아웃
 	const logout = async () => {
-        const res = await fetch('/api/auth/logout', {
-			method: 'POST',
-			credentials: 'include'
-		});
+        const res = await fetchLogout();
 
-		if (res.ok) {
-			setMemberState(false);
-		}
-	};
+		if (res.success) {
+			setIsAuthenticated(false);
 
-	const setMemberState = async (state: boolean) => {
-		setIsAuthenticated(state);
-
-		if (state) {
-			// 로그인
-			localStorage.setItem('rememberMe', rememberMe.toString());
-			localStorage.setItem("member", JSON.stringify(member)); // 회원 정보 보관
-		} else {
-			// 로그아웃
 			alert('로그아웃 되었습니다.');
 			localStorage.setItem('rememberMe', "false");
 			localStorage.removeItem('member');
-			await stopConnection();
-		}
 
-		location.replace('/');
+			location.replace('/');
+		}
 	};
 
 	// 로그인 여부 확인
-	const isLogined = async () => {
+	const isLoggedIn = async () => {
 		const res = await checkAuth();
 
 		if (!res) {
@@ -91,5 +70,5 @@ export default function useAuth()
 		}
 	};
 
-   	return { isAuthenticated, isLoading, login, logout, member, isLogined };
-}
+   	return { isAuthenticated, isLoading, login, logout, member, isLoggedIn };
+}

+ 0 - 134
hooks/useBroadcasts.ts

@@ -1,134 +0,0 @@
-'use client';
-
-import { useQuery, useInfiniteQuery, UseQueryResult, UseInfiniteQueryResult } from '@tanstack/react-query';
-import {
-    fetchBroadcastList,
-    fetchPopularBroadcasts,
-    fetchLiveBroadcasts,
-    fetchBroadcastDetail,
-    fetchBroadcastsByCategory,
-    searchBroadcasts
-} from '@/lib/api/broadcast';
-import { BroadcastInfo, BroadcastListResponse } from '@/types/broadcast';
-import { ResultDto } from '@/dtos/response/common';
-
-// 쿼리 키 상수 정의
-export const broadcastKeys = {
-    all: ['broadcasts'] as const,
-    lists: () => [...broadcastKeys.all, 'list'] as const,
-    list: (filters: string) => [...broadcastKeys.lists(), filters] as const,
-    details: () => [...broadcastKeys.all, 'detail'] as const,
-    detail: (id: string) => [...broadcastKeys.details(), id] as const,
-    popular: (limit: number) => [...broadcastKeys.all, 'popular', limit] as const,
-    live: (page: number, limit: number) => [...broadcastKeys.all, 'live', page, limit] as const,
-    category: (category: string, page: number, limit: number) => [...broadcastKeys.all, 'category', category, page, limit] as const,
-    search: (query: string, page: number, limit: number) => [...broadcastKeys.all, 'search', query, page, limit] as const,
-};
-
-// 인기 방송 목록 조회 훅
-export function usePopularBroadcasts(limit: number = 5): UseQueryResult<BroadcastInfo[], Error> {
-    return useQuery({
-        queryKey: broadcastKeys.popular(limit),
-        queryFn: async () => {
-            const result = await fetchPopularBroadcasts(limit);
-            if (!result.ok) {
-                throw new Error(result.message || '인기 방송 목록을 가져오는데 실패했습니다.');
-            }
-            return result.data || [];
-        },
-        staleTime: 2 * 60 * 1000, // 2분
-        refetchInterval: 30 * 1000, // 30초마다 갱신
-    });
-}
-
-// 실시간 방송 목록 조회 훅 (무한스크롤 지원)
-export function useLiveBroadcasts(limit: number = 20): UseInfiniteQueryResult<BroadcastListResponse, Error> {
-    return useInfiniteQuery({
-        queryKey: broadcastKeys.live(1, limit),
-        queryFn: async ({ pageParam = 1 }) => {
-            const result = await fetchLiveBroadcasts(pageParam as number, limit);
-            if (!result.ok) {
-                throw new Error(result.message || '실시간 방송 목록을 가져오는데 실패했습니다.');
-            }
-            return result.data || { broadcasts: [], totalCount: 0, hasMore: false };
-        },
-        getNextPageParam: (lastPage, allPages) => {
-            return lastPage.hasMore ? allPages.length + 1 : undefined;
-        },
-        initialPageParam: 1,
-        staleTime: 1 * 60 * 1000, // 1분
-        refetchInterval: 15 * 1000, // 15초마다 갱신
-    });
-}
-
-// 방송 상세 정보 조회 훅
-export function useBroadcastDetail(broadcastId: string): UseQueryResult<BroadcastInfo, Error> {
-    return useQuery({
-        queryKey: broadcastKeys.detail(broadcastId),
-        queryFn: async () => {
-            const result = await fetchBroadcastDetail(broadcastId);
-            if (!result.ok) {
-                throw new Error(result.message || '방송 정보를 가져오는데 실패했습니다.');
-            }
-            return result.data;
-        },
-        enabled: !!broadcastId,
-        staleTime: 30 * 1000, // 30초
-        refetchInterval: 10 * 1000, // 10초마다 갱신 (실시간 정보)
-    });
-}
-
-// 카테고리별 방송 목록 조회 훅
-export function useBroadcastsByCategory(
-    category: string,
-    page: number = 1,
-    limit: number = 20
-): UseQueryResult<BroadcastListResponse, Error> {
-    return useQuery({
-        queryKey: broadcastKeys.category(category, page, limit),
-        queryFn: async () => {
-            const result = await fetchBroadcastsByCategory(category, page, limit);
-            if (!result.ok) {
-                throw new Error(result.message || '카테고리별 방송 목록을 가져오는데 실패했습니다.');
-            }
-            return result.data || { broadcasts: [], totalCount: 0, hasMore: false };
-        },
-        enabled: !!category,
-        staleTime: 2 * 60 * 1000, // 2분
-    });
-}
-
-// 방송 검색 훅
-export function useSearchBroadcasts(
-    query: string,
-    page: number = 1,
-    limit: number = 20
-): UseQueryResult<BroadcastListResponse, Error> {
-    return useQuery({
-        queryKey: broadcastKeys.search(query, page, limit),
-        queryFn: async () => {
-            const result = await searchBroadcasts(query, page, limit);
-            if (!result.ok) {
-                throw new Error(result.message || '방송 검색에 실패했습니다.');
-            }
-            return result.data || { broadcasts: [], totalCount: 0, hasMore: false };
-        },
-        enabled: !!query && query.length > 0,
-        staleTime: 5 * 60 * 1000, // 5분
-    });
-}
-
-// 방송 목록 조회 훅 (일반적인 목록)
-export function useBroadcastList(page: number = 1, limit: number = 20): UseQueryResult<BroadcastListResponse, Error> {
-    return useQuery({
-        queryKey: broadcastKeys.list(`page-${page}-limit-${limit}`),
-        queryFn: async () => {
-            const result = await fetchBroadcastList(page, limit);
-            if (!result.ok) {
-                throw new Error(result.message || '방송 목록을 가져오는데 실패했습니다.');
-            }
-            return result.data || { broadcasts: [], totalCount: 0, hasMore: false };
-        },
-        staleTime: 2 * 60 * 1000, // 2분
-    });
-}

+ 0 - 91
hooks/useBroadcastsMock.ts

@@ -1,91 +0,0 @@
-'use client';
-
-import { useQuery, useInfiniteQuery, UseQueryResult, UseInfiniteQueryResult } from '@tanstack/react-query';
-import { BroadcastInfo, BroadcastListResponse } from '@/types/broadcast';
-import { liveStreams, getTopPopularStreams } from '@/data/mockBroadcasts';
-
-// 쿼리 키 상수 정의
-export const broadcastKeys = {
-    all: ['broadcasts'] as const,
-    lists: () => [...broadcastKeys.all, 'list'] as const,
-    list: (filters: string) => [...broadcastKeys.lists(), filters] as const,
-    details: () => [...broadcastKeys.all, 'detail'] as const,
-    detail: (id: string) => [...broadcastKeys.details(), id] as const,
-    popular: (limit: number) => [...broadcastKeys.all, 'popular', limit] as const,
-    live: (page: number, limit: number) => [...broadcastKeys.all, 'live', page, limit] as const,
-};
-
-// 인기 방송 목록 조회 훅 (더미 데이터)
-export function usePopularBroadcasts(limit: number = 5): UseQueryResult<BroadcastInfo[], Error> {
-    return useQuery({
-        queryKey: broadcastKeys.popular(limit),
-        queryFn: async () => {
-            // 실제 API 호출 시뮬레이션
-            await new Promise(resolve => setTimeout(resolve, 100));
-            return getTopPopularStreams(limit);
-        },
-        staleTime: 2 * 60 * 1000, // 2분
-        refetchInterval: 30 * 1000, // 30초마다 갱신
-    });
-}
-
-// 실시간 방송 목록 조회 훅 (더미 데이터)
-export function useLiveBroadcasts(limit: number = 20): UseQueryResult<BroadcastInfo[], Error> {
-    return useQuery({
-        queryKey: broadcastKeys.live(1, limit),
-        queryFn: async () => {
-            // 실제 API 호출 시뮬레이션
-            await new Promise(resolve => setTimeout(resolve, 150));
-            return [...liveStreams];
-        },
-        staleTime: 1 * 60 * 1000, // 1분
-        refetchInterval: 15 * 1000, // 15초마다 갱신
-    });
-}
-
-// 방송 상세 정보 조회 훅 (더미 데이터)
-export function useBroadcastDetail(broadcastId: string): UseQueryResult<BroadcastInfo | null, Error> {
-    return useQuery({
-        queryKey: broadcastKeys.detail(broadcastId),
-        queryFn: async () => {
-            // 실제 API 호출 시뮬레이션
-            await new Promise(resolve => setTimeout(resolve, 100));
-            const broadcast = liveStreams.find(stream => stream.id === broadcastId);
-            return broadcast || null;
-        },
-        enabled: !!broadcastId,
-        staleTime: 30 * 1000, // 30초
-        refetchInterval: 10 * 1000, // 10초마다 갱신 (실시간 정보)
-    });
-}
-
-// 무한스크롤을 위한 실시간 방송 목록 훅
-export function useLiveBroadcastsInfinite(limit: number = 20): UseInfiniteQueryResult<{ broadcasts: BroadcastInfo[], hasMore: boolean }, Error> {
-    return useInfiniteQuery({
-        queryKey: [...broadcastKeys.live(1, limit), 'infinite'],
-        queryFn: async ({ pageParam = 1 }) => {
-            // 실제 API 호출 시뮬레이션
-            await new Promise(resolve => setTimeout(resolve, 200));
-
-            const page = pageParam as number;
-            const startIndex = (page - 1) * limit;
-            const endIndex = startIndex + limit;
-
-            // 더미 데이터에서 페이지네이션 시뮬레이션
-            const allBroadcasts = [...liveStreams];
-            const broadcasts = allBroadcasts.slice(startIndex, endIndex);
-            const hasMore = endIndex < allBroadcasts.length;
-
-            return {
-                broadcasts,
-                hasMore
-            };
-        },
-        getNextPageParam: (lastPage, allPages) => {
-            return lastPage.hasMore ? allPages.length + 1 : undefined;
-        },
-        initialPageParam: 1,
-        staleTime: 1 * 60 * 1000, // 1분
-        refetchInterval: 15 * 1000, // 15초마다 갱신
-    });
-}

+ 33 - 27
lib/api/account.ts

@@ -4,26 +4,30 @@ import { ChangeEmailRequest, ChangeNameRequest, ChangeApproveRequest, ChangeSumm
 import { MemberResponse } from '@/dtos/response/account/member';
 import { ResultDto } from '@/dtos/response/common';
 import { fetchJson } from '@/lib/utils/server';
-import { LoginLogType } from '@/constants/common';
+
 
 // 회원정보 조회
 export async function fetchMemberInfo(): Promise<ResultDto<MemberResponse>> {
-	return await fetchJson<MemberResponse>('/api/account/info', {
+	return await fetchJson<MemberResponse>('/api/auth/profile', {
 		method: 'GET'
 	});
 }
 
 // 이메일 변경 인증 메일 발송
 export async function fetchChangeEmail(params: ChangeEmailRequest) {
-	return await fetchJson('/api/account/change-email', {
+	return await fetchJson('/api/mypage/email', {
 		method: 'POST',
-		data: params
+		headers: {
+			'Accept': 'application/json',
+			'Content-Type': 'application/json'
+		},
+		body: JSON.stringify(params)
 	});
 }
 
 // 이메일 변경 인증 확인
 export async function fetchValidEmail(token: string) {
-	return await fetchJson(`/api/auth/valid-email?token=${token}`, {
+	return await fetchJson(`/api/mypage/email/verify?token=${token}`, {
 		method: 'GET',
 		headers: {
 			'Accept': 'application/json'
@@ -33,7 +37,7 @@ export async function fetchValidEmail(token: string) {
 
 // 별명 변경
 export async function fetchChangeName(params: ChangeNameRequest) {
-	return await fetchJson('/api/account/change-name', {
+	return await fetchJson('/api/mypage/name', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json',
@@ -45,7 +49,7 @@ export async function fetchChangeName(params: ChangeNameRequest) {
 
 // 별명 삭제
 export async function fetchRemoveName() {
-	return await fetchJson('/api/account/remove-name', {
+	return await fetchJson('/api/mypage/name', {
 		method: 'DELETE',
 		headers: {
 			'Accept': 'application/json',
@@ -54,30 +58,33 @@ export async function fetchRemoveName() {
 	});
 }
 
-
 // 수신 설정
 export async function fetchChangeApprove(params: ChangeApproveRequest) {
-	return await fetchJson('/api/account/change-approve', {
+	return await fetchJson('/api/mypage/receive-settings', {
 		method: 'POST',
+		headers: {
+			'Accept': 'application/json',
+			'Content-Type': 'application/json'
+		},
 		body: JSON.stringify(params)
 	});
 }
 
 // 한마디 변경
 export async function fetchChangeSummary(params: ChangeSummaryRequest) {
-	return await fetchJson('/api/account/change-summary', {
+	return await fetchJson('/api/mypage/summary', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json',
 			'Content-Type': 'application/json'
 		},
-		data: params
+		body: JSON.stringify(params)
 	});
 }
 
 // 한마디 삭제
 export async function fetchRemoveSummary() {
-	return await fetchJson('/api/account/remove-summary', {
+	return await fetchJson('/api/mypage/summary', {
 		method: 'DELETE',
 		headers: {
 			'Accept': 'application/json',
@@ -88,19 +95,19 @@ export async function fetchRemoveSummary() {
 
 // 자기소개 변경
 export async function fetchChangeIntro(params: ChangeIntroRequest) {
-	return await fetchJson('/api/account/change-intro', {
+	return await fetchJson('/api/mypage/intro', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json',
 			'Content-Type': 'application/json'
 		},
-		data: params
+		body: JSON.stringify(params)
 	});
 }
 
 // 자기소개 삭제
 export async function fetchRemoveIntro() {
-	return await fetchJson('/api/account/remove-intro', {
+	return await fetchJson('/api/mypage/intro', {
 		method: 'DELETE',
 		headers: {
 			'Accept': 'application/json',
@@ -110,34 +117,34 @@ export async function fetchRemoveIntro() {
 }
 
 // 회원 사진 변경
-export async function fetchChangePhoto(f: File|null): Promise<ResultDto<{photoURL: string}>> {
+export async function fetchChangeThumb(f: File|null): Promise<ResultDto<{thumbUrl: string}>> {
 	const formData = new FormData();
-	formData.append('photo', f || '');
+	formData.append('thumb', f || '');
 
-	return await fetchJson<{photoURL: string}>('/api/account/change-photo', {
+	return await fetchJson<{thumbUrl: string}>('/api/mypage/thumb', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json'
 		},
-		data: formData
+		body: formData
 	});
 }
 
 // 비밀번호 변경
 export async function fetchChangePassword(params: ChangePasswordRequest) {
-	return await fetchJson('/api/account/change-password', {
+	return await fetchJson('/api/mypage/password', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json',
 			'Content-Type': 'application/json'
 		},
-		data: params
+		body: JSON.stringify(params)
 	});
 }
 
 // 회원탈퇴
 export async function fetchWithdraw() {
-	return await fetchJson('/api/account/withdraw', {
+	return await fetchJson('/api/mypage/withdraw', {
 		method: 'POST',
 		headers: {
 			'Accept': 'application/json',
@@ -147,12 +154,11 @@ export async function fetchWithdraw() {
 }
 
 // 로그인 기록
-export async function fetchLoginLog(type: LoginLogType, page: number) {
-	return await fetchJson(`/api/account/login-log?type=${type}&page=${page}`, {
+export async function fetchLoginLog(page: number, type: string = 'today', pageSize: number = 20) {
+	return await fetchJson(`/api/mypage/login-logs?page=${page}&type=${type}&pageSize=${pageSize}`, {
 		method: 'GET',
 		headers: {
-			'Accept': 'application/json',
-			'Content-Type': 'application/json'
+			'Accept': 'application/json'
 		}
 	});
-}
+}

+ 55 - 32
lib/api/auth.ts

@@ -3,14 +3,43 @@
 import { cookies } from 'next/headers';
 import { ResultDto } from '@/dtos/response/common';
 import { LoginRequest, RegisterRequest, VerifyEmailRequest, ResendEmailRequest, ForgotPasswordRequest, ResetPasswordRequest } from '@/dtos/request/auth';
+import { LoginResponse } from '@/dtos/response/auth';
 import { fetchJson, getAccessToken, getRefreshToken } from '@/lib/utils/server';
 
 // 로그인
-export async function fetchLogin(request: LoginRequest): Promise<ResultDto> {
-	return await fetchJson('/api/auth/login', {
-		method: 'POST',
+export async function fetchLogin(request: LoginRequest): Promise<ResultDto<LoginResponse>> {
+	const res = await fetchJson<LoginResponse>('/api/auth/login', {
+        method: 'POST',
         body: JSON.stringify(request)
-	});
+    });
+
+    if (res.success && res.data) {
+        const cookie = await cookies();
+		const option = {
+            httpOnly: true, path: '/'
+        };
+
+        cookie.set('accessToken', res.data.accessToken, option);
+        cookie.set('refreshToken', res.data.refreshToken, option);
+    }
+
+    return res;
+}
+
+// 로그아웃
+export async function fetchLogout(): Promise<ResultDto> {
+	const res = await fetchJson('/api/auth/logout', {
+        method: 'POST'
+    });
+
+    if (res.success) {
+        const cookie = await cookies();
+
+        cookie.delete('accessToken');
+        cookie.delete('refreshToken');
+    }
+
+    return res;
 }
 
 // 회원가입 요청
@@ -37,8 +66,7 @@ export async function Registration(email: string|null): Promise<ResultDto> {
 export async function fetchForgotPassword(request: ForgotPasswordRequest): Promise<ResultDto> {
 	return await fetchJson(`/api/auth/forgot-password`, {
 		method: 'POST',
-		headers: {'Content-Type': 'application/json'},
-		data: request
+		body: JSON.stringify(request)
 	});
 }
 
@@ -46,8 +74,7 @@ export async function fetchForgotPassword(request: ForgotPasswordRequest): Promi
 export async function fetchVerifyEmail(request: VerifyEmailRequest): Promise<ResultDto> {
 	return await fetchJson('/api/auth/verify-email', {
 		method: 'POST',
-		headers: {'Content-Type': 'application/json'},
-		data: request
+		body: JSON.stringify(request)
 	});
 }
 
@@ -55,8 +82,7 @@ export async function fetchVerifyEmail(request: VerifyEmailRequest): Promise<Res
 export async function fetchResendEmail(params: ResendEmailRequest): Promise<ResultDto> {
 	return await fetchJson('/api/auth/resend-email', {
 		method: 'POST',
-		headers: {'Content-Type': 'application/json'},
-		data: params
+		body: JSON.stringify(params)
 	});
 }
 
@@ -67,26 +93,12 @@ export async function fetchResetPassword(params: ResetPasswordRequest): Promise<
 	return await fetchJson('/api/auth/reset-password', {
 		method: 'POST',
 		headers: {
-			'Content-Type': 'application/json',
 			'Cookie': `isVerified-ForgotPassword=${cookie?.value || ""}`
 		},
-		data: params
+		body: JSON.stringify(params)
 	});
 }
 
-// AccessToken 인증
-export async function verifyAccessToken(): Promise<boolean>
-{
-	const accessToken = await getAccessToken();
-	if (!accessToken) {
-		return false;
-	}
-
-	return await fetchJson('/api/auth/verify-token', {
-		method: 'GET'
-	}).then(res => res.ok);
-}
-
 // RefreshToken으로 AccessToken 갱신
 export async function refreshAccessToken(): Promise<boolean>
 {
@@ -96,17 +108,28 @@ export async function refreshAccessToken(): Promise<boolean>
 	}
 
     return await fetchJson('/api/auth/refresh-token', {
-        method: 'GET'
-    }).then(res => res.ok);
+        method: 'POST',
+        body: JSON.stringify({ RefreshToken: refreshToken })
+    }).then(res => res.success);
 }
 
 // 로그인 확인
-export async function isAuthenticated(): Promise<boolean> {
-	let ret = await verifyAccessToken();
+export async function checkAuthServer(): Promise<boolean>
+{
+	const accessToken = await getAccessToken();
+	if (accessToken) {
+		try {
+			const payload = JSON.parse(
+                Buffer.from(accessToken.split('.')[1], 'base64').toString()
+            );
+
+			if (payload.exp * 1000 > Date.now()) {
+				return true;
+			}
+		} catch {
 
-	if (!ret) {
-		ret = await refreshAccessToken();
+		}
 	}
 
-	return ret;
+	return await refreshAccessToken();
 }

+ 29 - 22
lib/api/forum/board.ts

@@ -11,7 +11,7 @@ import { fetchJson } from '@/lib/utils/server';
 
 // 게시판 상세 조회
 export async function fetchBoard(boardCode: string): Promise<ResultDto<BoardResponse>> {
-    return await fetchJson<BoardResponse>(`/api/forum/board/${boardCode}`, {
+    return await fetchJson<BoardResponse>(`/api/forum/boards/${boardCode}`, {
         method: 'GET',
         headers: {
             'Accept': 'application/json'
@@ -20,39 +20,46 @@ export async function fetchBoard(boardCode: string): Promise<ResultDto<BoardResp
 }
 
 // 게시판 목록 조회
-export async function fetchBoardList(boardGroupCode: string): Promise<ResultDto<BoardListResponse[]>> {
-    return await fetchJson<BoardListResponse[]>('/api/forum/board/list', {
-        method: 'POST',
+export async function fetchBoardList(boardGroupCode?: string): Promise<ResultDto<BoardListResponse[]>> {
+    const params = boardGroupCode ? `?boardGroupID=${boardGroupCode}` : '';
+    return await fetchJson<BoardListResponse[]>(`/api/forum/boards${params}`, {
+        method: 'GET',
         headers: {
-            'Accept': 'application/json',
-            'Content-Type': 'application/json'
-        },
-        data: {
-            boardGroupCode
+            'Accept': 'application/json'
         }
     });
 }
 
 // 게시판 게시글 조회
 export async function fetchPostList(params: PostListRequest): Promise<ResultDto<PostListResponse>> {
-    return await fetchJson<PostListResponse>(`/api/forum/board/${params.boardCode}/posts`, {
-        method: 'POST',
+    const queryParams = new URLSearchParams();
+    queryParams.set('boardID', String(params.boardID));
+    queryParams.set('pageNum', String(params.page));
+    queryParams.set('perPage', String(params.perPage));
+    if (params.boardPrefixID) queryParams.set('boardPrefixID', String(params.boardPrefixID));
+    if (params.sort !== undefined && params.sort !== null) queryParams.set('sort', String(params.sort));
+    if (params.search !== undefined && params.search !== null) queryParams.set('search', String(params.search));
+    if (params.keyword) queryParams.set('keyword', params.keyword);
+
+    return await fetchJson<PostListResponse>(`/api/forum/posts?${queryParams.toString()}`, {
+        method: 'GET',
         headers: {
-            'Accept': 'application/json',
-            'Content-Type': 'application/json'
-        },
-        data: params
+            'Accept': 'application/json'
+        }
     });
 }
 
 // 최근 게시글 조회
 export async function fetchLatestPosts(params: LatestPostsRequest): Promise<ResultDto<LatestPostsResponse>> {
-    return await fetchJson<LatestPostsResponse>(`/api/forum/board/${params.boardCode}/latest`, {
-        method: 'POST',
+    const queryParams = new URLSearchParams();
+    queryParams.set('boardID', String(params.boardID));
+    queryParams.set('pageNum', String(params.page));
+    queryParams.set('perPage', String(params.perPage));
+
+    return await fetchJson<LatestPostsResponse>(`/api/forum/posts?${queryParams.toString()}`, {
+        method: 'GET',
         headers: {
-            'Accept': 'application/json',
-            'Content-Type': 'application/json'
-        },
-        data: params
+            'Accept': 'application/json'
+        }
     });
-}
+}

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است