Browse Source

no message

KIM-JINO5 2 months ago
parent
commit
1a6120436f
100 changed files with 7159 additions and 610 deletions
  1. 11 0
      .claude/launch.json
  2. 30 12
      .vscode/settings.json
  3. 36 12
      CLAUDE.md
  4. 1 5
      app/(auth)/approval/page.tsx
  5. 1 3
      app/(auth)/forgot-password/page.tsx
  6. 28 20
      app/(auth)/login/google/callback/route.ts
  7. 1 3
      app/(auth)/login/page.tsx
  8. 1 2
      app/(auth)/register/page.tsx
  9. 1 3
      app/(auth)/reset-password/page.tsx
  10. 2 4
      app/(main)/(account)/change-approve/page.tsx
  11. 3 4
      app/(main)/(account)/change-email/page.tsx
  12. 3 7
      app/(main)/(account)/change-intro/page.tsx
  13. 3 7
      app/(main)/(account)/change-name/page.tsx
  14. 2 4
      app/(main)/(account)/change-password/page.tsx
  15. 3 7
      app/(main)/(account)/change-summary/page.tsx
  16. 2 6
      app/(main)/(account)/change-thumb/page.tsx
  17. 156 0
      app/(main)/(account)/charge-logs/page.tsx
  18. 192 0
      app/(main)/(account)/charge-logs/style.scss
  19. 1 2
      app/(main)/(account)/exp-logs/page.tsx
  20. 1 1
      app/(main)/(account)/loading.tsx
  21. 1 2
      app/(main)/(account)/login-log/page.tsx
  22. 1 2
      app/(main)/(account)/my-comments/page.tsx
  23. 1 2
      app/(main)/(account)/my-posts/page.tsx
  24. 7 2
      app/(main)/(account)/navTabs.tsx
  25. 5 3
      app/(main)/(account)/profile/page.tsx
  26. 2 3
      app/(main)/(account)/verify-email/page.tsx
  27. 2 3
      app/(main)/(account)/withdraw/page.tsx
  28. 1 1
      app/(main)/(forum)/board/_component/PostWriteButton.tsx
  29. 1 3
      app/(main)/(forum)/comment/_component/EditForm.tsx
  30. 2 6
      app/(main)/(forum)/comment/_component/Item.tsx
  31. 1 3
      app/(main)/(forum)/comment/_component/WriteForm.tsx
  32. 1 3
      app/(main)/(forum)/comment/view.tsx
  33. 1 7
      app/(main)/(forum)/post/[id]/view.tsx
  34. 1 2
      app/(main)/(forum)/post/_component/LatestPosts.tsx
  35. 1 2
      app/(main)/(forum)/post/_component/Report.tsx
  36. 1 3
      app/(main)/(forum)/post/edit/[id]/view.tsx
  37. 1 3
      app/(main)/(forum)/post/write/view.tsx
  38. 103 0
      app/(main)/channel/[channelSID]/page.tsx
  39. 347 0
      app/(main)/channel/[channelSID]/style.scss
  40. 109 0
      app/(main)/note/inbox/page.tsx
  41. 82 0
      app/(main)/note/send/page.tsx
  42. 122 0
      app/(main)/notification/page.tsx
  43. 0 0
      app/(main)/watch/[channelSID]/ChatSidebar.tsx
  44. 61 0
      app/(main)/watch/[channelSID]/WatchView.tsx
  45. 0 0
      app/(main)/watch/[channelSID]/chat-sidebar.scss
  46. 20 0
      app/(main)/watch/[channelSID]/page.tsx
  47. 135 0
      app/(main)/watch/[channelSID]/style.scss
  48. 28 0
      app/api/auth/youtube/callback/route.ts
  49. 11 0
      app/api/channel/[...path]/route.ts
  50. 18 0
      app/api/crew/[...path]/route.ts
  51. 44 0
      app/api/donation/[...path]/route.ts
  52. 18 0
      app/api/note/[...path]/route.ts
  53. 18 0
      app/api/notification/[...path]/route.ts
  54. 20 0
      app/api/payment/[...path]/route.ts
  55. 47 0
      app/api/payment/fail/route.ts
  56. 51 0
      app/api/payment/success/route.ts
  57. 46 0
      app/api/studio/[...path]/route.ts
  58. 11 0
      app/api/widget/[...path]/route.ts
  59. 0 37
      app/auth/login/google/callback/route.ts
  60. 0 19
      app/auth/login/google/complete/page.tsx
  61. 38 0
      app/charge/fail/page.tsx
  62. 9 0
      app/charge/layout.tsx
  63. 197 0
      app/charge/page.tsx
  64. 252 0
      app/charge/style.scss
  65. 120 0
      app/charge/success/page.tsx
  66. 4 4
      app/component/EmojiPicker.tsx
  67. 32 0
      app/component/Footer.tsx
  68. 107 0
      app/component/Header.tsx
  69. 22 172
      app/component/Layout.tsx
  70. 150 0
      app/component/NotificationBell.tsx
  71. 5 6
      app/component/Pagination.tsx
  72. 0 204
      app/component/PopupModal.scss
  73. 11 11
      app/component/PopupModal.tsx
  74. 203 0
      app/component/Profile.tsx
  75. 143 0
      app/component/channel/ChannelSidebar.tsx
  76. 200 0
      app/component/channel/style.scss
  77. 52 5
      app/globals.scss
  78. 212 0
      app/remote/[channelSID]/page.tsx
  79. 236 0
      app/remote/[channelSID]/style.scss
  80. 7 0
      app/remote/layout.tsx
  81. 360 0
      app/studio/Sidebar.tsx
  82. 33 0
      app/studio/context.tsx
  83. 14 0
      app/studio/dashboard/page.tsx
  84. 8 0
      app/studio/dashboard/style.scss
  85. 389 0
      app/studio/donation/alert/_components/AlertFormPanel.tsx
  86. 287 0
      app/studio/donation/alert/_components/AlertListPanel.tsx
  87. 106 0
      app/studio/donation/alert/_components/AlertPreviewPanel.tsx
  88. 200 0
      app/studio/donation/alert/add/page.tsx
  89. 165 0
      app/studio/donation/alert/constants.ts
  90. 24 0
      app/studio/donation/alert/context.tsx
  91. 225 0
      app/studio/donation/alert/edit/[id]/page.tsx
  92. 55 0
      app/studio/donation/alert/layout.tsx
  93. 74 0
      app/studio/donation/alert/list/page.tsx
  94. 5 0
      app/studio/donation/alert/page.tsx
  95. 698 0
      app/studio/donation/alert/style.scss
  96. 11 0
      app/studio/donation/alert/types.ts
  97. 190 0
      app/studio/donation/crew/page.tsx
  98. 13 0
      app/studio/donation/crew/style.scss
  99. 274 0
      app/studio/donation/goal/_components/GoalFormPanel.tsx
  100. 229 0
      app/studio/donation/goal/_components/GoalListPanel.tsx

+ 11 - 0
.claude/launch.json

@@ -0,0 +1,11 @@
+{
+  "version": "0.0.1",
+  "configurations": [
+    {
+      "name": "frontend",
+      "runtimeExecutable": "npm",
+      "runtimeArgs": ["run", "dev"],
+      "port": 3000
+    }
+  ]
+}

+ 30 - 12
.vscode/settings.json

@@ -1,13 +1,31 @@
 {
-  "files.trimTrailingWhitespace": true,
-  "terminal.integrated.profiles": {
-    "PowerShell (Admin)": {
-      "path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
-      "args": ["-NoExit", "-Command", "Start-Process powershell -Verb RunAs"]
-    }
-  },
-  "terminal.integrated.defaultProfile.windows": "PowerShell (Admin)",
-  "editor.tabSize": 4,
-  "editor.insertSpaces": true,
-  "editor.formatOnSave": false
-}
+    "[json]": {
+        "editor.tabSize": 4,
+        "editor.insertSpaces": true
+    },
+    "[jsonc]": {
+        "editor.tabSize": 4,
+        "editor.insertSpaces": true
+    },
+    "files.trimTrailingWhitespace": true,
+    "terminal.integrated.profiles": {
+        "PowerShell (Admin)": {
+            "path": "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
+            "args": ["-NoExit", "-Command", "Start-Process powershell -Verb RunAs"]
+        }
+    },
+    "terminal.integrated.defaultProfile.windows": "PowerShell (Admin)",
+    "editor.tabSize": 4,
+    "editor.insertSpaces": true,
+    "editor.formatOnSave": false,
+    "scss.lint.unknownAtRules": "ignore",
+    "color-highlight.markerType": "dot-before",
+    "color-highlight.languages": [
+        "css",
+        "scss",
+        "sass",
+        "less",
+        "typescript",
+        "typescriptreact"
+    ]
+}

+ 36 - 12
CLAUDE.md

@@ -15,16 +15,22 @@ DPOT - Next.js 15 App Router 기반 Creators Support and donate platform fronten
 app/
   ├── (auth)/          → 인증 (login, register, forgot-password, reset-password, welcome, approval)
   ├── (main)/          → 메인 레이아웃 그룹
-  │   ├── (account)/   → 계정 관리 (profile, change-password, change-email, change-name, change-thumb,
-  │   │                   change-intro, change-summary, change-approve, verify-email,
-  │   │                   login-log, my-posts, my-comments, exp-logs, withdraw)
+  │   ├── (account)/   → 계정 관리 (profile, change-password, change-email 등)
   │   ├── (forum)/     → 게시판 (board, post, comment, latest)
+  │   ├── channel/     → 채널 상세 ([channelSID])
+  │   ├── note/        → 쪽지 (inbox, send)
+  │   ├── notification/→ 알림 목록
   │   ├── support/     → 고객지원 (faq, guide, contact)
   │   └── docs/        → 문서 페이지
-  ├── api/             → Route Handler (auth, document, faq, forum, mypage, popup, uploads)
+  ├── widget/          → OBS 위젯 (alert, goal, rank, crew — 별도 레이아웃)
+  ├── remote/          → 후원 리모콘 ([channelSID] — 별도 레이아웃)
+  ├── api/             → Route Handler (auth, channel, donation, note, notification, payment, forum 등)
   ├── auth/            → 인증 관련
-  ├── component/       → 공통 레이아웃 컴포넌트 (Layout, PopupModal, Editor, ChatSidebar 등)
-  └── styles/          → 글로벌 스타일
+  ├── component/       → 공통 레이아웃 컴포넌트
+  │   ├── channel/     → ChannelSidebar (좌측), ChannelSidebarItem
+  │   ├── NotificationBell, ProfileDropdown, ChargeModal
+  │   └── Layout, PopupModal, Editor
+  └── styles/          → 글로벌 + 컴포넌트 SCSS
 components/ui/         → shadcn/ui 컴포넌트 (accordion, button, checkbox, dialog, dropdown-menu,
                           input, label, select, textarea, HotIndicator)
 contexts/              → React Context Provider
@@ -33,7 +39,8 @@ contexts/              → React Context Provider
   ├── configProvider.tsx    → 시스템 설정 (initialConfig prop, React.cache)
   ├── signalrProvider.tsx   → SignalR WebSocket (채팅)
   └── themeProvider.tsx     → 테마 관리
-hooks/                 → 커스텀 훅 (useAuth, useChat, useDragScroll, useTheme, useErrorAlert)
+hooks/                 → 커스텀 훅 (useAuth, useChat, useDragScroll, useTheme, useErrorAlert,
+                          useDonationAlert, useDonationHub, useNotification)
 lib/
   ├── api/             → 서버 사이드 API 호출 함수 (Server Actions)
   │   ├── auth.ts      → 인증 API
@@ -64,9 +71,12 @@ middleware.ts          → 인증 미들웨어 (토큰 검증, 리다이렉트)
 - 서버 전용 함수: `'use server'` 선언 (Server Actions, lib/utils/server.ts)
 - 컴포넌트 파일명: PascalCase (`PopupModal.tsx`, `Layout.tsx`)
 - 페이지/라우트: 소문자 kebab-case 디렉토리 (`support/faq/`)
-- 스타일: 각 페이지 디렉토리에 `style.scss` 파일
+- 스타일: `app/styles/` 에 컴포넌트별 SCSS 파일 (inline CSS 사용 금지 → SCSS 또는 Tailwind)
+- SCSS 클래스명: BEM 네이밍 (`block__element--modifier`)
 - 파일 내용의 마지막 줄 제거 (빈 줄 없이 끝남)
 - type 을 지정할 때 `|` 문자 양쪽에 공백 제거해(예시로 `string|null|undefined`)
+- button 태그에 submit type이 아니면 `type="button"` 필수
+- C# 람다 `) => {` 한 줄에 붙여 작성 (개행 금지) — Frontend에서도 동일 스타일 적용
 
 ## Architecture Rules
 
@@ -76,15 +86,17 @@ middleware.ts          → 인증 미들웨어 (토큰 검증, 리다이렉트)
   - 클라이언트에서 직접 백엔드 호출 안 함 (CORS, 쿠키 처리)
 - **서버 데이터 흐름**: Server Component → `fetchJson()` → 백엔드 직접 호출
 - **인증**: JWT Bearer 토큰 (accessToken/refreshToken, httpOnly 쿠키)
-- **실시간**: SignalR WebSocket (채팅)
+- **실시간**: SignalR WebSocket
+  - AppHub (`/hubs/app`) — 채팅, 알림, 쪽지, 접속자, 크루 초대
+  - DonationHub (`/hubs/donation`) — 후원 위젯/리모콘
+- **결제**: 다날 PG (`@danalpay/javascript-sdk`)
 
 ## Code Conventions
 
 ### 사용하는 패턴
-- `fetchApi<T>()` — 클라이언트에서 Route Handler 호출 (`lib/utils/client.ts`)
+- `fetchApi<T>()` — 클라이언트에서 Route Handler 호출 (`lib/utils/client.ts`), 실패 시 자동 throw (에러 무시 시 `{ silent: true }`)
 - `fetchJson<T>()` — 서버에서 백엔드 직접 호출 (`lib/utils/server.ts`)
 - `ResultDto<T>` — 모든 API 응답 래퍼 (`{ success, status, message, data, errors }`)
-- `throwError(res)` — 에러 응답 시 예외 발생
 - `cn()` — Tailwind 클래스 병합 (clsx + tailwind-merge)
 - `dangerouslySetInnerHTML` — 서버에서 받은 HTML 콘텐츠 렌더링
 - Route Group — `(auth)`, `(main)/(account)`, `(main)/(forum)` 으로 관련 페이지 그룹화
@@ -114,11 +126,23 @@ ThemeProvider → SignalRProvider → AuthProvider → MemberProvider → Config
 ```
 
 - `ThemeProvider`: 테마 관리 (라이트/다크 모드)
-- `SignalRProvider`: WebSocket 연결 (채팅) — accessToken, signalRChatUrl prop
+- `SignalRProvider`: WebSocket 연결 (AppHub) — accessToken, signalRUrl prop
 - `AuthProvider`: 로그인 상태 관리, 토큰 갱신
 - `MemberProvider`: 현재 사용자 정보
 - `ConfigProvider`: `initialConfig` prop으로 서버에서 설정 전달 (React.cache)
 
+## 용어 규칙
+
+- **POINT (포인트)**: 후원 가능한 금액 (PG 충전 → PgCharged 잔액)
+- **머니 (Money)**: 후원 받은 금액 (Donation 잔액 → 출금 가능)
+
+## 레이아웃 구조
+
+- **좌측 사이드바** (`ChannelSidebar`): 채널 목록, 온/오프라인 LED, 시청자 수, 다크모드/다국어/즐겨찾기
+- **우측**: 방송 상세 페이지에서 채팅 표시 (고정 아님, 페이지별 조건부)
+- **모바일**: 하단 네비게이션 제거 → 상단 가로 스크롤 탭 (YouTube 모바일 스타일)
+- **위젯/리모콘**: 별도 Route Group, root layout 미사용
+
 ## API Route Handler 패턴
 
 ```typescript

+ 1 - 5
app/(auth)/approval/page.tsx

@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
 import { VerificationType } from '@/constants/common';
 import { VerifyEmailRequest, ResendEmailRequest } from '@/types/request/auth';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function Approval()
@@ -125,10 +125,6 @@ export default function Approval()
 				body: { Email: email, Code: verifyCode, Type: Number(type) } as VerifyEmailRequest
 			});
 
-            if (!res.success) {
-                throwError(res);
-            }
-
         	sessionStorage.setItem('email', email);
 
 			switch (type) {

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

@@ -5,7 +5,7 @@ import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
 import { ForgotPasswordRequest } from '@/types/request/auth';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { VerificationType } from '@/constants/common';
 import Loading from '@/app/component/Loading';
 
@@ -45,8 +45,6 @@ export default function ForgotPassword()
                 body: { Email: email } as ForgotPasswordRequest
             });
 
-			throwError(res);
-
 			// 시간 제한 생성
 			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();
 			const callbackURL: string = location.pathname;

+ 28 - 20
app/(auth)/login/google/callback/route.ts

@@ -15,27 +15,35 @@ export async function POST(request: NextRequest)
 		return NextResponse.redirect(url);
 	}
 
-	// 백엔드 google-login API 직접 호출
-	const res = await fetch(`${API_URL}/api/auth/google-login`, {
-		method: 'POST',
-		headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
-		body: JSON.stringify({ credential }),
-		cache: 'no-store'
-	});
+	try {
+		// 백엔드 google-login API 직접 호출
+		const res = await fetch(`${API_URL}/api/auth/google-login`, {
+			method: 'POST',
+			headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
+			body: JSON.stringify({ credential }),
+			cache: 'no-store'
+		});
 
-	const json = await res.json();
+		const json = await res.json();
+		console.log('[Google Callback] Backend response:', res.status, JSON.stringify(json).substring(0, 200));
 
-	if (json.success && json.data) {
-		const data = json.data as LoginResponse;
-		const response = NextResponse.redirect(new URL('/login/google/complete', request.url));
-		const cookieOptions = { httpOnly: true, path: '/' };
-		response.cookies.set('accessToken', data.accessToken, cookieOptions);
-		response.cookies.set('refreshToken', data.refreshToken, cookieOptions);
-		return response;
-	}
+		if (json.success && json.data) {
+			const data = json.data as LoginResponse;
+			const response = NextResponse.redirect(new URL('/login/google/complete', request.url));
+			const cookieOptions = { httpOnly: true, path: '/' };
+			response.cookies.set('accessToken', data.accessToken, cookieOptions);
+			response.cookies.set('refreshToken', data.refreshToken, cookieOptions);
+			return response;
+		}
 
-	// 실패 시 로그인 페이지로 리다이렉트
-	const url = new URL('/login', request.url);
-	url.searchParams.set('error', json.message || 'Google 로그인에 실패했습니다.');
-	return NextResponse.redirect(url);
+		// 실패 시 로그인 페이지로 리다이렉트
+		const errorUrl = new URL('/login', request.url);
+		errorUrl.searchParams.set('error', json.message || 'Google 로그인에 실패했습니다.');
+		return NextResponse.redirect(errorUrl);
+	} catch (e) {
+		console.error('[Google Callback] Error:', e);
+		const errorUrl = new URL('/login', request.url);
+		errorUrl.searchParams.set('error', e instanceof Error ? e.message : 'Server failure');
+		return NextResponse.redirect(errorUrl);
+	}
 }

+ 1 - 3
app/(auth)/login/page.tsx

@@ -5,7 +5,7 @@ import Link from 'next/link';
 import { Checkbox } from '@/components/ui/checkbox';
 import { useState, useEffect, useRef } from 'react';
 import { GoogleLogin } from '@react-oauth/google';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { LoginRequest } from '@/types/request/auth';
 import { LoginResponse } from '@/types/response/auth';
 import useAuth from '@/hooks/useAuth';
@@ -62,7 +62,6 @@ export default function Page()
                 body: { Email: email, Password: password } as LoginRequest
             });
 
-            throwError(res);
             login(rememberMe);
 
         } catch (err) {
@@ -82,7 +81,6 @@ export default function Page()
                 body: { credential: credentialResponse.credential }
             });
 
-            throwError(res);
             login(rememberMe);
 
         } catch (err) {

+ 1 - 2
app/(auth)/register/page.tsx

@@ -8,7 +8,7 @@ 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 { fetchApi } from '@/lib/utils/client';
 import { RegisterRequest } from '@/types/request/auth';
 import { RegisterResponse } from '@/types/response/auth';
 
@@ -73,7 +73,6 @@ export default function Page()
                 } as RegisterRequest
             });
 
-			throwError(res);
 
 			// 시간 제한 생성
 			const expiration: string = (Date.now() + 10 * 60 * 1000).toString();

+ 1 - 3
app/(auth)/reset-password/page.tsx

@@ -5,7 +5,7 @@ import Link from 'next/link';
 import { useRouter } from 'next/navigation';
 import { useState, useEffect, useRef } from 'react';
 import { ResetPasswordRequest } from '@/types/request/auth';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ResetPassword()
@@ -72,8 +72,6 @@ export default function ResetPassword()
                 body: { Email: email, Password: password, RePassword: rePassword } as ResetPasswordRequest
             });
 
-            throwError(res);
-
 			sessionStorage.clear();
 			alert(res.message);
 			router.push('/login');

+ 2 - 4
app/(main)/(account)/change-approve/page.tsx

@@ -5,7 +5,7 @@ import Link from 'next/link';
 import { useState, useEffect } from 'react';
 import { useMemberContext } from '@/contexts/memberProvider';
 import { ChangeApproveRequest } from '@/types/request/account';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ChangeApprove()
@@ -54,9 +54,7 @@ export default function ChangeApprove()
 				IsReceiveEmail: notifications.isReceiveEmail,
 				IsReceiveNote: notifications.isReceiveNote
 			} as ChangeApproveRequest
-		}).then(res => {
-			throwError(res);
-
+		}).then(() => {
 			member.memberApprove.isReceiveSMS = notifications.isReceiveSMS;
 			member.memberApprove.isReceiveEmail = notifications.isReceiveEmail;
 			member.memberApprove.isReceiveNote = notifications.isReceiveNote;

+ 3 - 4
app/(main)/(account)/change-email/page.tsx

@@ -7,7 +7,7 @@ import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { useConfigContext } from '@/contexts/configProvider';
 import { ChangeEmailRequest } from '@/types/request/account';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ChangeEmail()
@@ -38,8 +38,7 @@ export default function ChangeEmail()
 		fetchApi('/api/mypage/email', {
 			method: 'POST',
 			body: { NewEmail: newEmail } as ChangeEmailRequest
-		}).then((res) => {
-			throwError(res);
+		}).then(() => {
 			setComplete(true);
 		}).catch(err => {
 			setError(err.message);
@@ -122,7 +121,7 @@ export default function ChangeEmail()
 					처음부터 다시 시도해 주십시오.
 				</blockquote>
 				<br />
-				<button className="btn btn-default" onClick={refresh}>다시 시도하기</button>
+				<button type="button" className="btn btn-default" onClick={refresh}>다시 시도하기</button>
 			</>
 			}
 		</div>

+ 3 - 7
app/(main)/(account)/change-intro/page.tsx

@@ -7,7 +7,7 @@ import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { ChangeIntroRequest } from '@/types/request/account';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 import Editor from '@/app/component/Editor';
 
@@ -41,9 +41,7 @@ export default function ChangeIntro()
 		fetchApi('/api/mypage/intro', {
 			method: 'POST',
 			body: { Intro: newIntro } as ChangeIntroRequest
-		}).then((res) => {
-			throwError(res);
-
+		}).then(() => {
 			member.intro = newIntro;
 			setMember(member);
 			setNewIntro(newIntro);
@@ -66,9 +64,7 @@ export default function ChangeIntro()
 
 			setLoading(true);
 
-			fetchApi('/api/mypage/intro', { method: 'DELETE' }).then((res) => {
-				throwError(res);
-
+			fetchApi('/api/mypage/intro', { method: 'DELETE' }).then(() => {
 				member.intro = null;
 				setMember(member);
 				localStorage.setItem('member', JSON.stringify(member));

+ 3 - 7
app/(main)/(account)/change-name/page.tsx

@@ -7,7 +7,7 @@ import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { ChangeNameRequest } from '@/types/request/account';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ChangeName()
@@ -36,9 +36,7 @@ export default function ChangeName()
 		fetchApi('/api/mypage/name', {
 			method: 'POST',
 			body: { Name: newName } as ChangeNameRequest
-		}).then((res) => {
-			throwError(res);
-
+		}).then(() => {
 			member.name = newName;
 			setMember(member);
 			localStorage.setItem('member', JSON.stringify(member));
@@ -64,9 +62,7 @@ export default function ChangeName()
 
 			setLoading(true);
 
-			fetchApi('/api/mypage/name', { method: 'DELETE' }).then((res) => {
-				throwError(res);
-
+			fetchApi('/api/mypage/name', { method: 'DELETE' }).then(() => {
 				member.name = null;
 				setMember(member);
 				localStorage.setItem('member', JSON.stringify(member));

+ 2 - 4
app/(main)/(account)/change-password/page.tsx

@@ -7,7 +7,7 @@ import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { ChangePasswordRequest } from '@/types/request/account';
-import { fetchApi, getPasswordPolicyMessage, throwError } from '@/lib/utils/client';
+import { fetchApi, getPasswordPolicyMessage } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 import NavTabs from '../navTabs';
 
@@ -71,9 +71,7 @@ export default function ChangePassword()
 				NewPassword: formData.newPassword,
 				ConfirmPassword: formData.confirmPassword
 			} as ChangePasswordRequest
-		}).then((res) => {
-			throwError(res);
-
+		}).then(() => {
 			setComplete(true);
 		}).catch(err => {
 			setError(err.message);

+ 3 - 7
app/(main)/(account)/change-summary/page.tsx

@@ -7,7 +7,7 @@ import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { ChangeSummaryRequest } from '@/types/request/account';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ChangeSummary()
@@ -36,9 +36,7 @@ export default function ChangeSummary()
 		fetchApi('/api/mypage/summary', {
 			method: 'POST',
 			body: { Summary: newSummary } as ChangeSummaryRequest
-		}).then((res) => {
-			throwError(res);
-
+		}).then(() => {
 			member.summary = newSummary;
 			setMember(member);
 			localStorage.setItem('member', JSON.stringify(member));
@@ -64,9 +62,7 @@ export default function ChangeSummary()
 
 			setLoading(true);
 
-			fetchApi('/api/mypage/summary', { method: 'DELETE' }).then((res) => {
-				throwError(res);
-
+			fetchApi('/api/mypage/summary', { method: 'DELETE' }).then(() => {
 				member.summary = null;
 				setMember(member);
 				localStorage.setItem('member', JSON.stringify(member));

+ 2 - 6
app/(main)/(account)/change-thumb/page.tsx

@@ -6,7 +6,7 @@ import Image from 'next/image';
 import { useState } from 'react';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function ChangeThumb()
@@ -42,8 +42,6 @@ export default function ChangeThumb()
 			method: 'POST',
 			body: formData
 		}).then((res) => {
-			throwError(res);
-
 			member.thumb = (res.data?.thumbUrl || null);
 			setMember(member);
 			localStorage.setItem('member', JSON.stringify(member));
@@ -88,9 +86,7 @@ export default function ChangeThumb()
 
 		fetchApi('/api/mypage/thumb', {
 			method: 'DELETE'
-		}).then((res) => {
-			throwError(res);
-
+		}).then(() => {
 			member.thumb = null;
 			setMember(member);
 			localStorage.setItem('member', JSON.stringify(member));

+ 156 - 0
app/(main)/(account)/charge-logs/page.tsx

@@ -0,0 +1,156 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect } from 'react';
+import { LoginLogType } from '@/constants/common';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import type { ChargeLogsResponse } from '@/types/response/account/chargeLogs';
+import Loading from '@/app/component/Loading';
+import Pagination from '@/app/component/Pagination';
+import NavTabs from '../navTabs';
+
+const STATUS_MAP: Record<string, { label: string; cls: string }> = {
+	Paid: { label: '완료', cls: 'status--paid' },
+	Pending: { label: '대기', cls: 'status--pending' },
+	WaitingDeposit: { label: '입금대기', cls: 'status--pending' },
+	Failed: { label: '실패', cls: 'status--failed' },
+	Cancelled: { label: '취소', cls: 'status--cancelled' }
+};
+
+const METHOD_MAP: Record<string, string> = {
+	Card: '신용카드',
+	VirtualAccount: '가상계좌',
+	Mobile: '휴대폰',
+	Transfer: '계좌이체',
+	NaverPay: '네이버페이',
+	KakaoPay: '카카오페이',
+	Payco: '페이코',
+	Integrated: '통합결제'
+};
+
+export default function ChargeLogs()
+{
+	const [error, setError] = useState<string>('');
+	const [loading, setLoading] = useState<boolean>(true);
+	const [page, setPage] = useState<number>(1);
+	const [type, setType] = useState<LoginLogType>(LoginLogType.Today);
+	const [data, setData] = useState<ChargeLogsResponse>({
+		total: 0,
+		list: []
+	});
+
+	useEffect(() => {
+		if (error) {
+			alert(error);
+			setError('');
+		}
+	}, [error]);
+
+	useEffect(() => {
+		setLoading(true);
+
+		fetchApi<ChargeLogsResponse>(`/api/mypage/charge-logs?type=${type}&page=${page}&perPage=20`).then((res) => {
+			setData(res.data!);
+		}).catch(err => {
+			setError(err.message);
+		}).finally(() => {
+			setLoading(false);
+		});
+
+	}, [type, page]);
+
+	useEffect(() => {
+		setPage(1);
+	}, [type]);
+
+	const tabItems = [
+		{ label: "오늘", value: LoginLogType.Today },
+		{ label: "1주일", value: LoginLogType.Week },
+		{ label: "1개월", value: LoginLogType.Month },
+		{ label: "3개월", value: LoginLogType.QuarterYear },
+		{ label: "6개월", value: LoginLogType.HalfYear }
+	];
+
+	const getStatus = (status: string) => STATUS_MAP[status] ?? { label: status, cls: '' };
+	const getMethod = (method: string) => METHOD_MAP[method] ?? method;
+
+	return (
+		<>
+		<NavTabs />
+
+		<div id="chargeLogs">
+			{loading && <Loading />}
+
+			<h1>(P) 충전 내역</h1>
+			<div className="charge-logs__header">
+				<div className="charge-logs__summary">합계: {data.total}</div>
+				<div className="charge-logs__tabs">
+					{tabItems.map((item, i) => (
+						<button type="button" key={i} className={type === item.value ? 'active' : ''}
+							onClick={() => setType(item.value)}>{item.label}
+						</button>
+					))}
+				</div>
+			</div>
+
+			<section className="charge-logs__list">
+				<article>
+					<ul>
+						<li>일시</li>
+						<li>주문번호</li>
+						<li>결제 수단</li>
+						<li>결제 금액</li>
+						<li>포인트</li>
+						<li>상태</li>
+					</ul>
+				</article>
+				<article>
+					{data.list.length > 0 ? (
+						data.list.map((row) => {
+							const st = getStatus(row.status);
+							return (
+								<section key={row.id}>
+									{/* PC */}
+									<ol>
+										<li>{getDateTime(row.paidAt ?? row.createdAt)}</li>
+										<li className="charge-logs__order-id">{row.orderID}</li>
+										<li>{getMethod(row.paymentMethod)}</li>
+										<li>{row.amount.toLocaleString()}원</li>
+										<li className="amount-plus">+{row.pointAmount.toLocaleString()}P</li>
+										<li><span className={st.cls}>{st.label}</span></li>
+									</ol>
+
+									{/* Mobile */}
+									<dl hidden>
+										<dt>
+											<div className='flex justify-between'>
+												<div>{row.amount.toLocaleString()}원 충전</div>
+												<div>
+													<small className="charge-logs__order-id">{row.orderID}</small>
+												</div>
+											</div>
+										</dt>
+										<dd>
+											<ul>
+												<li className="amount-plus">+{row.pointAmount.toLocaleString()}P</li>
+												<li><span className={st.cls}>{st.label}</span></li>
+												<li>{getDateTime(row.paidAt ?? row.createdAt)}</li>
+											</ul>
+										</dd>
+									</dl>
+								</section>
+							);
+						})
+					) : (
+						<p className="empty">충전 기록이 없습니다.</p>
+					)}
+				</article>
+			</section>
+
+			{data.list.length > 0 && (
+				<Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
+			)}
+		</div>
+		</>
+	);
+}

+ 192 - 0
app/(main)/(account)/charge-logs/style.scss

@@ -0,0 +1,192 @@
+#chargeLogs {
+	padding: 0 32px 32px 32px;
+
+	h1 {
+		font-size: 22px;
+		margin-bottom: 10px;
+	}
+
+	.charge-logs {
+		&__header {
+			display: flex;
+			justify-content: space-between;
+			align-items: center;
+			margin-bottom: 10px;
+			flex-wrap: wrap;
+			gap: 0.5rem;
+		}
+
+		&__summary {
+			font-size: 16px;
+		}
+
+		&__tabs {
+			display: flex;
+
+			button {
+				padding: 0.5rem 1rem;
+				font-size: 0.875rem;
+				color: var(--text-secondary);
+				border-bottom: 2px solid transparent;
+				transition: color 0.2s, border-color 0.2s;
+
+				&:hover {
+					color: var(--brand-orange);
+					border-bottom-color: var(--brand-orange);
+				}
+
+				&.active {
+					color: var(--text-link);
+					font-weight: 600;
+					border-bottom-color: var(--text-link);
+				}
+			}
+		}
+
+		&__list {
+			border-top: 1px solid var(--border-default);
+			margin: 0.75rem 0;
+
+			// 헤더 행
+			article:nth-of-type(1) {
+				background-color: #f9fafb;
+				border-bottom: 1px solid var(--border-default);
+				padding: 0.5rem 0;
+
+				ul {
+					display: grid;
+					grid-template-columns:
+						clamp(100px, 14%, 140px)
+						1fr
+						clamp(70px, 10%, 90px)
+						clamp(70px, 10%, 90px)
+						clamp(70px, 10%, 90px)
+						clamp(50px, 8%, 70px);
+					column-gap: 0.75rem;
+
+					li {
+						text-align: center;
+						font-size: 0.875rem;
+						color: var(--text-secondary);
+					}
+				}
+
+				@media (max-width: 1024px) {
+					display: none;
+				}
+			}
+
+			// 데이터 행
+			article:nth-of-type(2) {
+				box-sizing: border-box;
+
+				section {
+					padding: 0.5rem 0;
+					box-sizing: inherit;
+					border-bottom: 1px solid var(--border-default);
+
+					&:hover {
+						background-color: #faffd1;
+					}
+
+					// PC
+					> ol {
+						display: grid;
+						grid-template-columns:
+							clamp(100px, 14%, 140px)
+							1fr
+							clamp(70px, 10%, 90px)
+							clamp(70px, 10%, 90px)
+							clamp(70px, 10%, 90px)
+							clamp(50px, 8%, 70px);
+						column-gap: 0.75rem;
+						align-items: center;
+
+						> li {
+							text-align: center;
+							font-size: 0.875rem;
+						}
+					}
+
+					// Mobile
+					dl {
+						dt {
+							font-size: 1rem;
+							word-break: keep-all;
+							overflow-wrap: break-word;
+						}
+
+						dd {
+							ul {
+								display: flex;
+								flex-direction: row;
+								flex-wrap: nowrap;
+								justify-content: start;
+								padding-top: 0.4rem;
+								column-gap: 1rem;
+
+								li {
+									font-size: 0.813rem;
+									color: var(--text-muted);
+
+									&:last-child {
+										flex-grow: 1;
+										text-align: right;
+									}
+								}
+							}
+						}
+					}
+
+					@media (max-width: 1024px) {
+						ol {
+							display: none;
+						}
+						dl {
+							display: block;
+						}
+					}
+				}
+
+				> .empty {
+					text-align: center;
+					padding: 2.5rem;
+					color: var(--text-muted);
+				}
+			}
+
+			.amount-plus {
+				font-weight: 600;
+				color: var(--color-danger) !important;
+			}
+
+			.status--paid {
+				color: var(--color-success);
+				font-weight: 600;
+			}
+
+			.status--pending {
+				color: var(--color-warning);
+				font-weight: 600;
+			}
+
+			.status--failed {
+				color: var(--color-danger);
+				font-weight: 600;
+			}
+
+			.status--cancelled {
+				color: var(--text-muted);
+			}
+		}
+
+		&__order-id {
+			min-width: 0;
+			overflow: hidden;
+			text-overflow: ellipsis;
+			white-space: nowrap;
+			font-family: monospace;
+			font-size: 0.8rem !important;
+		}
+	}
+}

+ 1 - 2
app/(main)/(account)/exp-logs/page.tsx

@@ -3,7 +3,7 @@
 import './style.scss';
 import { useState, useEffect } from 'react';
 import { LoginLogType } from '@/constants/common';
-import { fetchApi, throwError, getDateTime } from '@/lib/utils/client';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
 import type { ExpLogsResponse } from '@/types/response/account/expLogs';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
@@ -30,7 +30,6 @@ export default function ExpLogs()
 	useEffect(() => {
 		setLoading(true);
 		fetchApi<ExpLogsResponse>(`/api/mypage/exp-logs?type=${type}&page=${page}&perPage=20`).then((res) => {
-			throwError(res);
 			setData(res.data!);
 		}).catch(err => {
 			setError(err.message);

+ 1 - 1
app/(main)/(account)/loading.tsx

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

+ 1 - 2
app/(main)/(account)/login-log/page.tsx

@@ -3,7 +3,7 @@
 import './style.scss';
 import { useState, useEffect } from 'react';
 import { LoginLogType } from '@/constants/common';
-import { fetchApi, throwError, getDateTime } from '@/lib/utils/client';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
 import { LoginLogsResponse } from '@/types/response/account/loginLogs';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
@@ -30,7 +30,6 @@ export default function LoginLog()
 	useEffect(() => {
 		setLoading(true);
 		fetchApi<LoginLogsResponse>(`/api/mypage/login-logs?type=${type}&page=${page}&perPage=20`).then((res) => {
-			throwError(res);
 			setLogs(res.data!);
 		}).catch(err => {
 			setError(err.message);

+ 1 - 2
app/(main)/(account)/my-comments/page.tsx

@@ -3,7 +3,7 @@
 import './style.scss';
 import { useState, useEffect } from 'react';
 import Link from 'next/link';
-import { fetchApi, throwError, getDateTime } from '@/lib/utils/client';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
 import type { MyCommentsResponse } from '@/types/response/account/myComments';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
@@ -31,7 +31,6 @@ export default function MyComments()
 	useEffect(() => {
 		setLoading(true);
 		fetchApi<MyCommentsResponse>(`/api/mypage/comments?page=${page}&perPage=20`).then((res) => {
-			throwError(res);
 			setData(res.data!);
 		}).catch(err => {
 			setError(err.message);

+ 1 - 2
app/(main)/(account)/my-posts/page.tsx

@@ -3,7 +3,7 @@
 import './style.scss';
 import { useState, useEffect } from 'react';
 import Link from 'next/link';
-import { fetchApi, throwError, getDateTime } from '@/lib/utils/client';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
 import type { MyPostsResponse } from '@/types/response/account/myPosts';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
@@ -32,7 +32,6 @@ export default function MyPosts()
 	useEffect(() => {
 		setLoading(true);
 		fetchApi<MyPostsResponse>(`/api/mypage/posts?page=${page}&perPage=20`).then((res) => {
-			throwError(res);
 			setData(res.data!);
 		}).catch(err => {
 			setError(err.message);

+ 7 - 2
app/(main)/(account)/navTabs.tsx

@@ -10,13 +10,17 @@ import { faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons
 
 const SCROLL_AMOUNT = 120;
 
-export default function NavTabs() {
+export default function NavTabs()
+{
 	const pathname = usePathname();
 	const dragScroll = useDragScroll<HTMLDivElement>();
 
 	const scroll = useCallback((dir: 'left' | 'right') => {
 		const el = dragScroll.ref.current;
-		if (!el) return;
+		if (!el) {
+			return;
+		}
+
 		el.scrollBy({ left: dir === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT, behavior: 'smooth' });
 	}, [dragScroll.ref]);
 
@@ -31,6 +35,7 @@ export default function NavTabs() {
 					{ href: "/change-password", label: "비밀번호 변경" },
 					{ href: "/my-posts", label: "작성 게시글" },
 					{ href: "/my-comments", label: "작성 댓글" },
+					{ href: "/charge-logs", label: "(P) 충전 내역" },
 					{ href: "/exp-logs", label: "경험치 내역" },
 					{ href: "/login-log", label: "로그인 기록" },
 					{ href: "/withdraw", label: "회원탈퇴" }

+ 5 - 3
app/(main)/(account)/profile/page.tsx

@@ -4,7 +4,7 @@ import './style.scss';
 import Link from 'next/link';
 import Image from 'next/image';
 import useAuth from '@/hooks/useAuth';
-import { formatDate, stripHtmlTags } from '@/lib/utils/client';
+import { getDateTime, stripHtmlTags } from '@/lib/utils/client';
 import NavTabs from '../navTabs';
 
 export default function Profile()
@@ -54,11 +54,11 @@ export default function Profile()
 						</tr>
 						<tr>
 							<th>회원가입 일시</th>
-							<td colSpan={2}>{formatDate(member.createdAt)}</td>
+							<td colSpan={2}>{getDateTime(member.createdAt)}</td>
 						</tr>
 						<tr>
 							<th>마지막 로그인 일시</th>
-							<td colSpan={2}>{formatDate(member.lastLoginAt)}</td>
+							<td colSpan={2}>{getDateTime(member.lastLoginAt)}</td>
 						</tr>
 						<tr>
 							<th>알림 수신</th>
@@ -146,6 +146,8 @@ export default function Profile()
 						</tr>
 					</tbody>
 				</table>
+
+				<Link href='/studio' className='text-sky-600 hover:text-red-600 hover:underline'>채널 관리</Link>
 			</div>
 
 			<br />

+ 2 - 3
app/(main)/(account)/verify-email/page.tsx

@@ -4,7 +4,7 @@ import './style.scss';
 import Link from 'next/link';
 import { useSearchParams } from 'next/navigation';
 import { useState, useEffect } from 'react';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 
 export default function VerifyEmail()
@@ -31,8 +31,7 @@ export default function VerifyEmail()
 			return;
 		}
 
-		fetchApi(`/api/mypage/email/verify?token=${encodeURIComponent(token)}`).then((res) => {
-			throwError(res);
+		fetchApi(`/api/mypage/email/verify?token=${encodeURIComponent(token)}`).then(() => {
 			localStorage.removeItem('member');
 			setComplete(true);
 		}).catch(err => {

+ 2 - 3
app/(main)/(account)/withdraw/page.tsx

@@ -3,7 +3,7 @@
 import './style.scss';
 import Link from 'next/link';
 import { useState, useEffect, useRef } from 'react';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import useAuth from '@/hooks/useAuth';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import Loading from '@/app/component/Loading';
@@ -43,8 +43,7 @@ export default function Withdraw()
 			fetchApi('/api/mypage/withdraw', {
 				method: 'POST',
 				body: { password }
-			}).then((res) => {
-				throwError(res);
+			}).then(() => {
 				setComplete(true);
 				localStorage.removeItem('member');
 			}).catch(err => {

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

@@ -48,7 +48,7 @@ export default function PostWriteButton({ alwaysShowButton, boardCode, boardMeta
 	return (
 		<>
 			<section aria-label='글쓰기 버튼'>
-				<button value={`/post/write?board=${boardCode}`} className='btn btn-submit' onClick={handleClick}>글쓰기</button>
+				<button type="button" value={`/post/write?board=${boardCode}`} className='btn btn-submit' onClick={handleClick}>글쓰기</button>
 			</section>
 		</>
 	);

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

@@ -11,7 +11,7 @@ import { CommentUpdateResponse } from '@/types/response/forum/comment';
 import { type CommentItem } from '@/types/forum/comment';
 import EmojiPicker from '@/app/component/EmojiPicker';
 import { CommentConst } from '@/constants/forum';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import useAuth from '@/hooks/useAuth';
 import Loading from '@/app/component/Loading';
 
@@ -179,8 +179,6 @@ export default function EditForm({ _board, _post, _comment, onSuccess, onCancel
 				body: formData
 			});
 
-			throwError(res);
-
 			const updatedComment: CommentItem = {
 				..._comment,
 				content: content,

+ 2 - 6
app/(main)/(forum)/comment/_component/Item.tsx

@@ -8,7 +8,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
 import { faThumbsUp as nThumbsUp, faThumbsDown as nThumbsDown, faFlag as nFlag, faPenToSquare, faTrashCan } from '@fortawesome/free-regular-svg-icons';
 import { faThumbsUp as yThumbsUp, faThumbsDown as yThumbsDown, faFlag as yFlag, faEllipsisVertical, faLock } from '@fortawesome/free-solid-svg-icons';
 import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
-import { fetchApi, throwError, formatDate } from '@/lib/utils/client';
+import { fetchApi, formatDate } from '@/lib/utils/client';
 import { isBoardAdmin } from '@/lib/utils/permission';
 import useAuth from '@/hooks/useAuth';
 import useErrorAlert from '@/hooks/useErrorAlert';
@@ -128,8 +128,6 @@ export default function Item({ _board, _post, _comment, isReplying, onReply, onS
 						});
 						break;
 				}
-			} else {
-				throwError(res);
 			}
 		} catch (err) {
 			if (err instanceof Error) {
@@ -168,9 +166,7 @@ export default function Item({ _board, _post, _comment, isReplying, onReply, onS
 			setLoading(true);
 
 			// 댓글 삭제 호출
-			fetchApi('/api/forum/comments/' + _comment.id, { method: 'DELETE' }).then((res) => {
-				throwError(res);
-
+			fetchApi('/api/forum/comments/' + _comment.id, { method: 'DELETE' }).then(() => {
 				// 삭제 성공 시 해당 댓글 영역 삭제
 				onDelete(_comment.id, _comment.parentID);
 			}).catch(err => {

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

@@ -14,7 +14,7 @@ import { CommentCreateRequest } from '@/types/request/forum/comment';
 import { CommentCreateResponse } from '@/types/response/forum/comment';
 import { CommentItem } from '@/types/forum/comment';
 import { BoardLayout, CommentConst } from '@/constants/forum';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import useAuth from '@/hooks/useAuth';
 import MentionSuggestion from './MentionSuggestion';
 
@@ -213,8 +213,6 @@ export default function WriteForm({ _board, _post, _comment, onSuccess, onCancel
 				body: formData
 			});
 
-			throwError(res);
-
 			const newComment: CommentItem = {
 				id: res.data!.id,
 				postID: _post.id,

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

@@ -9,7 +9,7 @@ import { BoardResponse } from '@/types/response/forum/board';
 import { PostResponse } from '@/types/response/forum/post';
 import { CommentListResponse } from '@/types/response/forum/comment';
 import { type CommentItem } from '@/types/forum/comment';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { checkPermission } from '@/lib/utils/permission';
 import useAuth from '@/hooks/useAuth';
 import WriteForm from './_component/WriteForm';
@@ -52,8 +52,6 @@ export default function View({ _board, _post } : Props)
 
 		// 댓글 목록 호출
 		fetchApi<CommentListResponse>(`/api/forum/posts/${_post.id}/comments?${queryParams.toString()}`).then((res) => {
-			throwError(res);
-
 			if (res.data != null) {
 				setData(res.data);
 			}

+ 1 - 7
app/(main)/(forum)/post/[id]/view.tsx

@@ -22,7 +22,7 @@ import Copied from '../_component/Copied';
 import SnsShare from '../_component/SnsShare';
 import Report from '../_component/Report';
 import { Reaction } from '@/constants/forum';
-import { fetchApi, getDateTime, throwError, formatDate, isDateOverdue } from '@/lib/utils/client';
+import { fetchApi, getDateTime, formatDate, isDateOverdue } from '@/lib/utils/client';
 import { isBoardAdmin } from '@/lib/utils/permission';
 import useAuth from '@/hooks/useAuth';
 
@@ -107,8 +107,6 @@ export default function View({ _board, _post }: Props)
 						setHasLike(false);
 						break;
 				}
-			} else {
-				throwError(res);
 			}
 		} catch (err) {
 			if (err instanceof Error) setError(err.message);
@@ -128,8 +126,6 @@ export default function View({ _board, _post }: Props)
 			const res = await fetchApi('/api/forum/posts/' + _post.id + '/bookmark', { method: 'POST' });
 			if (res.success) {
 				setHasBookmark(prev => !prev);
-			} else {
-				throwError(res);
 			}
 		} catch (err) {
 			if (err instanceof Error) setError(err.message);
@@ -187,8 +183,6 @@ export default function View({ _board, _post }: Props)
 				if (res.success) {
 					alert('게시글이 삭제되었습니다.');
 					router.push(`/board/${_post.boardCode}`);
-				} else {
-					throwError(res);
 				}
 			}).catch((err) => {
 				setError(err.message);

+ 1 - 2
app/(main)/(forum)/post/_component/LatestPosts.tsx

@@ -10,7 +10,7 @@ import { BoardListMeta } from '@/types/forum/boardMeta';
 import Post from '@/types/forum/post';
 import PostLatest from '@/types/forum/latestPost';
 import { LatestPostsResponse } from '@/types/response/forum/board'
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 import DefaultListLayout from '@/app/(main)/(forum)/board/_component/DefaultListLayout';
 import QnAListLayout from '@/app/(main)/(forum)/board/_component/QnAListLayout';
@@ -65,7 +65,6 @@ export default function LatestPosts(params : Props)
 		if (keywordParam) queryParams.set('keyword', keywordParam);
 
 		fetchApi<LatestPostsResponse>(`/api/forum/posts?${queryParams.toString()}`).then((res) => {
-			throwError(res);
 			setLatestPosts(res.data as LatestPostsResponse);
 		}).catch((err) => {
 			setError(err.message);

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

@@ -5,7 +5,7 @@ import { useState, useCallback, useRef } from 'react';
 import Loading from '@/app/component/Loading';
 import { Button } from '@/components/ui/button';
 import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { ReportTypeLabels } from '@/constants/forum';
 
 type Props = {
@@ -77,7 +77,6 @@ export default function Report({ isEnable, open, onChange, onComplete, postID, c
 				setForm({ type: '', reason: '' });
 				onComplete(true);
 			} else {
-				throwError(res);
 			}
 
         } catch (err) {

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

@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation';
 import { useState, useCallback, useRef, FormEvent } from 'react';
 import Loading from '@/app/component/Loading';
 import { BoardLayout, PostConst } from '@/constants/forum';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { checkPermission, isBoardAdmin } from '@/lib/utils/permission';
 import useAuth from '@/hooks/useAuth';
 import useErrorAlert from '@/hooks/useErrorAlert';
@@ -265,8 +265,6 @@ export default function View({ _boardList, _board, _post }: Props)
 			if (res.success) {
 				resetForm();
 				router.push(redirectUrl);
-			} else {
-				throwError(res);
 			}
 
 		} catch (err) {

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

@@ -6,7 +6,7 @@ import Link from 'next/link';
 import { useState, useEffect, useCallback, useRef, FormEvent } from 'react';
 import Loading from '@/app/component/Loading';
 import { BoardLayout, PostConst } from '@/constants/forum';
-import { fetchApi, throwError } from '@/lib/utils/client';
+import { fetchApi } from '@/lib/utils/client';
 import { checkPermission, isBoardAdmin } from '@/lib/utils/permission';
 import useAuth from '@/hooks/useAuth';
 import useErrorAlert from '@/hooks/useErrorAlert';
@@ -302,8 +302,6 @@ export default function View({ _boardList, _board }: Props)
 			if (res.success) {
 				resetForm();
 				router.push(`/post/${res.data?.id}`);
-			} else {
-				throwError(res);
 			}
 
 		} catch (err) {

+ 103 - 0
app/(main)/channel/[channelSID]/page.tsx

@@ -0,0 +1,103 @@
+import { fetchJson } from '@/lib/utils/server';
+import { ResultDto } from '@/types/response/common';
+import { ChannelDetail } from '@/types/channel';
+import { notFound } from 'next/navigation';
+import Link from 'next/link';
+import './style.scss';
+
+type Props = {
+	params: Promise<{ channelSID: string }>;
+};
+
+function formatCount(n: number): string {
+	if (n >= 10000) return `${(n / 10000).toFixed(n >= 100000 ? 0 : 1)}만`;
+	if (n >= 1000) return `${(n / 1000).toFixed(1)}천`;
+	return n.toLocaleString();
+}
+
+export default async function ChannelPage({ params }: Props) {
+	const { channelSID } = await params;
+	const res: ResultDto<ChannelDetail> = await fetchJson(`/api/channel/${channelSID}`, { method: 'GET' });
+
+	if (!res.data) {
+		notFound();
+	}
+
+	const ch = res.data;
+
+	return (
+		<div className="channel-page">
+			{/* 배너 */}
+			<div className="channel-page__banner">
+				{ch.bannerUrl && (
+					<img
+						src={`${ch.bannerUrl}=w1707-fcrop64=1,00005a57ffffa5a8-k-c0xffffffff-no-nd-rj`}
+						alt={`${ch.name} 배너`}
+						className="channel-page__banner-img"
+					/>
+				)}
+			</div>
+
+			{/* 프로필 */}
+			<div className="channel-page__profile">
+				{/* 썸네일 */}
+				<a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className={`channel-page__avatar${ch.isLive ? ' channel-page__avatar--live' : ''}`}>
+					{ch.thumbnailUrl ? (
+						<img src={ch.thumbnailUrl} alt={ch.name} />
+					) : (
+						<div className="channel-page__avatar-placeholder">{ch.name.charAt(0)}</div>
+					)}
+					{ch.isLive && <span className="channel-page__live-badge">LIVE</span>}
+				</a>
+
+				{/* 채널명 + 메타 */}
+				<div className="channel-page__info">
+					<h1 className="channel-page__name">
+						<a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer">{ch.name}</a>
+						{ch.isVerified && <span className="channel-page__verified" title="인증됨">✓</span>}
+					</h1>
+					<div className="channel-page__meta">
+						{ch.handle && <span>@{ch.handle}</span>}
+						{(ch.subscriberCount ?? 0) > 0 && <span>구독자 {formatCount(ch.subscriberCount)}명</span>}
+						{(ch.videoCount ?? 0) > 0 && <span>동영상 {ch.videoCount.toLocaleString()}개</span>}
+					</div>
+					{/* 데스크톱 전용: 메타 아래 인라인 */}
+					<div className="channel-page__buttons channel-page__buttons--desktop">
+						<a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className="channel-page__subscribe-btn">구독</a>
+						<Link href={`/donation/${ch.channelSID}`} className="channel-page__donate-btn">후원하기</Link>
+					</div>
+				</div>
+			</div>
+
+			{/* 모바일 전용: 프로필 아래 별도 줄 */}
+			<div className="channel-page__buttons channel-page__buttons--mobile mt-3">
+				<a href={ch.youTubeUrl} target="_blank" rel="noopener noreferrer" className="channel-page__subscribe-btn">구독</a>
+				<Link href={`/donation/${ch.channelSID}`} className="channel-page__donate-btn">후원하기</Link>
+			</div>
+
+			{/* 라이브 상태 */}
+			{ch.isLive && (
+				<div className="channel-page__live">
+					<span className="channel-page__live-dot" />
+					<span className="channel-page__live-title">{ch.liveTitle}</span>
+					<Link href={`/watch/${ch.channelSID}`} className="channel-page__watch-btn">방송 보러가기 →</Link>
+				</div>
+			)}
+
+			{/* 탭 네비게이션 */}
+			<nav className="channel-page__tabs">
+				<span className="channel-page__tab channel-page__tab--active">소개</span>
+				<span className="channel-page__tab">게시물</span>
+			</nav>
+
+			{/* 소개 탭 콘텐츠 */}
+			<div className="channel-page__tab-content">
+				{ch.description ? (
+					<p className="channel-page__description">{ch.description}</p>
+				) : (
+					<p className="channel-page__empty">채널 소개가 없습니다</p>
+				)}
+			</div>
+		</div>
+	);
+}

+ 347 - 0
app/(main)/channel/[channelSID]/style.scss

@@ -0,0 +1,347 @@
+.channel-page {
+	max-width: 1080px;
+	margin: 0 auto;
+
+	// ── 배너 ─────────────────────────────────────────
+	&__banner {
+		aspect-ratio: 6.2 / 1;
+		background: linear-gradient(135deg, var(--bg-subtle), var(--border-default));
+		border-radius: 12px;
+		overflow: hidden;
+		margin: 20px 20px 0 20px;
+	}
+
+	&__banner-img {
+		width: 100%;
+		height: 100%;
+		object-fit: cover;
+	}
+
+	// ── 프로필: 썸네일 + 채널명/메타/버튼 ─────────────
+	&__profile {
+		display: flex;
+		align-items: flex-start;
+		gap: 16px;
+		padding: 16px 16px 12px;
+	}
+
+	&__avatar {
+		width: 72px;
+		height: 72px;
+		border-radius: 50%;
+		overflow: visible;
+		background: var(--bg-subtle);
+		flex-shrink: 0;
+		position: relative;
+		display: block;
+		text-decoration: none;
+
+		img {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+			border-radius: 50%;
+		}
+
+		&--live {
+			padding: 2px;
+			background: linear-gradient(135deg, #f43f5e, #dc2626, #f97316);
+			background-origin: border-box;
+
+			img {
+				border: 2px solid var(--bg-page, #fff);
+			}
+		}
+	}
+
+	&__live-badge {
+		position: absolute;
+		bottom: -4px;
+		left: 50%;
+		transform: translateX(-50%);
+		background: #dc2626;
+		color: #fff;
+		font-size: 0.5625rem;
+		font-weight: 700;
+		letter-spacing: 0.5px;
+		padding: 1px 6px;
+		border-radius: 3px;
+		line-height: 1.4;
+		border: none;
+	}
+
+	&__avatar-placeholder {
+		width: 100%;
+		height: 100%;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		font-size: 1.75rem;
+		font-weight: 700;
+		color: var(--text-muted);
+		border-radius: 50%;
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+	}
+
+	&__name {
+		font-size: 1.25rem;
+		font-weight: 700;
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		line-height: 1.3;
+
+		a {
+			color: inherit;
+			text-decoration: none;
+
+			&:hover {
+				text-decoration: underline;
+			}
+		}
+	}
+
+	&__verified {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 16px;
+		height: 16px;
+		border-radius: 50%;
+		background: #3b82f6;
+		color: #fff;
+		font-size: 0.5625rem;
+		flex-shrink: 0;
+	}
+
+	&__meta {
+		display: flex;
+		align-items: center;
+		flex-wrap: wrap;
+		gap: 4px;
+		font-size: 0.8125rem;
+		color: var(--text-muted);
+		margin-top: 2px;
+
+		span + span::before {
+			content: ' · ';
+		}
+	}
+
+	// ── 버튼 ────────────────────────────────────
+	&__buttons {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+
+		// 데스크톱 전용 (info 내부)
+		&--desktop {
+			display: none;
+			margin-top: 20px;
+		}
+
+		// 모바일 전용 (profile 아래 별도 줄)
+		&--mobile {
+			padding: 0 16px 4px;
+		}
+	}
+
+	&__subscribe-btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		padding: 8px 16px;
+		border-radius: 20px;
+		background: var(--fg-default, #0f0f0f);
+		color: var(--bg-page, #fff);
+		font-size: 0.875rem;
+		font-weight: 500;
+		text-decoration: none;
+		white-space: nowrap;
+
+		&:hover {
+			opacity: 0.85;
+		}
+	}
+
+	&__donate-btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		gap: 4px;
+		padding: 8px 16px;
+		border-radius: 20px;
+		background: #3b82f6;
+		color: #fff;
+		font-size: 0.875rem;
+		font-weight: 500;
+		text-decoration: none;
+		white-space: nowrap;
+
+		&:hover {
+			background: #2563eb;
+		}
+	}
+
+	// ── 라이브 ───────────────────────────────────────
+	&__live {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+		padding: 10px 16px;
+		margin: 0 16px;
+		background: rgba(220, 38, 38, 0.05);
+		border: 1px solid rgba(220, 38, 38, 0.15);
+		border-radius: 8px;
+	}
+
+	&__live-dot {
+		width: 8px;
+		height: 8px;
+		border-radius: 50%;
+		background: #dc2626;
+		flex-shrink: 0;
+		animation: pulse-live 1.5s ease-in-out infinite;
+	}
+
+	&__live-title {
+		flex: 1;
+		font-size: 0.875rem;
+		font-weight: 500;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__watch-btn {
+		font-size: 0.8125rem;
+		color: #3b82f6;
+		font-weight: 500;
+		flex-shrink: 0;
+		white-space: nowrap;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	// ── 탭 네비게이션 ────────────────────────────────
+	&__tabs {
+		display: flex;
+		border-bottom: 1px solid var(--border-default);
+		margin-top: 4px;
+		padding: 0 16px;
+	}
+
+	&__tab {
+		padding: 12px 16px;
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: var(--text-muted);
+		cursor: pointer;
+		position: relative;
+
+		&--active {
+			color: var(--fg-default);
+
+			&::after {
+				content: '';
+				position: absolute;
+				bottom: -1px;
+				left: 0;
+				right: 0;
+				height: 2px;
+				background: var(--fg-default);
+				border-radius: 2px 2px 0 0;
+			}
+		}
+
+		&:hover:not(&--active) {
+			color: var(--fg-default);
+		}
+	}
+
+	// ── 탭 콘텐츠 ───────────────────────────────────
+	&__tab-content {
+		padding: 16px;
+	}
+
+	&__description {
+		font-size: 0.875rem;
+		color: var(--text-secondary);
+		line-height: 1.6;
+		white-space: pre-line;
+	}
+
+	&__empty {
+		font-size: 0.875rem;
+		color: var(--text-muted);
+	}
+
+	// ── 모바일: 버튼 풀너비, 별도 줄 ────────────────
+	@media (max-width: 767px) {
+		&__banner {
+			margin: 10px;
+		}
+
+		&__buttons--mobile {
+			display: flex;
+		}
+
+		&__subscribe-btn,
+		&__donate-btn {
+			flex: 1;
+		}
+	}
+
+	// ── 데스크톱: 버튼 info 내부, 썸네일 크게 ────────
+	@media (min-width: 768px) {
+		&__buttons--desktop {
+			display: flex;
+		}
+
+		&__buttons--mobile {
+			display: none;
+		}
+
+		&__profile {
+			align-items: center;
+			gap: 20px;
+			padding: 20px 24px 16px;
+		}
+
+		&__avatar {
+			width: 128px;
+			height: 128px;
+		}
+
+		&__name {
+			font-size: 1.5rem;
+		}
+
+		&__meta {
+			font-size: 0.875rem;
+		}
+
+		&__tabs {
+			padding: 0 24px;
+		}
+
+		&__tab-content {
+			padding: 20px 24px;
+		}
+
+		&__live {
+			margin: 0 24px;
+		}
+	}
+}
+
+@keyframes pulse-live {
+	0%, 100% { opacity: 1; }
+	50% { opacity: 0.4; }
+}

+ 109 - 0
app/(main)/note/inbox/page.tsx

@@ -0,0 +1,109 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { NotePreview } from '@/types/notification';
+import { NoteInboxResponse, NoteDetail } from '@/types/response/note/inbox';
+
+export default function NoteInboxPage() {
+	const [notes, setNotes] = useState<NotePreview[]>([]);
+	const [total, setTotal] = useState(0);
+	const [unreadCount, setUnreadCount] = useState(0);
+	const [page, setPage] = useState(1);
+	const [loading, setLoading] = useState(true);
+	const [selectedNote, setSelectedNote] = useState<NoteDetail|null>(null);
+
+	useEffect(() => {
+		loadNotes();
+	}, [page]);
+
+	const loadNotes = async () => {
+		setLoading(true);
+		try {
+			const res = await fetchApi<NoteInboxResponse>(`/api/note/inbox?pageNum=${page}&perPage=20`);
+			if (res.data) {
+				setNotes(res.data.list || []);
+				setTotal(res.data.total || 0);
+				setUnreadCount(res.data.unreadCount || 0);
+			}
+		} catch {}
+		setLoading(false);
+	};
+
+	const openNote = async (note: NotePreview) => {
+		// TODO: 상세 조회 API 호출 + 읽음 처리
+		setSelectedNote({
+			id: note.id,
+			title: note.title,
+			content: '(쪽지 내용 로딩...)',
+			senderName: note.senderName || '시스템',
+			createdAt: note.createdAt
+		});
+		if (!note.isRead) {
+			setNotes(prev => prev.map(n => n.id === note.id ? { ...n, isRead: true } : n));
+			setUnreadCount(prev => Math.max(0, prev - 1));
+		}
+	};
+
+	const formatDate = (dateStr: string) => {
+		const d = new Date(dateStr);
+		return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')} ${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
+	};
+
+	return (
+		<div className="container mx-auto max-w-2xl p-4">
+			<div className="flex items-center justify-between mb-4">
+				<h1 className="text-xl font-bold">받은 쪽지함</h1>
+				<div className="flex gap-2">
+					<span className="text-sm text-gray-500">안읽은 쪽지: {unreadCount}건</span>
+					<a href="/note/send" className="text-sm text-blue-500 hover:underline">쪽지 보내기</a>
+				</div>
+			</div>
+
+			{loading && <div className="text-center text-gray-500 py-10">로딩 중...</div>}
+
+			{!loading && notes.length === 0 && (
+				<div className="text-center text-gray-500 py-10">쪽지가 없습니다</div>
+			)}
+
+			<div className="flex flex-col gap-2">
+				{notes.map(note => (
+					<div key={note.id} onClick={() => openNote(note)} className={`flex items-center gap-3 p-3 rounded-lg cursor-pointer border transition-colors ${note.isRead ? 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700' : 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800'}`}>
+						<div className="flex-1 overflow-hidden">
+							<div className="flex items-center gap-2">
+								{note.isSystem && <span className="text-xs bg-gray-200 dark:bg-gray-700 px-1.5 py-0.5 rounded">시스템</span>}
+								<span className={`text-sm ${note.isRead ? 'font-normal' : 'font-semibold'}`}>{note.title}</span>
+							</div>
+							<div className="text-xs text-gray-500 mt-1">
+								{note.senderName || '시스템'} · {formatDate(note.createdAt)}
+							</div>
+						</div>
+						{!note.isRead && <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0" />}
+					</div>
+				))}
+			</div>
+
+			{total > 20 && (
+				<div className="flex justify-center gap-2 mt-4">
+					<button type="button" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="px-3 py-1 text-sm border rounded disabled:opacity-50">이전</button>
+					<span className="px-3 py-1 text-sm">{page} / {Math.ceil(total / 20)}</span>
+					<button type="button" onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(total / 20)} className="px-3 py-1 text-sm border rounded disabled:opacity-50">다음</button>
+				</div>
+			)}
+
+			{/* 쪽지 상세 모달 */}
+			{selectedNote && (
+				<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={() => setSelectedNote(null)}>
+					<div className="bg-white dark:bg-gray-900 rounded-xl w-full max-w-md mx-4 p-6" onClick={e => e.stopPropagation()}>
+						<div className="flex items-center justify-between mb-3">
+							<h3 className="font-bold text-lg">{selectedNote.title}</h3>
+							<button type="button" onClick={() => setSelectedNote(null)} className="text-gray-400 hover:text-gray-600 text-xl">×</button>
+						</div>
+						<div className="text-xs text-gray-500 mb-3">{selectedNote.senderName} · {formatDate(selectedNote.createdAt)}</div>
+						<div className="text-sm whitespace-pre-wrap">{selectedNote.content}</div>
+					</div>
+				</div>
+			)}
+		</div>
+	);
+}

+ 82 - 0
app/(main)/note/send/page.tsx

@@ -0,0 +1,82 @@
+'use client';
+
+import { useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { NoteSendRequest } from '@/types/request/note/send';
+
+export default function NoteSendPage() {
+	const [receiverID, setReceiverID] = useState('');
+	const [title, setTitle] = useState('');
+	const [content, setContent] = useState('');
+	const [sending, setSending] = useState(false);
+	const [result, setResult] = useState<{ success: boolean; message: string }|null>(null);
+
+	const handleSend = async () => {
+		if (!receiverID || !title.trim() || !content.trim()) {
+			setResult({ success: false, message: '모든 필드를 입력해주세요.' });
+			return;
+		}
+
+		setSending(true);
+		setResult(null);
+
+		try {
+			const res = await fetchApi('/api/note/send', {
+				method: 'POST',
+				body: {
+					receiverMemberID: parseInt(receiverID),
+					title: title.trim(),
+					content: content.trim()
+				} as NoteSendRequest
+			});
+
+			if (res.success) {
+				setResult({ success: true, message: '쪽지를 보냈습니다.' });
+				setTitle('');
+				setContent('');
+			} else {
+				setResult({ success: false, message: res.message || '전송에 실패했습니다.' });
+			}
+		} catch (e: unknown) {
+			const message = e instanceof Error ? e.message : '전송에 실패했습니다.';
+			setResult({ success: false, message });
+		}
+
+		setSending(false);
+	};
+
+	return (
+		<div className="container mx-auto max-w-lg p-4">
+			<h1 className="text-xl font-bold mb-4">쪽지 보내기</h1>
+
+			{result && (
+				<div className={`p-3 rounded-lg mb-4 text-sm ${result.success ? 'bg-green-50 text-green-700 dark:bg-green-950 dark:text-green-300' : 'bg-red-50 text-red-700 dark:bg-red-950 dark:text-red-300'}`}>
+					{result.message}
+				</div>
+			)}
+
+			<div className="flex flex-col gap-3">
+				<div>
+					<label className="text-sm font-medium mb-1 block">받는 사람 (회원 ID)</label>
+					<input type="number" value={receiverID} onChange={e => setReceiverID(e.target.value)} placeholder="회원 ID" className="w-full border rounded-lg px-3 py-2 text-sm" />
+				</div>
+				<div>
+					<label className="text-sm font-medium mb-1 block">제목</label>
+					<input type="text" value={title} onChange={e => setTitle(e.target.value)} maxLength={200} placeholder="제목을 입력하세요" className="w-full border rounded-lg px-3 py-2 text-sm" />
+				</div>
+				<div>
+					<label className="text-sm font-medium mb-1 block">내용</label>
+					<textarea value={content} onChange={e => setContent(e.target.value)} maxLength={2000} rows={6} placeholder="내용을 입력하세요" className="w-full border rounded-lg px-3 py-2 text-sm resize-none" />
+					<div className="text-xs text-gray-400 text-right mt-1">{content.length}/2000</div>
+				</div>
+				<button type="button" onClick={handleSend} disabled={sending} className="w-full bg-blue-500 text-white py-2.5 rounded-lg font-medium text-sm hover:bg-blue-600 disabled:opacity-50">
+					{sending ? '전송 중...' : '보내기'}
+				</button>
+			</div>
+
+			<div className="mt-4 text-center">
+				<a href="/note/inbox" className="text-sm text-blue-500 hover:underline">받은 쪽지함으로 돌아가기</a>
+			</div>
+		</div>
+	);
+}

+ 122 - 0
app/(main)/notification/page.tsx

@@ -0,0 +1,122 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { NotificationItem } from '@/types/notification';
+import { NotificationListResponse } from '@/types/response/notification/list';
+import { NotificationReadRequest } from '@/types/request/notification/read';
+
+const TYPE_LABELS: Record<number, string> = {
+	1: '후원 받음',
+	2: '후원 보냄',
+	10: '크루 초대',
+	11: '크루 시작',
+	12: '크루 종료',
+	13: '크루 후원',
+	20: '정산 승인',
+	21: '정산 거부',
+	30: '새 쪽지',
+	99: '시스템'
+};
+
+export default function NotificationPage() {
+	const [notifications, setNotifications] = useState<NotificationItem[]>([]);
+	const [total, setTotal] = useState(0);
+	const [page, setPage] = useState(1);
+	const [loading, setLoading] = useState(true);
+
+	useEffect(() => {
+		loadNotifications();
+	}, [page]);
+
+	const loadNotifications = async () => {
+		setLoading(true);
+		try {
+			const res = await fetchApi<NotificationListResponse>(`/api/notification/list?pageNum=${page}&perPage=20`, { silent: true });
+			if (res.data) {
+				setNotifications(res.data.list || []);
+				setTotal(res.data.total || 0);
+			}
+		} catch {}
+		setLoading(false);
+	};
+
+	const handleRead = async (item: NotificationItem) => {
+		if (!item.isRead) {
+			await fetchApi('/api/notification/read', {
+				method: 'POST',
+				body: { notificationID: item.id } as NotificationReadRequest,
+				silent: true
+			});
+			setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, isRead: true } : n));
+		}
+		if (item.actionUrl) {
+			window.location.href = item.actionUrl;
+		}
+	};
+
+	const handleReadAll = async () => {
+		await fetchApi('/api/notification/read', {
+			method: 'POST',
+			body: {} as NotificationReadRequest,
+			silent: true
+		});
+		setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
+	};
+
+	const formatDate = (dateStr: string) => {
+		const d = new Date(dateStr);
+		const now = new Date();
+		const diff = Math.floor((now.getTime() - d.getTime()) / 60000);
+		if (diff < 1) { return '방금'; }
+		if (diff < 60) { return `${diff}분 전`; }
+		if (diff < 1440) { return `${Math.floor(diff / 60)}시간 전`; }
+		return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, '0')}-${d.getDate().toString().padStart(2, '0')}`;
+	};
+
+	return (
+		<div className="container mx-auto max-w-2xl p-4">
+			<div className="flex items-center justify-between mb-4">
+				<h1 className="text-xl font-bold">알림</h1>
+				<button type="button" onClick={handleReadAll} className="text-sm text-blue-500 hover:underline">모두 읽음</button>
+			</div>
+
+			{loading && <div className="text-center text-gray-500 py-10">로딩 중...</div>}
+
+			{!loading && notifications.length === 0 && (
+				<div className="text-center text-gray-500 py-10">알림이 없습니다</div>
+			)}
+
+			<div className="flex flex-col gap-2">
+				{notifications.map(n => (
+					<div key={n.id} onClick={() => handleRead(n)} className={`flex items-start gap-3 p-3 rounded-lg cursor-pointer border transition-colors ${n.isRead ? 'bg-white dark:bg-gray-900 border-gray-200 dark:border-gray-700' : 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800'}`}>
+						{n.imageUrl ? (
+							<img src={n.imageUrl} alt="" className="w-10 h-10 rounded-full object-cover flex-shrink-0" />
+						) : (
+							<div className="w-10 h-10 rounded-full bg-gray-200 dark:bg-gray-700 flex items-center justify-center flex-shrink-0 text-lg">🔔</div>
+						)}
+						<div className="flex-1 overflow-hidden">
+							<div className="flex items-center gap-2 mb-1">
+								<span className="text-xs bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-gray-600 dark:text-gray-400">
+									{TYPE_LABELS[n.type] || '알림'}
+								</span>
+								<span className="text-xs text-gray-400">{formatDate(n.createdAt)}</span>
+							</div>
+							<div className={`text-sm ${n.isRead ? 'font-normal' : 'font-semibold'}`}>{n.title}</div>
+							<div className="text-xs text-gray-500 mt-0.5 overflow-hidden text-ellipsis whitespace-nowrap">{n.message}</div>
+						</div>
+						{!n.isRead && <div className="w-2 h-2 rounded-full bg-blue-500 flex-shrink-0 mt-2" />}
+					</div>
+				))}
+			</div>
+
+			{total > 20 && (
+				<div className="flex justify-center gap-2 mt-4">
+					<button type="button" onClick={() => setPage(p => Math.max(1, p - 1))} disabled={page <= 1} className="px-3 py-1 text-sm border rounded disabled:opacity-50">이전</button>
+					<span className="px-3 py-1 text-sm">{page} / {Math.ceil(total / 20)}</span>
+					<button type="button" onClick={() => setPage(p => p + 1)} disabled={page >= Math.ceil(total / 20)} className="px-3 py-1 text-sm border rounded disabled:opacity-50">다음</button>
+				</div>
+			)}
+		</div>
+	);
+}

+ 0 - 0
app/component/chat/ChatSidebar.tsx → app/(main)/watch/[channelSID]/ChatSidebar.tsx


+ 61 - 0
app/(main)/watch/[channelSID]/WatchView.tsx

@@ -0,0 +1,61 @@
+'use client';
+
+import { ChannelDetail } from '@/types/channel';
+import ChatSidebar from './ChatSidebar';
+import Link from 'next/link';
+import './style.scss';
+
+type Props = {
+	channel: ChannelDetail;
+};
+
+export default function WatchView({ channel }: Props) {
+	const embedUrl = channel.videoId
+		? `https://www.youtube.com/embed/${channel.videoId}?autoplay=1&mute=1`
+		: null;
+
+	return (
+		<div className="watch-page">
+			<div className="watch-page__content">
+				{/* 플레이어 */}
+				<div className="watch-page__player">
+					{embedUrl ? (
+						<iframe
+							src={embedUrl}
+							allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
+							allowFullScreen
+							title={channel.liveTitle || channel.name}
+						/>
+					) : (
+						<div className="watch-page__offline">
+							<p>현재 방송 중이 아닙니다</p>
+							<Link href={`/channel/${channel.channelSID}`}>채널 페이지로 이동</Link>
+						</div>
+					)}
+				</div>
+
+				{/* 채널 정보 */}
+				<div className="watch-page__info">
+					<h1 className="watch-page__title">{channel.liveTitle || channel.name}</h1>
+					<div className="watch-page__channel">
+						<Link href={`/channel/${channel.channelSID}`} className="watch-page__channel-name">
+							{channel.name}
+							{channel.isVerified && <span className="watch-page__verified" title="인증됨">✓</span>}
+						</Link>
+						{channel.handle && <span className="watch-page__handle">@{channel.handle}</span>}
+					</div>
+					<div className="watch-page__actions">
+						<Link href={`/donation/${channel.channelSID}`} className="watch-page__donate-btn">
+							💰 후원하기
+						</Link>
+					</div>
+				</div>
+			</div>
+
+			{/* 우측 채팅 */}
+			<div className="watch-page__chat">
+				<ChatSidebar />
+			</div>
+		</div>
+	);
+}

+ 0 - 0
app/component/chat/chat-sidebar.scss → app/(main)/watch/[channelSID]/chat-sidebar.scss


+ 20 - 0
app/(main)/watch/[channelSID]/page.tsx

@@ -0,0 +1,20 @@
+import { fetchJson } from '@/lib/utils/server';
+import { ResultDto } from '@/types/response/common';
+import { ChannelDetail } from '@/types/channel';
+import { notFound } from 'next/navigation';
+import WatchView from './WatchView';
+
+type Props = {
+	params: Promise<{ channelSID: string }>;
+};
+
+export default async function WatchPage({ params }: Props) {
+	const { channelSID } = await params;
+	const res: ResultDto<ChannelDetail> = await fetchJson(`/api/channel/${channelSID}`, { method: 'GET' });
+
+	if (!res.data) {
+		notFound();
+	}
+
+	return <WatchView channel={res.data} />;
+}

+ 135 - 0
app/(main)/watch/[channelSID]/style.scss

@@ -0,0 +1,135 @@
+.watch-page {
+	display: flex;
+	height: 100%;
+	gap: 0;
+
+	@media (max-width: 768px) {
+		flex-direction: column;
+	}
+
+	&__content {
+		flex: 1;
+		min-width: 0;
+		overflow-y: auto;
+	}
+
+	&__player {
+		position: relative;
+		width: 100%;
+		padding-top: 56.25%; // 16:9
+		background: #000;
+
+		iframe {
+			position: absolute;
+			top: 0;
+			left: 0;
+			width: 100%;
+			height: 100%;
+			border: none;
+		}
+	}
+
+	&__offline {
+		position: absolute;
+		top: 0;
+		left: 0;
+		width: 100%;
+		height: 100%;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+		gap: 12px;
+		color: #999;
+
+		a {
+			color: var(--brand-orange);
+			text-decoration: underline;
+		}
+	}
+
+	&__info {
+		padding: 16px;
+	}
+
+	&__title {
+		font-size: 1.125rem;
+		font-weight: 600;
+		margin-bottom: 8px;
+		line-height: 1.3;
+	}
+
+	&__channel {
+		display: flex;
+		align-items: center;
+		gap: 8px;
+		margin-bottom: 12px;
+	}
+
+	&__channel-name {
+		font-size: 0.875rem;
+		font-weight: 500;
+		display: flex;
+		align-items: center;
+		gap: 4px;
+
+		&:hover {
+			text-decoration: underline;
+		}
+	}
+
+	&__verified {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 16px;
+		height: 16px;
+		border-radius: 50%;
+		background: var(--brand-orange);
+		color: #fff;
+		font-size: 0.625rem;
+	}
+
+	&__handle {
+		font-size: 0.75rem;
+		color: var(--text-muted);
+	}
+
+	&__actions {
+		display: flex;
+		gap: 8px;
+	}
+
+	&__donate-btn {
+		display: inline-flex;
+		align-items: center;
+		gap: 4px;
+		padding: 8px 16px;
+		border-radius: 20px;
+		background: var(--brand-orange);
+		color: #fff;
+		font-size: 0.875rem;
+		font-weight: 500;
+		text-decoration: none;
+		transition: opacity 0.15s;
+
+		&:hover {
+			opacity: 0.9;
+		}
+	}
+
+	&__chat {
+		width: 360px;
+		flex-shrink: 0;
+		border-left: 1px solid var(--border-default);
+		display: flex;
+		flex-direction: column;
+
+		@media (max-width: 768px) {
+			width: 100%;
+			height: 400px;
+			border-left: none;
+			border-top: 1px solid var(--border-default);
+		}
+	}
+}

+ 28 - 0
app/api/auth/youtube/callback/route.ts

@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest)
+{
+	const { searchParams, origin } = request.nextUrl;
+	const code = searchParams.get('code');
+	const error = searchParams.get('error');
+
+	if (error || !code) {
+		return NextResponse.redirect(new URL('/studio/settings?error=cancelled', request.url));
+	}
+
+	const redirectUri = `${origin}/api/auth/youtube/callback`;
+
+	const res = await fetchJson('/api/studio/youtube-connect', {
+		method: 'POST',
+		body: JSON.stringify({ code, redirectUri }),
+		headers: { 'Content-Type': 'application/json' }
+	});
+
+	if (!res.success) {
+		const msg = encodeURIComponent(res.message ?? '연결 실패');
+		return NextResponse.redirect(new URL(`/studio/settings?error=${msg}`, request.url));
+	}
+
+	return NextResponse.redirect(new URL('/studio/settings?connected=true', request.url));
+}

+ 11 - 0
app/api/channel/[...path]/route.ts

@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/channel/${path.join('/')}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}

+ 18 - 0
app/api/crew/[...path]/route.ts

@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/crew/${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/crew/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(await request.json()) });
+	return NextResponse.json(res);
+}

+ 44 - 0
app/api/donation/[...path]/route.ts

@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/donation/${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/donation/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+
+	if (contentType.includes('multipart/form-data')) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST', body: await request.arrayBuffer(),
+			headers: { 'Content-Type': contentType }
+		});
+
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(await request.json()) });
+
+	return NextResponse.json(res);
+}
+
+export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/donation/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'DELETE'
+	});
+
+	return NextResponse.json(res);
+}

+ 18 - 0
app/api/note/[...path]/route.ts

@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/note/${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/note/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(await request.json()) });
+	return NextResponse.json(res);
+}

+ 18 - 0
app/api/notification/[...path]/route.ts

@@ -0,0 +1,18 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/notification/${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/notification/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(await request.json()) });
+	return NextResponse.json(res);
+}

+ 20 - 0
app/api/payment/[...path]/route.ts

@@ -0,0 +1,20 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/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/payment/${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/payment/${path.join('/')}`;
+	const jsonBody = await request.json();
+	console.log('[Payment Route] POST', endpoint, JSON.stringify(jsonBody));
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body: JSON.stringify(jsonBody) });
+	return NextResponse.json(res);
+}

+ 47 - 0
app/api/payment/fail/route.ts

@@ -0,0 +1,47 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+// 다날 failUrl 핸들러
+// PC: POST (form body), 모바일: GET (query string)
+export async function GET(request: NextRequest) {
+	const params = request.nextUrl.searchParams;
+	const orderId = params.get('orderId') || '';
+	const errorCode = params.get('errorCode') || params.get('code') || '';
+	const errorMessage = params.get('errorMessage') || params.get('message') || '';
+
+	const url = new URL('/charge/fail', request.url);
+	url.searchParams.set('orderId', orderId);
+	url.searchParams.set('errorCode', errorCode);
+	url.searchParams.set('errorMessage', errorMessage);
+
+	return NextResponse.redirect(url);
+}
+
+export async function POST(request: NextRequest) {
+	const contentType = request.headers.get('content-type') || '';
+	let orderId = '';
+	let errorCode = '';
+	let errorMessage = '';
+
+	if (contentType.includes('application/x-www-form-urlencoded')) {
+		const formData = await request.formData();
+		orderId = (formData.get('orderId') as string) || '';
+		errorCode = (formData.get('errorCode') as string) || (formData.get('code') as string) || '';
+		errorMessage = (formData.get('errorMessage') as string) || (formData.get('message') as string) || '';
+	} else {
+		try {
+			const json = await request.json();
+			orderId = json.orderId || '';
+			errorCode = json.errorCode || json.code || '';
+			errorMessage = json.errorMessage || json.message || '';
+		} catch {
+			orderId = request.nextUrl.searchParams.get('orderId') || '';
+		}
+	}
+
+	const url = new URL('/charge/fail', request.url);
+	url.searchParams.set('orderId', orderId);
+	url.searchParams.set('errorCode', errorCode);
+	url.searchParams.set('errorMessage', errorMessage);
+
+	return NextResponse.redirect(url);
+}

+ 51 - 0
app/api/payment/success/route.ts

@@ -0,0 +1,51 @@
+import { NextRequest, NextResponse } from 'next/server';
+
+// 다날 successUrl 핸들러
+// PC: POST (form body), 모바일: GET (query string)
+export async function GET(request: NextRequest) {
+	const params = request.nextUrl.searchParams;
+	const orderId = params.get('orderId') || '';
+	const transactionId = params.get('transactionId') || '';
+	const method = params.get('method') || '';
+
+	const url = new URL('/charge/success', request.url);
+	url.searchParams.set('orderId', orderId);
+	url.searchParams.set('transactionId', transactionId);
+	url.searchParams.set('method', method);
+
+	return NextResponse.redirect(url);
+}
+
+export async function POST(request: NextRequest) {
+	// form-urlencoded 또는 JSON body 파싱
+	const contentType = request.headers.get('content-type') || '';
+	let orderId = '';
+	let transactionId = '';
+	let method = '';
+
+	if (contentType.includes('application/x-www-form-urlencoded')) {
+		const formData = await request.formData();
+		orderId = (formData.get('orderId') as string) || '';
+		transactionId = (formData.get('transactionId') as string) || '';
+		method = (formData.get('method') as string) || '';
+	} else {
+		try {
+			const json = await request.json();
+			orderId = json.orderId || '';
+			transactionId = json.transactionId || '';
+			method = json.method || '';
+		} catch {
+			// fallback: query string
+			orderId = request.nextUrl.searchParams.get('orderId') || '';
+			transactionId = request.nextUrl.searchParams.get('transactionId') || '';
+			method = request.nextUrl.searchParams.get('method') || '';
+		}
+	}
+
+	const url = new URL('/charge/success', request.url);
+	url.searchParams.set('orderId', orderId);
+	url.searchParams.set('transactionId', transactionId);
+	url.searchParams.set('method', method);
+
+	return NextResponse.redirect(url);
+}

+ 46 - 0
app/api/studio/[...path]/route.ts

@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const url = new URL(request.url);
+	const endpoint = `/api/studio/${path.join('/')}`;
+
+	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/studio/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+
+	if (contentType.includes('multipart/form-data')) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: await request.arrayBuffer(),
+			headers: { 'Content-Type': contentType }
+		});
+
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, {
+		method: 'POST',
+		body: JSON.stringify(await request.json())
+	});
+
+	return NextResponse.json(res);
+}
+
+export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/studio/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(endpoint, { method: 'DELETE' });
+
+	return NextResponse.json(res);
+}

+ 11 - 0
app/api/widget/[...path]/route.ts

@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const url = new URL(request.url);
+	const endpoint = `/api/widget/${path.join('/')}`;
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}

+ 0 - 37
app/auth/login/google/callback/route.ts

@@ -1,37 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { ResultDto } from '@/types/response/common';
-import { LoginResponse } from '@/types/response/auth';
-import { fetchJson } from '@/lib/utils/server';
-
-export async function POST(request: NextRequest)
-{
-	// Google redirect 모드는 form data로 credential을 전송
-	const formData = await request.formData();
-	const credential = formData.get('credential') as string;
-
-	if (!credential) {
-		const url = new URL('/login', request.url);
-		url.searchParams.set('error', 'Google 인증 정보가 없습니다.');
-		return NextResponse.redirect(url);
-	}
-
-	// 백엔드 google-login API 호출
-	const res: ResultDto = await fetchJson('/api/auth/google-login', {
-		method: 'POST',
-		body: JSON.stringify({ credential })
-	});
-
-	if (res.success && res.data) {
-		const data = res.data as LoginResponse;
-		const response = NextResponse.redirect(new URL('/auth/login/google/complete', request.url));
-		const cookieOptions = { httpOnly: true, path: '/' };
-		response.cookies.set('accessToken', data.accessToken, cookieOptions);
-		response.cookies.set('refreshToken', data.refreshToken, cookieOptions);
-		return response;
-	}
-
-	// 실패 시 로그인 페이지로 리다이렉트
-	const url = new URL('/login', request.url);
-	url.searchParams.set('error', res.message || 'Google 로그인에 실패했습니다.');
-	return NextResponse.redirect(url);
-}

+ 0 - 19
app/auth/login/google/complete/page.tsx

@@ -1,19 +0,0 @@
-'use client';
-
-import { useEffect } from 'react';
-import useAuth from '@/hooks/useAuth';
-
-export default function Page()
-{
-	const { login } = useAuth();
-
-	useEffect(() => {
-		login(true);
-	}, []);
-
-	return (
-		<div className="flex items-center justify-center min-h-screen">
-			<p>Google 로그인 처리 중...</p>
-		</div>
-	);
-}

+ 38 - 0
app/charge/fail/page.tsx

@@ -0,0 +1,38 @@
+'use client';
+
+import '../style.scss';
+import { useSearchParams } from 'next/navigation';
+
+export default function ChargeFailPage()
+{
+	const searchParams = useSearchParams();
+	const errorCode = searchParams.get('errorCode') || '';
+	const errorMessage = searchParams.get('errorMessage') || '결제가 취소되었거나 실패했습니다.';
+
+	if (errorCode === '9999') {
+		return window.location.replace('/charge');
+	}
+
+	const handleClose = () => {
+		if (window.opener) {
+			window.close();
+		} else {
+			window.location.href = '/';
+		}
+	};
+
+	return (
+		<div className="charge-page">
+			<div className="charge-page__card">
+				<div className="charge-page__result">
+					<div className="charge-page__result-icon charge-page__result-icon--error">✕</div>
+					<p className="charge-page__result-message">{errorMessage}</p>
+					{errorCode && <p className="charge-page__result-code">오류 코드: {errorCode}</p>}
+					<button type="button" className="charge-page__submit" onClick={handleClose}>
+						닫기
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 9 - 0
app/charge/layout.tsx

@@ -0,0 +1,9 @@
+import './style.scss';
+
+export default function ChargeLayout({ children }: { children: React.ReactNode }) {
+	return (
+		<div className="charge-layout">
+			{children}
+		</div>
+	);
+}

+ 197 - 0
app/charge/page.tsx

@@ -0,0 +1,197 @@
+'use client';
+
+import { useState, useEffect } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import useAuth from '@/hooks/useAuth';
+import { DanalCreateOrderRequest, PaymentMethodType } from '@/types/request/payment/charge';
+import { DanalCreateOrderResponse } from '@/types/response/payment/charge';
+import CreditCardIcon from '@/public/icons/payment/credit-card.svg';
+import KakaoIcon from '@/public/icons/payment/kakao.svg';
+import NaverIcon from '@/public/icons/payment/naver.svg';
+import BankIcon from '@/public/icons/payment/bank.svg';
+import PhoneIcon from '@/public/icons/payment/phone.svg';
+import { Landmark as VBankIcon } from 'lucide-react';
+
+type PaymentMethodOption = {
+	value: PaymentMethodType;
+	label: string;
+	danalMethod: string;
+	danalKey: string;
+	Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
+};
+
+const PAYMENT_METHODS: PaymentMethodOption[] = [
+	{ value: 'Card', label: '신용카드', danalMethod: 'CARD', danalKey: 'card', Icon: CreditCardIcon },
+	{ value: 'KakaoPay', label: '카카오페이', danalMethod: 'KAKAOPAY', danalKey: 'kakaoPay', Icon: KakaoIcon },
+	{ value: 'NaverPay', label: '네이버페이', danalMethod: 'NAVERPAY', danalKey: 'naverPay', Icon: NaverIcon },
+	{ value: 'Transfer', label: '계좌이체', danalMethod: 'TRANSFER', danalKey: 'transfer', Icon: BankIcon },
+	{ value: 'Mobile', label: '휴대폰', danalMethod: 'MOBILE', danalKey: 'mobile', Icon: PhoneIcon },
+	{ value: 'VirtualAccount', label: '가상계좌', danalMethod: 'VIRTUAL_ACCOUNT', danalKey: 'virtualAccount', Icon: VBankIcon },
+];
+
+// 결제수단별 다날 SDK methods 설정
+const METHOD_CONFIGS: Record<string, object> = {
+	card: {},
+	kakaoPay: {},
+	naverPay: {},
+	payco: {},
+	transfer: {},
+	mobile: { itemCode: '1270000000', itemType: '1' },
+	virtualAccount: { notiUrl: `${process.env.NEXT_PUBLIC_API_URL}/api/payment/noti/vaccount` }
+};
+
+const QUICK_AMOUNTS = [10000, 50000, 100000, 1000000];
+
+export default function ChargePage()
+{
+	const [amount, setAmount] = useState<number>(0);
+	const [paymentMethod, setPaymentMethod] = useState<PaymentMethodType>('Card');
+	const [loading, setLoading] = useState(false);
+	const [isPopup, setIsPopup] = useState(false);
+	const { member, loginCheck } = useAuth();
+
+	useEffect(() => {
+		setIsPopup(!!window.opener);
+	}, []);
+
+	if (!member || !loginCheck()) {
+		return null;
+	}
+
+	const handleAmountChange = (value: string) => {
+		const num = parseInt(value.replace(/[^0-9]/g, ''), 10);
+		setAmount(isNaN(num) ? 0 : num);
+	};
+
+	const addAmount = (add: number) => {
+		setAmount(prev => prev + add);
+	};
+
+	const vatAmount = Math.round(amount / 11);
+	const pointAmount = amount;
+
+	const handleSubmit = async () => {
+		if (amount < 1000) {
+			alert('최소 충전 금액은 1,000원입니다.');
+			return;
+		}
+
+		setLoading(true);
+
+		try {
+			const res = await fetchApi<DanalCreateOrderResponse>('/api/payment/order', {
+				method: 'POST',
+				body: {
+					amount,
+					paymentMethod
+				} as DanalCreateOrderRequest
+			});
+
+			if (!res.data) {
+				alert(res.message || '주문 생성에 실패했습니다.');
+				return setLoading(false);
+			}
+
+			const order = res.data;
+			const selected = PAYMENT_METHODS.find(m => m.value === paymentMethod)!;
+
+			const { loadDanalPaymentsSDK } = await import('@danalpay/javascript-sdk');
+			const danalPayments = await loadDanalPaymentsSDK({ clientKey: order.clientKey });
+
+			// eslint-disable-next-line @typescript-eslint/no-explicit-any
+			const paymentParams: any = {
+				paymentsMethod: selected.danalMethod,
+				methods: {
+					[selected.danalKey]: METHOD_CONFIGS[selected.danalKey]
+				},
+				orderName: `DPOT ${pointAmount.toLocaleString()}(P) 충전`,
+				amount: order.amount,
+				merchantId: order.merchantID,
+				orderId: order.orderID,
+				userId: member.id,
+				userEmail: member.email,
+				userName: member.name,
+				successUrl: order.successUrl,
+				failUrl: order.failUrl
+			};
+
+			danalPayments.requestPayment(paymentParams);
+		} catch (e) {
+			console.error('결제 요청 실패:', e);
+			alert('결제 요청에 실패했습니다.');
+			setLoading(false);
+		}
+	};
+
+	return (
+		<div className="charge-page">
+			<div className="charge-page__card">
+				<div className="charge-page__header">
+					<h1>포인트 충전</h1>
+					{isPopup && (
+						<button type="button" className="charge-page__close" onClick={() => window.close()}>×</button>
+					)}
+				</div>
+
+				<div className="charge-page__body">
+					<label className="charge-page__label">충전 금액</label>
+					<input
+						type="text"
+						className="charge-page__amount-input"
+						value={amount > 0 ? amount.toLocaleString() : ''}
+						onChange={e => handleAmountChange(e.target.value)}
+						placeholder="0"
+						inputMode="numeric"
+						autoFocus
+					/>
+
+					<div className="charge-page__quick-btns">
+						{QUICK_AMOUNTS.map(v => (
+							<button type="button" key={v} className="charge-page__quick-btn" onClick={() => addAmount(v)}>
+								+{v >= 10000 ? `${v / 10000}만` : v.toLocaleString()}
+							</button>
+						))}
+					</div>
+
+					<div className="charge-page__method-section">
+						<label className="charge-page__label">결제 수단</label>
+						<div className="charge-page__method-grid">
+							{PAYMENT_METHODS.map(m => (
+								<button
+									type="button"
+									key={m.value}
+									className={`charge-page__method-btn${paymentMethod === m.value ? ' charge-page__method-btn--active' : ''}`}
+									onClick={() => setPaymentMethod(m.value)}
+								>
+									<span className="charge-page__method-icon">
+										<m.Icon width={24} height={24} aria-label={m.label} />
+									</span>
+									<span>{m.label}</span>
+								</button>
+							))}
+						</div>
+					</div>
+
+					<div className="charge-page__receipt">
+						<div className="charge-page__receipt-row">
+							<span>충전 (P)</span>
+							<span>{pointAmount.toLocaleString()} P</span>
+						</div>
+						<div className="charge-page__receipt-row">
+							<span>부가세 (10%)</span>
+							<span>{vatAmount.toLocaleString()} 원</span>
+						</div>
+						<div className="charge-page__receipt-row charge-page__receipt-row--total">
+							<span>총 결제 금액</span>
+							<span>{amount.toLocaleString()} 원</span>
+						</div>
+					</div>
+
+					<button type="button" className="charge-page__submit" onClick={handleSubmit} disabled={loading || amount < 1000}>
+						{loading ? '처리 중...' : '충전하기'}
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 252 - 0
app/charge/style.scss

@@ -0,0 +1,252 @@
+.charge-layout {
+	position: relative;
+	min-height: 100vh;
+}
+
+.charge-page {
+	width: 100%;
+	padding: 0;
+	min-height: inherit;
+
+	&__card {
+		border-radius: 0;
+		overflow: hidden;
+		min-height: inherit;
+	}
+
+	&__header {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		padding: 10px;
+		background: var(--bg-head);
+		color: var(--text-normal);
+
+		h1 {
+			font-size: 1.125rem;
+			font-weight: 700;
+			margin: 0;
+		}
+	}
+
+	&__close {
+		width: 32px;
+		height: 32px;
+		background: transparent;
+		font-size: 1.25rem;
+		cursor: pointer;
+
+		&:hover {
+			background: var(--bg-subtle);
+			color: var(--text-primary);
+		}
+	}
+
+	&__body {
+		padding: 16px 20px 24px;
+	}
+
+	&__label {
+		font-size: 0.8125rem;
+		font-weight: 600;
+		color: var(--text-secondary);
+		margin-bottom: 10px;
+		display: block;
+	}
+
+	&__amount-input {
+		width: 100%;
+		padding: 6px 14px;
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		font-size: 1.125rem;
+		font-weight: 600;
+		outline: none;
+		text-align: right;
+		background: var(--bg-page);
+		color: var(--fg-default);
+
+		&:focus {
+			border-color: #3b82f6;
+			box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
+		}
+	}
+
+	&__quick-btns {
+		display: flex;
+		gap: 6px;
+		margin-top: 8px;
+	}
+
+	&__quick-btn {
+		flex: 1;
+		padding: 8px 0;
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		font-size: 0.75rem;
+		font-weight: 500;
+		background: var(--bg-page, #fff);
+		color: var(--fg-default);
+		cursor: pointer;
+		transition: all 0.15s;
+
+		&:hover {
+			background: var(--bg-subtle);
+			border-color: #3b82f6;
+			color: #bb1a98;
+		}
+	}
+
+	&__method-section {
+		margin-top: 16px;
+	}
+
+	&__method-grid {
+		display: grid;
+		grid-template-columns: repeat(4, 1fr);
+		gap: 6px;
+		margin-top: 8px;
+	}
+
+	&__method-btn {
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		gap: 4px;
+		padding: 10px 4px;
+		border: 1px solid var(--border-default);
+		border-radius: 8px;
+		font-size: 0.6875rem;
+		font-weight: 500;
+		background: var(--bg-page);
+		color: var(--fg-default);
+		cursor: pointer;
+		transition: all 0.15s;
+
+		&:hover:not(&--active) {
+			background: var(--bg-subtle);
+		}
+
+		&--active {
+			border-color: #3b82f6;
+			background: rgba(59, 130, 246, 0.08);
+			color: #3b82f6;
+			font-weight: 600;
+		}
+	}
+
+	&__method-icon {
+		font-size: 1.125rem;
+		line-height: 1;
+		padding: 2px 27px;
+
+		> svg {
+			fill: var(--text-primary);
+		}
+	}
+
+	&__receipt {
+		width: 100%;
+		margin-top: 20px;
+		padding: 16px;
+		background: var(--bg-subtle);
+		border-radius: 8px;
+	}
+
+	&__receipt-row {
+		display: flex;
+		justify-content: space-between;
+		font-size: 0.8125rem;
+		padding: 4px 0;
+		color: var(--text-secondary);
+
+		&--total {
+			font-size: 0.9375rem;
+			font-weight: 700;
+			color: var(--fg-default);
+			border-top: 1px solid var(--border-default);
+			margin-top: 8px;
+			padding-top: 10px;
+		}
+	}
+
+	&__submit {
+		width: 100%;
+		padding: 10px 0;
+		margin-top: 16px;
+		border: none;
+		border-radius: 8px;
+		background: #2a8db3;
+		color: #fff;
+		font-size: 1rem;
+		font-weight: 600;
+		cursor: pointer;
+		transition: background 0.15s;
+
+		&:hover {
+			background: #82b917;
+		}
+
+		&:disabled {
+			background: var(--text-muted);
+			cursor: not-allowed;
+		}
+	}
+
+	// 결제 결과
+	&__result {
+		padding: 40px 20px;
+		min-height: inherit;
+		display: flex;
+		flex-direction: column;
+		align-items: center;
+		justify-content: center;
+	}
+
+	&__result-icon {
+		width: 56px;
+		height: 56px;
+		border-radius: 50%;
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		font-size: 1.5rem;
+		font-weight: 700;
+		margin-bottom: 16px;
+
+		&--success {
+			background: #dcfce7;
+			color: #16a34a;
+		}
+
+		&--error {
+			background: #fee2e2;
+			color: #dc2626;
+		}
+	}
+
+	&__result-message {
+		font-size: 1rem;
+		font-weight: 600;
+	}
+
+	&__result-code {
+		font-size: 0.75rem;
+		color: var(--text-muted);
+		margin-bottom: 16px;
+	}
+
+	&__spinner {
+		width: 40px;
+		height: 40px;
+		border: 3px solid var(--border-default);
+		border-top-color: #3b82f6;
+		border-radius: 50%;
+		animation: charge-spin 0.8s linear infinite;
+		margin: 0 auto 16px;
+	}
+}
+
+@keyframes charge-spin {
+	to { transform: rotate(360deg); }
+}

+ 120 - 0
app/charge/success/page.tsx

@@ -0,0 +1,120 @@
+'use client';
+
+import '../style.scss';
+import { useEffect, useRef, useState } from 'react';
+import { useSearchParams } from 'next/navigation';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import { DanalConfirmRequest } from '@/types/request/payment/charge';
+import { DanalConfirmResponse } from '@/types/response/payment/charge';
+
+const METHOD_LABELS: Record<string, string> = {
+	CARD: '신용카드',
+	KAKAOPAY: '카카오페이',
+	NAVERPAY: '네이버페이',
+	TRANSFER: '계좌이체',
+	MOBILE: '휴대폰',
+	VIRTUAL_ACCOUNT: '가상계좌'
+};
+
+export default function ChargeSuccessPage()
+{
+	const searchParams = useSearchParams();
+	const [status, setStatus] = useState<'loading'|'success'|'error'>('loading');
+	const [message, setMessage] = useState('결제 승인 처리 중...');
+	const [pointAmount, setPointAmount] = useState<number>(0);
+	const [paidAt, setPaidAt] = useState<string|null>(null);
+	const calledRef = useRef(false);
+
+	useEffect(() => {
+		if (calledRef.current) {
+			return;
+		}
+
+		calledRef.current = true;
+		confirmPayment();
+
+	}, []);
+
+	const confirmPayment = async () => {
+		const orderID = searchParams.get('orderId');
+		const transactionID = searchParams.get('transactionId');
+		const method = searchParams.get('method');
+
+		if (!orderID || !transactionID || !method) {
+			setStatus('error');
+			setMessage('결제 정보가 올바르지 않습니다.');
+			return;
+		}
+
+		try {
+			const res = await fetchApi<DanalConfirmResponse>('/api/payment/confirm', {
+				method: 'POST',
+				body: { orderID, transactionID, method } as DanalConfirmRequest
+			});
+
+			if (res.success && res.data) {
+				setPointAmount(res.data.pointAmount);
+				setPaidAt(res.data.paidAt);
+				setStatus('success');
+				setMessage('포인트 충전 완료');
+
+				if (window.opener) {
+					window.opener.postMessage({ type: 'CHARGE_COMPLETE' }, '*');
+				}
+			} else {
+				setStatus('error');
+				setMessage(res.message || '결제 승인에 실패했습니다.');
+			}
+		} catch {
+			setStatus('error');
+			setMessage('결제 승인 중 오류가 발생했습니다.');
+		}
+	};
+
+	const method = searchParams.get('method') ?? '';
+	const methodLabel = METHOD_LABELS[method] ?? method;
+
+	const handleClose = () => {
+		if (window.opener) {
+			window.close();
+		} else {
+			window.location.href = '/';
+		}
+	};
+
+	return (
+		<div className="charge-page">
+			<div className="charge-page__card">
+				<div className="charge-page__result">
+					{status === 'loading' && <div className="charge-page__spinner" />}
+					{status === 'success' && <div className="charge-page__result-icon charge-page__result-icon--success">✓</div>}
+					{status === 'error' && <div className="charge-page__result-icon charge-page__result-icon--error">✕</div>}
+					<p className="charge-page__result-message">{message}</p>
+
+					{status === 'success' && (
+						<div className="charge-page__receipt">
+							<div className="charge-page__receipt-row">
+								<span>결제 금액</span>
+								<span>{pointAmount.toLocaleString()} 원</span>
+							</div>
+							<div className="charge-page__receipt-row">
+								<span>결제 수단</span>
+								<span>{methodLabel}</span>
+							</div>
+							<div className="charge-page__receipt-row">
+								<span>결제 일시</span>
+								<span>{getDateTime(paidAt)}</span>
+							</div>
+						</div>
+					)}
+
+					{status !== 'loading' && (
+						<button type="button" className="charge-page__submit" onClick={handleClose}>
+							닫기
+						</button>
+					)}
+				</div>
+			</div>
+		</div>
+	);
+}

+ 4 - 4
app/component/EmojiPicker.tsx

@@ -1,11 +1,11 @@
 'use client';
 
+import '@/styles/emoji-picker.scss';
 import dynamic from 'next/dynamic';
 import { useState, useRef, useEffect } from 'react';
 import { EmojiClickData, EmojiStyle, Categories } from 'emoji-picker-react';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
 import { faFaceSmile } from '@fortawesome/free-regular-svg-icons';
-import '@/styles/emoji-picker.scss';
 
 const Picker = dynamic(
 	() => {
@@ -57,12 +57,12 @@ export default function EmojiPicker({ onEmojiSelect }: Props) {
 						searchPlaceholder="그림 이모니콘 검색"
 						onEmojiClick={handleEmojiClick}
 						emojiStyle={EmojiStyle.NATIVE}
-						previewConfig={{ 
-							showPreview: false 
+						previewConfig={{
+							showPreview: false
 						}}
 						lazyLoadEmojis={true}
 						width="30rem"
-						height="20rem" 
+						height="20rem"
 						categories={[
 							{ category: Categories.SMILEYS_PEOPLE, name: '인물' },
 							{ category: Categories.ANIMALS_NATURE, name: '자연' },

+ 32 - 0
app/component/Footer.tsx

@@ -0,0 +1,32 @@
+'use client';
+
+import Styles from '../styles/layout.module.scss';
+import Link from 'next/link';
+import { useConfigContext } from '@/contexts/configProvider';
+
+export default function Footer()
+{
+	const config = useConfigContext();
+
+    return (
+        <footer id='footer' className={`${Styles.footer} px-4`}>
+            <ol>
+                <li>
+                    <Link href='https://dpot.company'>회사소개</Link>
+                </li>
+                <li>
+                    <Link href='/docs/teams'>이용약관</Link>
+                </li>
+                <li>
+                    <Link href='/docs/privacy'>개인정보처리방침</Link>
+                </li>
+                <li>
+                    <Link href={'https://www.ftc.go.kr/bizCommPop.do?wrkr_no=' + config.company.regNo} target='_blank' rel='noopener noreferrer'>
+                        사업자 정보
+                    </Link>
+                </li>
+                <li>© 2025 DPOT. All rights reserved.</li>
+            </ol>
+        </footer>
+    );
+}

+ 107 - 0
app/component/Header.tsx

@@ -0,0 +1,107 @@
+'use client';
+
+import Styles from '../styles/layout.module.scss';
+import { useCallback } from 'react';
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+import useAuth from '@/hooks/useAuth';
+import useDragScroll from '@/hooks/useDragScroll';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faBars, faXmark } from '@fortawesome/free-solid-svg-icons';
+import NotificationBell from '@/app/component/NotificationBell';
+import Profile from '@/app/component/Profile';
+import PointChargeIcon from '@/public/icons/layout/point.svg';
+
+const navItems = [
+	{ href: '/', label: '생방송' },
+	{ href: '/feed', label: '피드' },
+	{ href: '/ranking', label: '순위' },
+	{ href: '/market', label: '상점' },
+	{ href: '/attendance', label: '출석부' },
+	{ href: '/board/notice', label: '고객지원' },
+];
+
+type Props = {
+	sidebarOpen: boolean;
+	onToggle: () => void;
+};
+
+export default function Header({ sidebarOpen, onToggle }: Props)
+{
+	const { isAuthenticated } = useAuth();
+	const pathname = usePathname();
+	const dragScroll = useDragScroll<HTMLDivElement>();
+
+	const handlePopupCharge = useCallback(() => {
+		const w = 450, h = 635;
+		const left = window.screenX + (window.outerWidth - w) / 2;
+		const top = window.screenY + (window.outerHeight - h) / 2;
+		window.open('/charge', 'charge', `width=${w},height=${h},left=${left},top=${top},scrollbars=no,resizable=no`);
+	}, []);
+
+	return (
+		<header id='header' className={Styles.header}>
+
+			{/* 1줄: 로고 + 우측 아이콘 (PC에서는 전체 내비) */}
+			<div className={Styles.headerRow1}>
+				<button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴'>
+					<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
+				</button>
+
+				<Link href='/' className={Styles.logo}>
+					<picture>
+						<source src="image.webp" type="image/webp" />
+						{/* <img src="/resources/logo.jpg" alt="DPOT" /> */}
+						로고 자리
+					</picture>
+				</Link>
+
+				{/* PC 내비게이션 */}
+				<nav className={Styles.pcNav}>
+					<ul className='flex gap-4'>
+						{navItems.map(item => (
+							<li key={item.label}>
+								<Link href={item.href}>{item.label}</Link>
+							</li>
+						))}
+					</ul>
+				</nav>
+
+				{/* 우측 아이콘 (항상 표시) */}
+				<div className={Styles.headerActions}>
+					{isAuthenticated && (
+						<>
+							<button type="button" className={Styles.chargeBtn} onClick={handlePopupCharge} title="포인트 충전">
+								<PointChargeIcon width={24} height={24} />
+							</button>
+							<NotificationBell />
+						</>
+					)}
+					<Profile />
+				</div>
+			</div>
+
+			{/* 2줄: 모바일 전용 가로 스크롤 탭 */}
+			<div className={Styles.headerRow2}>
+				<div
+					className={Styles.mobileTabScroll}
+					ref={dragScroll.ref}
+					onMouseDown={dragScroll.onMouseDown}
+					onMouseMove={dragScroll.onMouseMove}
+					onMouseUp={dragScroll.onMouseUp}
+					onMouseLeave={dragScroll.onMouseLeave}
+				>
+					{navItems.map(item => (
+						<Link
+							key={item.label}
+							href={item.href}
+							className={`${Styles.mobileTab}${pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href)) ? ` ${Styles.mobileTabActive}` : ''}`}
+						>
+							{item.label}
+						</Link>
+					))}
+				</div>
+			</div>
+		</header>
+	);
+}

+ 22 - 172
app/component/Layout.tsx

@@ -1,200 +1,50 @@
 'use client';
 
-import { useState, useCallback, useEffect } from 'react';
-import Link from 'next/link';
-import { usePathname } from 'next/navigation';
-import Styles from '../styles/common.module.scss';
-import useAuth from '@/hooks/useAuth';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faBars, faXmark, faComments, faHeadset, faUser, faRightToBracket, faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
-import { faCommentDots, faClock } from '@fortawesome/free-regular-svg-icons';
-import useTheme from '@/hooks/useTheme';
-import ChatSidebar from '@/app/component/chat/ChatSidebar';
+import { useState, useCallback } from 'react';
+import Link from 'next/link'
+import Styles from '../styles/layout.module.scss';
+import Header from './Header';
+import Footer from './Footer'
+import ChannelSidebar from '@/app/component/channel/ChannelSidebar';
 
 type Props = {
 	children: React.ReactNode;
 };
 
 export default function Layout({ children }: Props) {
-	const { isAuthenticated, isLoading, logout } = useAuth();
-	const { toggleTheme, isDark } = useTheme();
 	const [sidebarOpen, setSidebarOpen] = useState(false);
-	const [chatOpen, setChatOpen] = useState(false);
-	const pathname = usePathname();
 
 	const toggleSidebar = useCallback(() => {
 		setSidebarOpen((prev) => !prev);
 	}, []);
 
-	const toggleChat = useCallback(() => {
-		setChatOpen((prev) => !prev);
-	}, []);
-
 	const closeOverlay = useCallback(() => {
 		setSidebarOpen(false);
-		setChatOpen(false);
-	}, []);
-
-	const [currentTime, setCurrentTime] = useState('');
-	useEffect(() => {
-		const updateTime = () => {
-			const now = new Date();
-			setCurrentTime(
-				`${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`
-			);
-		};
-		updateTime();
-		const timer = setInterval(updateTime, 1000);
-		return () => clearInterval(timer);
 	}, []);
 
-	if (isLoading) {
-		return <></>;
-	}
+	return (
+		<>
+			<div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}`}>
 
-    return (
-        <>
-            <div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}${chatOpen ? ` ${Styles.chatOpen}` : ''}`}>
-
-                {/* 상단 내용 */}
-                <header id='header' className={`${Styles.header} flex items-center w-full px-4`}>
-					<button type='button' className={Styles.hamburger} onClick={toggleSidebar} aria-label='메뉴'>
-						<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
-					</button>
-					<Link href='/' className={Styles.logo}>
-						<picture>
-							<source src="image.webp" type="image/webp" />
-							<img src="/resources/logo.svg" alt="bitforum logo" />
-						</picture>
-					</Link>
-                    <nav className={Styles.pcNav}>
-                        <ul className='flex gap-4'>
-                            <li>
-                                <Link href='/'>
-                                    생방송
-                                </Link>
-                            </li>
-							 <li>
-                                <Link href='/feed'>
-                                    피드
-                                </Link>
-                            </li>
-                            <li>
-                                <Link href='/latest'>
-                                    순위
-                                </Link>
-                            </li>
-							<li>
-                                <Link href='/market'>
-                                    상점
-                                </Link>
-                            </li>
-								<li>
-                                <Link href='/market'>
-                                    출석부
-                                </Link>
-                            </li>
-                            <li>
-                                <Link href='/board/notice'>
-                                    고객지원
-                                </Link>
-                            </li>
-                        </ul>
-						<ul className='flex gap-4 items-center'>
-							<li>
-								<button type='button' onClick={toggleTheme} aria-label='다크모드 전환' title={isDark ? '라이트 모드' : '다크 모드'}>
-									<FontAwesomeIcon icon={isDark ? faSun : faMoon} />
-								</button>
-							</li>
-							<li className={`${Styles.clock} text-sm opacity-70`} style={{ fontVariantNumeric: 'tabular-nums' }}>
-								<FontAwesomeIcon icon={faClock} className='mr-1' />{currentTime}
-							</li>
-						{!isAuthenticated ? (
-							<>
-								<li>
-									<a href='/login'>
-										로그인
-									</a>
-								</li>
-								<li>
-									<a href='/register'>
-										회원가입
-									</a>
-								</li>
-							</>
-						) : (
-							<>
-								<li>
-									<Link href='/profile'>
-										내 정보
-									</Link>
-								</li>
-								<li>
-									<button type='button' onClick={logout}>
-										로그아웃
-									</button>
-								</li>
-							</>
-						)}
-						</ul>
-                    </nav>
-					<button type='button' className={Styles.chatToggle} onClick={toggleChat} aria-label='채팅'>
-						<FontAwesomeIcon icon={faCommentDots} />
-					</button>
-                </header>
+				<Header sidebarOpen={sidebarOpen} onToggle={toggleSidebar} />
 
 				{/* 모바일 오버레이 */}
 				<div className={Styles.overlay} onClick={closeOverlay} />
 
-                {/* 메인 내용 */}
-                <main id='main' className={`${Styles.main} relative`}>
-                    {children}
-                </main>
-
-				{/* 우측 채팅 사이드바 */}
-				<aside id='chatAside' className={Styles.chatAside}>
-					<ChatSidebar />
+				{/* 좌측 채널 사이드바 */}
+				<aside id='aside' className={Styles.aside}>
+					<ChannelSidebar />
 				</aside>
 
-                {/* 하단 내용 */}
-                <footer id='footer' className={`${Styles.footer} px-4`}>
-                    <ol>
-                        <li>
-							<Link href='/docs/teams'>이용약관</Link>
-						</li>
-
-                        <li>
-							<Link href='/docs/privacy'>개인정보처리방침</Link>
-						</li>
+				{/* 메인 내용 */}
+				<main id='main' className={`${Styles.main} relative`}>
+					{children}
+				</main>
 
-                        {/* 저작권 표시 */}
-                        <li>© 2025 PLAYR. All rights reserved.</li>
-                    </ol>
-                </footer>
+				{/* 하단 */}
+				<Footer />
 
-				{/* 모바일 하단 탭바 */}
-				<nav className={Styles.bottomTab}>
-					<Link href='/latest' className={pathname.startsWith('/latest') || (pathname.startsWith('/board') && !pathname.startsWith('/board/notice')) || pathname.startsWith('/post') ? Styles.active : undefined}>
-						<FontAwesomeIcon icon={faComments} />
-						<span>토론</span>
-					</Link>
-					<Link href='/board/notice' className={pathname.startsWith('/board/notice') || pathname.startsWith('/support') ? Styles.active : undefined}>
-						<FontAwesomeIcon icon={faHeadset} />
-						<span>고객지원</span>
-					</Link>
-					{isAuthenticated ? (
-						<Link href='/profile' className={pathname.startsWith('/profile') ? Styles.active : undefined}>
-							<FontAwesomeIcon icon={faUser} />
-							<span>내 정보</span>
-						</Link>
-					) : (
-						<a href='/login'>
-							<FontAwesomeIcon icon={faRightToBracket} />
-							<span>로그인</span>
-						</a>
-					)}
-				</nav>
-            </div>
-        </>
-    );
+			</div>
+		</>
+	);
 }

+ 150 - 0
app/component/NotificationBell.tsx

@@ -0,0 +1,150 @@
+'use client';
+
+import '@/app/styles/notification-bell.scss';
+import { useState, useEffect, useRef } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useNotification, NotificationItem } from '@/hooks/useNotification';
+import { NotificationListResponse } from '@/types/response/notification/list';
+import {
+  	BellIcon,
+} from "lucide-react"
+
+export default function NotificationBell()
+{
+	const { unreadNotifCount, unreadNoteCount, latestNotification, markNotifRead, clearLatestNotification } = useNotification();
+	const [open, setOpen] = useState(false);
+	const [notifications, setNotifications] = useState<NotificationItem[]>([]);
+	const [loading, setLoading] = useState(false);
+	const dropdownRef = useRef<HTMLDivElement>(null);
+	const totalUnread = unreadNotifCount + unreadNoteCount;
+
+	// toast 알림 (3초 후 사라짐)
+	const [toast, setToast] = useState<string|null>(null);
+	useEffect(() => {
+		if (latestNotification) {
+			setToast(latestNotification.title);
+			const timer = setTimeout(() => {
+				setToast(null);
+				clearLatestNotification();
+			}, 3000);
+			return () => clearTimeout(timer);
+		}
+	}, [latestNotification, clearLatestNotification]);
+
+	// 드롭다운 열 때 목록 로드
+	const handleToggle = async () => {
+		const next = !open;
+		setOpen(next);
+		if (next && notifications.length === 0) {
+			setLoading(true);
+			try {
+				const res = await fetchApi<NotificationListResponse>('/api/notification/list?page=1&perPage=10');
+				if (res.data?.list) {
+					setNotifications(res.data.list);
+				}
+			} catch {}
+			setLoading(false);
+		}
+	};
+
+	// 읽음 처리
+	const handleRead = async (item: NotificationItem) => {
+		if (!item.isRead) {
+			await markNotifRead(item.id);
+			setNotifications(prev => prev.map(n => n.id === item.id ? { ...n, isRead: true } : n));
+		}
+		if (item.actionUrl) {
+			window.location.href = item.actionUrl;
+		}
+	};
+
+	// 전체 읽음
+	const handleReadAll = async () => {
+		await markNotifRead();
+		setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
+	};
+
+	// 외부 클릭 시 닫기
+	useEffect(() => {
+		const handler = (e: MouseEvent) => {
+			if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
+				setOpen(false);
+			}
+		};
+		document.addEventListener('mousedown', handler);
+		return () => document.removeEventListener('mousedown', handler);
+	}, []);
+
+	const formatTime = (dateStr: string) => {
+		const d = new Date(dateStr);
+		const now = new Date();
+		const diff = Math.floor((now.getTime() - d.getTime()) / 60000);
+
+		if (diff < 1) {
+			return '방금';
+		}
+		if (diff < 60) {
+			return `${diff}분 전`;
+		}
+		if (diff < 1440) {
+			return `${Math.floor(diff / 60)}시간 전`;
+		}
+		return `${Math.floor(diff / 1440)}일 전`;
+	};
+
+	return (
+		<div ref={dropdownRef} className="notification-bell">
+			{/* 벨 버튼 */}
+			<button type="button" onClick={handleToggle} className="notification-bell__button" title="알림">
+				<BellIcon />
+				{totalUnread > 0 && (
+					<span className="notification-bell__badge">
+						{totalUnread > 99 ? '99+' : totalUnread}
+					</span>
+				)}
+			</button>
+
+			{/* Toast */}
+			{toast && (
+				<div className="notification-bell__toast">
+					{toast}
+				</div>
+			)}
+
+			{/* 드롭다운 */}
+			{open && (
+				<div className="notification-bell__dropdown">
+					{/* 헤더 */}
+					<div className="notification-bell__header">
+						<span className="notification-bell__header-title">알림</span>
+						{unreadNotifCount > 0 && (
+							<button type="button" onClick={handleReadAll} className="notification-bell__read-all-btn">모두 읽음</button>
+						)}
+					</div>
+
+					{/* 목록 */}
+					{loading && <div className="notification-bell__loading">준비 중...</div>}
+					{!loading && notifications.length === 0 && <div className="notification-bell__empty">알림이 없습니다</div>}
+					{notifications.map(n => (
+						<div key={n.id} onClick={() => handleRead(n)} className={`notification-bell__item ${n.isRead ? '' : 'notification-bell__item--unread'}`}>
+							{n.imageUrl && <img src={n.imageUrl} alt={n.title} className="notification-bell_item-image" />}
+							<div className="notification-bell__item-content">
+								<div className={`notification-bell__item-title ${n.isRead ? 'notification-bell__item-title--read' : 'notification-bell__item-title--unread'}`}>{n.title}</div>
+								<div className="notification-bell__item-message">{n.message}</div>
+								<div className="notification-bell__item-time">{formatTime(n.createdAt)}</div>
+							</div>
+							{!n.isRead && <div className="notification-bell__item-dot" />}
+						</div>
+					))}
+
+					{/* 하단 링크 */}
+					<div className="notification-bell__footer">
+						<a href="/notification" className="notification-bell__footer-link">모든 알림 보기</a>
+						<span className="notification-bell__footer-divider">|</span>
+						<a href="/note/inbox" className="notification-bell__footer-link">쪽지함 ({unreadNoteCount})</a>
+					</div>
+				</div>
+			)}
+		</div>
+	);
+}

+ 5 - 6
app/component/Pagination.tsx

@@ -1,8 +1,7 @@
 'use client';
 
-// @/app/component/pagination.tsx
+import '../styles/common.scss';
 import React from 'react';
-import Styles from '../styles/common.module.scss';
 
 interface PaginationProps {
 	total: number;
@@ -31,7 +30,7 @@ export default function Pagination({ total, page = 1, perPage = 10, onChange }:
 	}
 
     return (
-        <div id={Styles.pagination} className='mt-3'>
+        <div id='pagination' className='mt-3'>
 			{totalPage > 1 && (
 				<button type='button' onClick={handlePrevious} disabled={page === 1}>
 					이전
@@ -40,7 +39,7 @@ export default function Pagination({ total, page = 1, perPage = 10, onChange }:
 
 			{totalPage > 7 ? (
 				<>
-					{page > 3 && <button onClick={() => onChange(1)}>1</button>}
+					{page > 3 && <button type="button" onClick={() => onChange(1)}>1</button>}
 					{page > 4 && <span>...</span>}
 
 					{Array.from({ length: 5 }, (_, index) => {
@@ -50,7 +49,7 @@ export default function Pagination({ total, page = 1, perPage = 10, onChange }:
 								<button type='button'
 									key={pageNumber}
 									onClick={() => onChange(pageNumber)}
-									{...(page === pageNumber ? { className: Styles.active } : {})}
+									{...(page === pageNumber ? { className: 'active' } : {})}
 								>
 									{pageNumber}
 								</button>
@@ -67,7 +66,7 @@ export default function Pagination({ total, page = 1, perPage = 10, onChange }:
 					<button type='button'
 						key={index + 1}
 						onClick={() => onChange(index + 1)}
-						{...(page === index + 1 ? { className: Styles.active } : {})}
+						{...(page === index + 1 ? { className: 'active' } : {})}
 					>
 						{index + 1}
 					</button>

+ 0 - 204
app/component/PopupModal.scss

@@ -1,204 +0,0 @@
-// 팝업 모달 오버레이
-.popup-modal-overlay {
-	position: fixed;
-	inset: 0;
-	z-index: 9999;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-	background: var(--overlay-color);
-	backdrop-filter: blur(4px);
-	-webkit-backdrop-filter: blur(4px);
-	animation: popupFadeIn 0.2s ease-out;
-}
-
-// 팝업 컨테이너
-.popup-modal-container {
-	position: relative;
-	width: 90%;
-	max-width: 500px;
-	max-height: 80vh;
-	background: var(--bg-page);
-	border-radius: 12px;
-	overflow: hidden;
-	box-shadow: 0 20px 60px var(--overlay-color);
-	animation: popupSlideUp 0.25s ease-out;
-}
-
-// 팝업 본문
-.popup-modal-body {
-	max-height: calc(80vh - 50px);
-	overflow-y: auto;
-
-	// Swiper 커스텀 스타일
-	.swiper {
-		width: 100%;
-	}
-
-	.swiper-button-prev,
-	.swiper-button-next {
-		color: #fff;
-		background: var(--overlay-color);
-		width: 36px;
-		height: 36px;
-		border-radius: 50%;
-		transition: background 0.2s;
-
-		&:hover {
-			background: var(--overlay-color);
-		}
-
-		&::after {
-			font-size: 14px;
-			font-weight: bold;
-		}
-	}
-
-	.swiper-pagination-bullet {
-		background: #fff;
-		opacity: 0.5;
-
-		&-active {
-			opacity: 1;
-			background: var(--color-primary-600, #2563eb);
-		}
-	}
-}
-
-// 팝업 슬라이드
-.popup-slide {
-	padding: 24px;
-}
-
-.popup-slide-subject {
-	font-size: 1.15rem;
-	font-weight: 700;
-	margin-bottom: 12px;
-	color: var(--text-primary);
-	line-height: 1.4;
-}
-
-.popup-slide-content {
-	font-size: 0.95rem;
-	line-height: 1.7;
-	color: var(--text-primary);
-	word-break: keep-all;
-
-	img {
-		max-width: 100%;
-		height: auto;
-		border-radius: 8px;
-	}
-
-	a {
-		color: var(--color-primary-600, #2563eb);
-		text-decoration: underline;
-	}
-}
-
-.popup-slide-link {
-	display: block;
-	text-decoration: none;
-	color: inherit;
-	cursor: pointer;
-
-	&:hover .popup-slide-content {
-		opacity: 0.85;
-	}
-}
-
-// 하단 - 하루동안 보지않기 + 닫기
-.popup-modal-footer {
-	display: flex;
-	align-items: center;
-	justify-content: space-between;
-	padding: 10px 16px;
-	border-top: 1px solid var(--border-default);
-	background: var(--bg-elevated);
-}
-
-.popup-modal-dismiss {
-	display: flex;
-	align-items: center;
-	gap: 6px;
-	cursor: pointer;
-	user-select: none;
-
-	input[type='checkbox'] {
-		width: 16px;
-		height: 16px;
-		accent-color: var(--color-primary-600, #2563eb);
-		cursor: pointer;
-	}
-
-	span {
-		font-size: 0.85rem;
-		color: var(--text-secondary);
-	}
-}
-
-.popup-modal-close {
-	padding: 6px 16px;
-	font-size: 0.85rem;
-	font-weight: 600;
-	color: var(--text-secondary);
-	background: var(--border-default);
-	border: none;
-	border-radius: 6px;
-	cursor: pointer;
-	transition: background 0.15s;
-
-	&:hover {
-		background: var(--border-strong);
-	}
-}
-
-// 애니메이션
-@keyframes popupFadeIn {
-	from {
-		opacity: 0;
-	}
-	to {
-		opacity: 1;
-	}
-}
-
-@keyframes popupSlideUp {
-	from {
-		opacity: 0;
-		transform: translateY(20px);
-	}
-	to {
-		opacity: 1;
-		transform: translateY(0);
-	}
-}
-
-// 반응형
-@media (max-width: 480px) {
-	.popup-modal-container {
-		width: 95%;
-		max-height: 85vh;
-		border-radius: 10px;
-	}
-
-	.popup-slide {
-		padding: 16px;
-	}
-
-	.popup-slide-subject {
-		font-size: 1.05rem;
-	}
-
-	.popup-modal-body {
-		.swiper-button-prev,
-		.swiper-button-next {
-			width: 30px;
-			height: 30px;
-
-			&::after {
-				font-size: 12px;
-			}
-		}
-	}
-}

+ 11 - 11
app/component/PopupModal.tsx

@@ -9,7 +9,7 @@ import { fetchApi } from '@/lib/utils/client';
 import 'swiper/css';
 import 'swiper/css/navigation';
 import 'swiper/css/pagination';
-import './PopupModal.scss';
+import '../styles/popup.scss';
 
 interface PopupModalProps {
 	position: string;
@@ -95,10 +95,10 @@ export default function PopupModal({ position }: PopupModalProps) {
 	}
 
 	return (
-		<div className='popup-modal-overlay' onClick={handleClose}>
-			<div className='popup-modal-container' onClick={(e) => e.stopPropagation()}>
+		<div className='popup-modal__overlay' onClick={handleClose}>
+			<div className='popup-modal__container' onClick={(e) => e.stopPropagation()}>
 				{/* 팝업 본문 */}
-				<div className='popup-modal-body'>
+				<div className='popup-modal__body'>
 					{items.length === 1 ? (
 						// 단일 팝업
 						<PopupSlide item={items[0]} />
@@ -122,8 +122,8 @@ export default function PopupModal({ position }: PopupModalProps) {
 				</div>
 
 				{/* 하단 - 하루 동안 보지 않기 + 닫기 */}
-				<div className='popup-modal-footer'>
-					<label className='popup-modal-dismiss'>
+				<div className='popup-modal__footer'>
+					<label className='popup-modal__dismiss'>
 						<input
 							type='checkbox'
 							checked={dismissToday}
@@ -131,7 +131,7 @@ export default function PopupModal({ position }: PopupModalProps) {
 						/>
 						<span>하루 동안 보지 않기</span>
 					</label>
-					<button type='button' className='popup-modal-close' onClick={handleClose}>
+					<button type='button' className='popup-modal__close' onClick={handleClose}>
 						닫기
 					</button>
 				</div>
@@ -143,13 +143,13 @@ export default function PopupModal({ position }: PopupModalProps) {
 // 개별 팝업 슬라이드
 function PopupSlide({ item }: { item: PopupItem }) {
 	const content = (
-		<div className='popup-slide'>
+		<div className='popup-modal__slide'>
 			{item.subject && (
-				<h3 className='popup-slide-subject'>{item.subject}</h3>
+				<h3 className='popup-modal__slide-subject'>{item.subject}</h3>
 			)}
 			{item.content && (
 				<div
-					className='popup-slide-content'
+					className='popup-modal__slide-content'
 					dangerouslySetInnerHTML={{ __html: item.content }}
 				/>
 			)}
@@ -158,7 +158,7 @@ function PopupSlide({ item }: { item: PopupItem }) {
 
 	if (item.link) {
 		return (
-			<a href={item.link} target='_blank' rel='noopener noreferrer' className='popup-slide-link'>
+			<a href={item.link} target='_blank' rel='noopener noreferrer' className='popup-modal__slide-link'>
 				{content}
 			</a>
 		);

+ 203 - 0
app/component/Profile.tsx

@@ -0,0 +1,203 @@
+'use client';
+
+import '../styles/profile.scss';
+import { useEffect, useState } from 'react';
+import Link from 'next/link';
+import { GoogleOAuthProvider, GoogleLogin, useGoogleOneTapLogin } from '@react-oauth/google';
+import useAuth from '@/hooks/useAuth';
+import { useConfigContext } from '@/contexts/configProvider';
+import { fetchApi } from '@/lib/utils/client';
+import { DropdownData } from '@/types/response/mypage/dropdown';
+import { LoginResponse } from '@/types/response/auth';
+import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
+import SignalSteamIcon from '@/public/icons/user/signal-steam.svg';
+
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuLabel,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+
+import {
+	Package,
+	UserRoundCog,
+	LogOut
+} from 'lucide-react';
+
+export default function Profile()
+{
+	const config = useConfigContext();
+	const clientId = config?.external?.googleClientId ?? '';
+
+	return (
+		<GoogleOAuthProvider clientId={clientId} nonce="" locale="ko">
+			<ProfileInner />
+		</GoogleOAuthProvider>
+	);
+}
+
+function ProfileInner() {
+	const { member, login, logout } = useAuth();
+	const [open, setOpen] = useState(false);
+	const [data, setData] = useState<DropdownData|null>(null);
+
+	// 구글 로그인 핸들러
+	const handleGoogleLogin = async (credentialResponse: { credential?: string }) => {
+		try {
+			const res = await fetchApi<LoginResponse>('/api/auth/google-login', {
+				method: 'POST',
+				body: {
+					credential: credentialResponse.credential
+				}
+			});
+
+			login(true);
+		} catch {
+			// silent
+		}
+	};
+
+	// Google One Tap — 비로그인 상태에서만 활성화
+	useGoogleOneTapLogin({
+		onSuccess: handleGoogleLogin,
+		onError: () => {},
+		disabled: !!member
+	});
+
+	// 드롭다운 열 때 잔액 조회
+	const loadData = async () => {
+		const res = await fetchApi<DropdownData>('/api/mypage/dropdown');
+		if (res.data) {
+			setData(res.data);
+		}
+	};
+
+	useEffect(() => {
+		if (!open) {
+			return;
+		}
+
+		loadData();
+	}, [open]);
+
+	// 충전 완료 시 팝업에서 postMessage 수신 → 잔액 갱신
+	useEffect(() => {
+		const handleMessage = (e: MessageEvent) => {
+			if (e.data?.type === 'CHARGE_COMPLETE') {
+				loadData();
+			}
+		};
+
+		window.addEventListener('message', handleMessage);
+		return () => window.removeEventListener('message', handleMessage);
+	}, []);
+
+	// ── 비로그인 ──────────────────────────────────
+	if (!member) {
+		return (
+			<DropdownMenu>
+				<DropdownMenuTrigger asChild>
+					<label className="profile-dropdown__trigger--guest">
+						로그인
+					</label>
+				</DropdownMenuTrigger>
+				<DropdownMenuContent className="profile-dropdown__login-content" align="end">
+					<div className="profile-dropdown__login-panel">
+						<GoogleLogin
+							onSuccess={handleGoogleLogin}
+							onError={() => {}}
+							size="large"
+							shape="rectangular"
+							logo_alignment="center"
+						/>
+					</div>
+				</DropdownMenuContent>
+			</DropdownMenu>
+		);
+	}
+
+	// ── 로그인 ────────────────────────────────────
+	const displayName = member.name || member.sid;
+	const initial = (member.name || member.sid || '?').charAt(0).toUpperCase();
+
+	return (
+		<DropdownMenu open={open} onOpenChange={setOpen}>
+			<DropdownMenuTrigger asChild>
+				<button type="button" className="profile-dropdown__trigger" title={displayName}>
+					<Avatar className="h-8 w-8">
+						<AvatarImage src={member.thumb || undefined} alt={displayName} />
+						<AvatarFallback className="profile-dropdown__fallback">{initial}</AvatarFallback>
+					</Avatar>
+				</button>
+			</DropdownMenuTrigger>
+
+			<DropdownMenuContent className="profile-dropdown__content" align="end">
+				{/* 프로필 헤더 */}
+				<DropdownMenuLabel className="profile-dropdown__header">
+					<Avatar>
+						<AvatarImage src={member.thumb || undefined} alt={displayName} />
+						<AvatarFallback className="profile-dropdown__fallback">{initial}</AvatarFallback>
+					</Avatar>
+					<div className="profile-dropdown__user-info">
+						<div className="profile-dropdown__name">{displayName}</div>
+						{data && (
+							<>
+								<div className="profile-dropdown__balance">
+									<span className="profile-dropdown__balance-icon">P</span>
+									<span>{data.spendableBalance.toLocaleString()}</span>
+								</div>
+								{data.isCreator && data.withdrawableBalance !== null && (
+									<div className="profile-dropdown__withdraw">
+										<span className="profile-dropdown__withdraw-icon">M</span>
+										<span>{data.withdrawableBalance.toLocaleString()}</span>
+									</div>
+								)}
+							</>
+						)}
+					</div>
+				</DropdownMenuLabel>
+
+				<DropdownMenuSeparator />
+
+				{member.isCreator && (
+					<DropdownMenuItem asChild>
+						<Link href="/studio">
+							<span className="profile-dropdown__menu-icon">
+								<SignalSteamIcon width={20} height={20} aria-label='채널 관리' />
+							</span>
+							채널 관리
+						</Link>
+					</DropdownMenuItem>
+				)}
+				<DropdownMenuItem asChild>
+					<Link href="/profile">
+						<span className="profile-dropdown__menu-icon">
+							<UserRoundCog width={20} height={20} aria-label='내 정보' />
+						</span>
+						내 정보
+					</Link>
+				</DropdownMenuItem>
+				<DropdownMenuItem asChild>
+					<Link href="/storage">
+						<span className="profile-dropdown__menu-icon">
+							<Package width={20} height={20} aria-label='보관함' />
+						</span>
+						보관함
+					</Link>
+				</DropdownMenuItem>
+
+				<DropdownMenuSeparator />
+
+				<DropdownMenuItem className="profile-dropdown__danger" onSelect={logout}>
+					<span className="profile-dropdown__menu-icon">
+						<LogOut width={20} height={20} aria-label='로그아웃' />
+					</span>
+					로그아웃
+				</DropdownMenuItem>
+			</DropdownMenuContent>
+		</DropdownMenu>
+	);
+}

+ 143 - 0
app/component/channel/ChannelSidebar.tsx

@@ -0,0 +1,143 @@
+'use client';
+
+import './style.scss';
+import { useEffect, useState, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faSun, faMoon, faGlobe, faBookmark } from '@fortawesome/free-solid-svg-icons';
+import { fetchApi } from '@/lib/utils/client';
+import { useSignalRContext } from '@/contexts/signalrProvider';
+import { ChannelListItem, ChannelStatusUpdate } from '@/types/channel';
+import { ChannelListResponse } from '@/types/response/channel/list';
+import useTheme from '@/hooks/useTheme';
+
+export default function ChannelSidebar() {
+	const [channels, setChannels] = useState<ChannelListItem[]>([]);
+	const [loading, setLoading] = useState(true);
+	const [langOpen, setLangOpen] = useState(false);
+	const router = useRouter();
+	const { chatConnection } = useSignalRContext();
+	const { toggleTheme, isDark } = useTheme();
+
+	const sortChannels = useCallback((list: ChannelListItem[]) => {
+		return [...list].sort((a, b) => {
+			if (a.isLive && !b.isLive) return -1;
+			if (!a.isLive && b.isLive) return 1;
+			if (a.isLive && b.isLive) return b.viewerCount - a.viewerCount;
+			return a.name.localeCompare(b.name);
+		});
+	}, []);
+
+	const loadChannels = useCallback(async () => {
+		try {
+			const res = await fetchApi<ChannelListResponse>('/api/channel/list');
+			if (res.data?.channels) {
+				setChannels(sortChannels(res.data.channels));
+			}
+		} catch {}
+		setLoading(false);
+	}, [sortChannels]);
+
+	useEffect(() => {
+		loadChannels();
+	}, [loadChannels]);
+
+	// SignalR: 채널 상태 실시간 업데이트
+	useEffect(() => {
+		if (!chatConnection) return;
+
+		const handler = (status: ChannelStatusUpdate) => {
+			setChannels(prev => {
+				const updated = prev.map(ch =>
+					ch.channelSID === status.channelSID
+						? { ...ch, isLive: status.isLive, viewerCount: status.viewerCount, videoId: status.videoId }
+						: ch
+				);
+				return sortChannels(updated);
+			});
+		};
+
+		chatConnection.on('ReceiveChannelStatus', handler);
+		return () => {
+			chatConnection.off('ReceiveChannelStatus', handler);
+		};
+	}, [chatConnection, sortChannels]);
+
+	const handleClick = (ch: ChannelListItem) => {
+		if (ch.isLive && ch.videoId) {
+			router.push(`/watch/${ch.channelSID}`);
+		} else {
+			router.push(`/channel/${ch.channelSID}`);
+		}
+	};
+
+	const formatCount = (n: number) => {
+		if (n >= 10000) return `${(n / 10000).toFixed(1)}만`;
+		if (n >= 1000) return `${(n / 1000).toFixed(1)}K`;
+		return n.toString();
+	};
+
+	const getThumbnailUrl = (ch: ChannelListItem) => {
+		if (ch.thumbnailUrl) return ch.thumbnailUrl;
+		return `https://yt3.googleusercontent.com/a/${ch.channelSID}=s48-c-k-c0x00ffffff-no-rj`;
+	};
+
+	if (loading) {
+		return (
+			<div className="channel-sidebar">
+				<div className="channel-sidebar__header">채널</div>
+				<div className="channel-sidebar__loading">로딩 중...</div>
+			</div>
+		);
+	}
+
+	return (
+		<div className="channel-sidebar">
+			<div className="channel-sidebar__header">채널</div>
+			<div className="channel-sidebar__list">
+				{channels.length === 0 && (
+					<div className="channel-sidebar__empty">등록된 채널이 없습니다</div>
+				)}
+				{channels.map(ch => (
+					<div key={ch.channelSID} className={`channel-sidebar__item${ch.isLive ? ' channel-sidebar__item--live' : ''}`} onClick={() => handleClick(ch)}>
+						<div className="channel-sidebar__thumb">
+							<img src={getThumbnailUrl(ch)} alt={ch.name} width={36} height={36} />
+						</div>
+						<div className="channel-sidebar__info">
+							<div className="channel-sidebar__name">{ch.name}</div>
+							<div className="channel-sidebar__sub">
+								{ch.subscriberCount > 0 ? `구독자 ${formatCount(ch.subscriberCount)}` : ch.handle || ''}
+							</div>
+						</div>
+						<div className="channel-sidebar__status">
+							{ch.isLive && (
+								<span className="channel-sidebar__viewers">👁 {formatCount(ch.viewerCount)}</span>
+							)}
+							<span className={`channel-sidebar__led${ch.isLive ? ' channel-sidebar__led--on' : ''}`} />
+						</div>
+					</div>
+				))}
+			</div>
+			{/* 하단: 다크모드 / 다국어 / 즐겨찾기 */}
+			<div className="channel-sidebar__footer">
+				{langOpen && (
+					<div className="channel-sidebar__lang-dropdown">
+						<button type="button" className="channel-sidebar__lang-option channel-sidebar__lang-option--active" onClick={() => setLangOpen(false)}>한국어</button>
+						<button type="button" className="channel-sidebar__lang-option" onClick={() => setLangOpen(false)}>English</button>
+					</div>
+				)}
+				<div className="channel-sidebar__footer-actions">
+					<button type="button" className="channel-sidebar__icon-btn" onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
+						<FontAwesomeIcon icon={isDark ? faSun : faMoon} />
+					</button>
+					<button type="button" className="channel-sidebar__icon-btn" onClick={() => setLangOpen(prev => !prev)} aria-label="다국어" title="다국어">
+						<FontAwesomeIcon icon={faGlobe} />
+					</button>
+					<button type="button" className="channel-sidebar__icon-btn" onClick={() => alert('Ctrl+D (Mac: ⌘+D)를 누르면 사이트를 즐겨찾기에 추가할 수 있습니다.')} aria-label="즐겨찾기" title="즐겨찾기">
+						<FontAwesomeIcon icon={faBookmark} />
+					</button>
+				</div>
+			</div>
+		</div>
+	);
+}

+ 200 - 0
app/component/channel/style.scss

@@ -0,0 +1,200 @@
+.channel-sidebar {
+	display: flex;
+	flex-direction: column;
+	height: 100%;
+	overflow: hidden;
+
+	&__header {
+		padding: 12px 16px;
+		font-size: 0.8125rem;
+		font-weight: 600;
+		color: var(--text-secondary);
+		text-transform: uppercase;
+		letter-spacing: 0.05em;
+		border-bottom: 1px solid var(--border-default);
+		flex-shrink: 0;
+	}
+
+	&__list {
+		overflow-y: auto;
+		flex: 1;
+
+		&::-webkit-scrollbar {
+			width: 4px;
+		}
+		&::-webkit-scrollbar-thumb {
+			background: var(--border-default);
+			border-radius: 2px;
+		}
+	}
+
+	&__loading, &__empty {
+		padding: 24px 16px;
+		text-align: center;
+		font-size: 0.75rem;
+		color: var(--text-muted);
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: 10px;
+		padding: 8px 12px;
+		cursor: pointer;
+		transition: background 0.15s;
+
+		&:hover {
+			background: var(--bg-subtle);
+		}
+
+		&--live {
+			background: rgba(0, 255, 0, 0.03);
+
+			&:hover {
+				background: rgba(0, 255, 0, 0.06);
+			}
+		}
+	}
+
+	&__thumb {
+		flex-shrink: 0;
+		width: 36px;
+		height: 36px;
+		border-radius: 50%;
+		overflow: hidden;
+		background: var(--bg-subtle);
+
+		img {
+			width: 100%;
+			height: 100%;
+			object-fit: cover;
+		}
+	}
+
+	&__info {
+		flex: 1;
+		min-width: 0;
+		overflow: hidden;
+	}
+
+	&__name {
+		font-size: 0.8125rem;
+		font-weight: 500;
+		color: var(--text-primary);
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	&__sub {
+		font-size: 0.6875rem;
+		color: var(--text-muted);
+		white-space: nowrap;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+
+	&__status {
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		flex-shrink: 0;
+	}
+
+	&__viewers {
+		font-size: 0.6875rem;
+		color: var(--text-secondary);
+		white-space: nowrap;
+	}
+
+	&__led {
+		display: inline-block;
+		width: 8px;
+		height: 8px;
+		border-radius: 50%;
+		background: #dc2626;
+		flex-shrink: 0;
+
+		&--on {
+			background: #22c55e;
+			box-shadow: 0 0 4px rgba(34, 197, 94, 0.5);
+		}
+	}
+
+	// ── 하단: 다크모드 / 다국어 / 즐겨찾기 ──────────
+	&__footer {
+		margin-top: auto;
+		border-top: 1px solid var(--border-default);
+		position: relative;
+	}
+
+	&__footer-actions {
+		display: flex;
+		align-items: stretch;
+	}
+
+	&__icon-btn {
+		flex: 1;
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		height: 25px;
+		border: none;
+		background: none;
+		color: var(--text-muted);
+		cursor: pointer;
+		transition: background 0.15s, color 0.15s;
+		position: relative;
+
+		&:hover {
+			background: var(--bg-subtle);
+			color: var(--fg-default);
+		}
+
+		// 구분선
+		& + & {
+			border-left: 1px solid var(--border-default);
+		}
+
+		svg {
+			width: 13px;
+			height: 13px;
+		}
+	}
+
+	&__lang-dropdown {
+		position: absolute;
+		bottom: 100%;
+		left: 0;
+		right: 0;
+		background: var(--bg-default);
+		border: 1px solid var(--border-default);
+		border-bottom: none;
+		border-radius: 8px 8px 0 0;
+		box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
+		overflow: hidden;
+		z-index: 10;
+	}
+
+	&__lang-option {
+		display: block;
+		width: 100%;
+		padding: 10px 14px;
+		border: none;
+		background: none;
+		color: var(--text-secondary);
+		font-size: 0.8125rem;
+		text-align: left;
+		cursor: pointer;
+		transition: background 0.15s;
+
+		&:hover {
+			background: var(--bg-subtle);
+		}
+
+		&--active {
+			color: var(--fg-default);
+			font-weight: 600;
+		}
+	}
+}

+ 52 - 5
app/globals.scss

@@ -50,15 +50,19 @@ body {
 		--crypto-neutral: 0 0% 50%;
 
 		/* 시맨틱 색상 변수 */
-		--text-primary: #333;
+        --text-normal: #f1f1f1;
+		--text-primary: #141414;
 		--text-secondary: #666;
 		--text-muted: #999;
 		--text-link: #0060a9;
 		--text-link-hover: #c7511f;
+        --text-head: #e5e5e5;
 		--bg-page: #fff;
 		--bg-elevated: #f9f9f9;
 		--bg-subtle: #f4f4f4;
 		--bg-input: #fff;
+        --bg-head: #007bc3;
+        --bg-icon: #004f7c;
 		--border-default: #eaeaea;
 		--border-light: #eee;
 		--border-strong: #ccc;
@@ -75,7 +79,26 @@ body {
 		--btn-default-border: #b3b3b3;
 		--btn-default-hover: #e3e3e3;
 		--btn-submit-shadow: #d38817;
+        --outline-default: #c2c2c2;
+		--fg-default: #0f0f0f;
+		--color-blue: #3b82f6;
+		--color-blue-hover: #2563eb;
+		--color-blue-bg: rgba(59, 130, 246, 0.06);
+		--color-danger-bg: #fee2e2;
+		--color-danger-border: #fca5a5;
+		--color-success-bg: #dcfce7;
+		--color-success-text: #16a34a;
+		--bg-subtle-hover: rgba(0, 0, 0, 0.05);
+		--sidebar-background: 0 0% 98%;
+		--sidebar-foreground: 240 5.3% 26.1%;
+		--sidebar-primary: 240 5.9% 10%;
+		--sidebar-primary-foreground: 0 0% 98%;
+		--sidebar-accent: 240 4.8% 95.9%;
+		--sidebar-accent-foreground: 240 5.9% 10%;
+		--sidebar-border: 220 13% 91%;
+		--sidebar-ring: 217.2 91.2% 59.8%;
 	}
+
   	.dark {
         --background: 0 0% 3.9%;
         --foreground: 0 0% 98%;
@@ -106,15 +129,19 @@ body {
 		--crypto-neutral: 0 0% 63.9%;
 
 		/* 시맨틱 색상 변수 */
-		--text-primary: #e5e5e5;
+        --text-normal: #f1f1f1;
+		--text-primary: #cacaca;
 		--text-secondary: #a3a3a3;
 		--text-muted: #737373;
 		--text-link: #60a5fa;
 		--text-link-hover: #f59e0b;
+        --text-head: #cacaca;
 		--bg-page: #171717;
 		--bg-elevated: #1e1e1e;
 		--bg-subtle: #262626;
 		--bg-input: #262626;
+        --bg-head: #5603a3;
+        --bg-icon: #3c00aa;
 		--border-default: #333;
 		--border-light: #2a2a2a;
 		--border-strong: #444;
@@ -131,6 +158,23 @@ body {
 		--btn-default-border: #555;
 		--btn-default-hover: #333;
 		--btn-submit-shadow: #b8700f;
+		--fg-default: #f1f1f1;
+		--color-blue: #60a5fa;
+		--color-blue-hover: #3b82f6;
+		--color-blue-bg: rgba(96, 165, 250, 0.1);
+		--color-danger-bg: #3f1212;
+		--color-danger-border: #7f1d1d;
+		--color-success-bg: #0f2d1a;
+		--color-success-text: #4ade80;
+		--bg-subtle-hover: rgba(255, 255, 255, 0.05);
+		--sidebar-background: 240 5.9% 10%;
+		--sidebar-foreground: 240 4.8% 95.9%;
+		--sidebar-primary: 224.3 76.3% 48%;
+		--sidebar-primary-foreground: 0 0% 100%;
+		--sidebar-accent: 240 3.7% 15.9%;
+		--sidebar-accent-foreground: 240 4.8% 95.9%;
+		--sidebar-border: 240 3.7% 15.9%;
+		--sidebar-ring: 217.2 91.2% 59.8%;
     }
 }
 
@@ -192,13 +236,16 @@ select, input, textarea {
     color: var(--text-primary);
     background: var(--bg-input);
     border: 1px solid var(--border-strong);
-    border-radius: 3px;
-    transition: border-color 0.3s ease;
 	line-height: inherit;
+    transition: border-color 0.3s ease;
 
-    &:focus {
+    &:focus,
+    &:hover {
         outline: none;
         border-color: var(--text-link);
+    }
+
+    &:focus {
         -webkit-box-shadow: inset 1px 2px 2px rgba(0, 0, 0, 0.05), 0 0 4px rgba(44, 112, 170, 0.8);
         box-shadow: inset 1px 2px 2px rgba(0, 0, 0, 0.05), 0 0 4px rgba(44, 112, 170, 0.8);
     }

+ 212 - 0
app/remote/[channelSID]/page.tsx

@@ -0,0 +1,212 @@
+'use client';
+
+import { use, useEffect, useState, useRef } from 'react';
+import * as signalR from '@microsoft/signalr';
+import { DonationAlertData, DonationRemoteState } from '@/types/donation';
+import { fetchApi } from '@/lib/utils/client';
+import './style.scss';
+
+type Props = {
+	params: Promise<{ channelSID: string }>;
+};
+
+type AlertQueueItem = {
+	alertID: number;
+	donationID: number;
+	status: string;
+	sponsorMemberID: number;
+	sendName: string;
+	amount: number;
+	message: string|null;
+	channelName?: string;
+	createdAt: string;
+};
+
+const STATUS_LABEL: Record<string, string> = {
+	playing: '재생 중',
+	queued: '대기',
+	failed: '실패',
+	delivered: '완료',
+	skipped: '건너뜀',
+	ignored: '무시'
+};
+
+export default function RemotePage({ params }: Props) {
+	const { channelSID } = use(params);
+	const apiBase = '/api/donation/remote';
+	const hubUrl = process.env.NEXT_PUBLIC_API_URL + '/hubs/donation';
+
+	const [connected, setConnected] = useState(false);
+	const [state, setState] = useState<DonationRemoteState>({ isPaused: false, isAccepting: true, isAudioOnly: false, isVideoOnly: false });
+	const [queue, setQueue] = useState<AlertQueueItem[]>([]);
+	const [openMenuID, setOpenMenuID] = useState<number|null>(null);
+	const connectionRef = useRef<signalR.HubConnection|null>(null);
+
+	// 초기 데이터 로드 + SignalR 연결
+	useEffect(() => {
+		loadState();
+		connectHub();
+		return () => { connectionRef.current?.stop(); };
+	}, []);
+
+	const loadState = async () => {
+		try {
+			const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${channelSID}`, { silent: true });
+			if (res.data) {
+				setState({ isPaused: res.data.isPaused, isAccepting: res.data.isAccepting, isAudioOnly: res.data.isAudioOnly, isVideoOnly: res.data.isVideoOnly });
+				setQueue(res.data.queue || []);
+			}
+		} catch {}
+	};
+
+	const connectHub = () => {
+		const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
+
+		conn.on('ReceiveAlert', (data: DonationAlertData) => {
+			setQueue(prev => [...prev, { alertID: data.alertID, donationID: data.donationID, status: 'queued', sponsorMemberID: data.sponsorMemberID, sendName: data.sendName, amount: data.amount, message: data.message, createdAt: data.createdAt }]);
+		});
+
+		conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
+		conn.on('ReceiveSkip', () => {
+			setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
+		});
+
+		conn.start().then(() => {
+			conn.invoke('JoinChannel', channelSID);
+			setConnected(true);
+		}).catch(() => {});
+
+		conn.onclose(() => setConnected(false));
+		conn.onreconnected(() => { conn.invoke('JoinChannel', channelSID); setConnected(true); });
+		connectionRef.current = conn;
+	};
+
+	// 리모콘 액션
+	const togglePause = async () => {
+		const next = { ...state, isPaused: !state.isPaused };
+		setState(next);
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+	};
+
+	const toggleAccepting = async () => {
+		const next = { ...state, isAccepting: !state.isAccepting };
+		setState(next);
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+	};
+
+	const toggleAudioOnly = async () => {
+		const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
+		setState(next);
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+	};
+
+	const toggleVideoOnly = async () => {
+		const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
+		setState(next);
+		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, channelID: 0, memberID: 0 }, silent: true });
+	};
+
+	const skipCurrent = async () => {
+		await fetchApi(`${apiBase}/skip/${channelSID}`, { method: 'POST', silent: true });
+	};
+
+	const ignoreAlert = async (alertID: number) => {
+		await fetchApi(`${apiBase}/ignore/${alertID}`, { method: 'POST', silent: true });
+		setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'ignored' } : q));
+		setOpenMenuID(null);
+	};
+
+	const resendAlert = async (alertID: number) => {
+		await fetchApi(`${apiBase}/resend/${alertID}`, { method: 'POST', silent: true });
+		setQueue(prev => prev.map(q => q.alertID === alertID ? { ...q, status: 'queued' } : q));
+		setOpenMenuID(null);
+	};
+
+	const formatTime = (dateStr: string) => {
+		const d = new Date(dateStr);
+		return `${d.getHours().toString().padStart(2, '0')}:${d.getMinutes().toString().padStart(2, '0')}`;
+	};
+
+	return (
+		<div className="remote-container">
+			<div className="remote-header">
+				<h1>리모콘</h1>
+				<span className={`connection-badge ${connected ? 'connected' : 'disconnected'}`}>
+					{connected ? '연결됨' : '연결 끊김'}
+				</span>
+			</div>
+
+			{/* 컨트롤 패널 */}
+			<div className="control-panel">
+				<button type="button" className={`control-btn ${state.isPaused ? 'active' : ''}`} onClick={togglePause}>
+					{state.isPaused ? '▶ 재개' : '⏸ 일시정지'}
+				</button>
+				<button type="button" className={`control-btn ${!state.isAccepting ? 'danger' : ''}`} onClick={toggleAccepting}>
+					{state.isAccepting ? '🔔 후원 받는 중' : '🔕 후원 안 받음'}
+				</button>
+				<button type="button" className={`control-btn ${state.isAudioOnly ? 'active' : ''}`} onClick={toggleAudioOnly}>
+					🔊 음성만
+				</button>
+				<button type="button" className={`control-btn ${state.isVideoOnly ? 'active' : ''}`} onClick={toggleVideoOnly}>
+					🖼 영상만
+				</button>
+				<button type="button" className="control-btn full-width" onClick={skipCurrent}>
+					⏭ 건너뛰기
+				</button>
+			</div>
+
+			{/* 후원 목록 */}
+			<div className="alert-list-header">
+				<h2>후원 목록</h2>
+				<span className="alert-count">{queue.length}건</span>
+			</div>
+
+			<div className="alert-list">
+				{queue.length === 0 && <div className="empty-list">후원 알림이 없습니다</div>}
+				{queue.map(item => (
+					<div key={item.alertID} className={`alert-item ${item.status}`}>
+						{/* 방향 아이콘 */}
+						<div className="alert-direction">
+							<span>{item.sendName}</span>
+							<span className="alert-arrow">→</span>
+						</div>
+
+						{/* 본문 */}
+						<div className="alert-body">
+							<div className="alert-body-top">
+								<span className="alert-sender-name">{item.sendName}</span>
+								<span className="alert-amount">{item.amount.toLocaleString()}원</span>
+							</div>
+							{item.message && <div className="alert-msg">{item.message}</div>}
+							<div className="alert-time">{formatTime(item.createdAt)}</div>
+						</div>
+
+						{/* 상태 뱃지 */}
+						<span className={`alert-status-badge status-${item.status}`}>
+							{STATUS_LABEL[item.status] || item.status}
+						</span>
+
+						{/* 햄버거 메뉴 (재생 중이 아닐 때만) */}
+						{item.status !== 'playing' && (
+							<div className="relative">
+								<button type="button" className="alert-menu-btn" onClick={() => setOpenMenuID(openMenuID === item.alertID ? null : item.alertID)}>
+									⋮
+								</button>
+								{openMenuID === item.alertID && (
+									<div className="alert-menu-dropdown">
+										{(item.status === 'failed' || item.status === 'skipped') && (
+											<div className="alert-menu-item" onClick={() => resendAlert(item.alertID)}>재전송</div>
+										)}
+										{item.status === 'queued' && (
+											<div className="alert-menu-item danger" onClick={() => ignoreAlert(item.alertID)}>무시</div>
+										)}
+									</div>
+								)}
+							</div>
+						)}
+					</div>
+				))}
+			</div>
+		</div>
+	);
+}

+ 236 - 0
app/remote/[channelSID]/style.scss

@@ -0,0 +1,236 @@
+.remote-container {
+	max-width: 480px;
+	margin: 0 auto;
+	padding: 16px;
+	font-family: 'Pretendard', sans-serif;
+	color: #E0E0E0;
+	background: #1A1A2E;
+	min-height: 100vh;
+}
+
+.remote-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	padding-bottom: 12px;
+	border-bottom: 1px solid #333;
+	margin-bottom: 16px;
+
+	h1 {
+		font-size: 18px;
+		font-weight: 700;
+		color: #FFF;
+		margin: 0;
+	}
+}
+
+.connection-badge {
+	font-size: 11px;
+	padding: 3px 8px;
+	border-radius: 12px;
+	font-weight: 600;
+
+	&.connected { background: #1B5E20; color: #A5D6A7; }
+	&.disconnected { background: #B71C1C; color: #EF9A9A; }
+}
+
+/* 컨트롤 패널 */
+.control-panel {
+	display: grid;
+	grid-template-columns: 1fr 1fr;
+	gap: 8px;
+	margin-bottom: 16px;
+}
+
+.control-btn {
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	gap: 6px;
+	padding: 12px;
+	border: 1px solid #444;
+	border-radius: 10px;
+	background: #2A2A3E;
+	color: #CCC;
+	font-size: 13px;
+	font-weight: 600;
+	cursor: pointer;
+	transition: all 0.2s;
+
+	&:hover { background: #3A3A4E; }
+
+	&.active { background: #FF6B35; color: #FFF; border-color: #FF6B35; }
+	&.danger { background: #C62828; color: #FFF; border-color: #C62828; }
+	&.full-width { grid-column: 1 / -1; }
+}
+
+/* 후원 목록 */
+.alert-list-header {
+	display: flex;
+	align-items: center;
+	justify-content: space-between;
+	margin-bottom: 8px;
+
+	h2 {
+		font-size: 15px;
+		font-weight: 700;
+		color: #FFF;
+		margin: 0;
+	}
+}
+
+.alert-count {
+	font-size: 12px;
+	color: #888;
+}
+
+.alert-list {
+	display: flex;
+	flex-direction: column;
+	gap: 6px;
+}
+
+.alert-item {
+	display: flex;
+	align-items: center;
+	gap: 10px;
+	padding: 10px 12px;
+	border-radius: 10px;
+	background: #2A2A3E;
+	position: relative;
+
+	&.playing {
+		background: #1A3A1A;
+		border-left: 3px solid #4CAF50;
+		animation: playingPulse 2s infinite;
+	}
+
+	&.failed {
+		background: #3A1A1A;
+		border-left: 3px solid #F44336;
+	}
+
+	&.skipped, &.ignored {
+		opacity: 0.5;
+	}
+}
+
+.alert-direction {
+	display: flex;
+	align-items: center;
+	gap: 4px;
+	font-size: 12px;
+	color: #888;
+	flex-shrink: 0;
+}
+
+.alert-arrow {
+	color: #FF6B35;
+	font-size: 14px;
+}
+
+.alert-body {
+	flex: 1;
+	overflow: hidden;
+}
+
+.alert-body-top {
+	display: flex;
+	align-items: center;
+	gap: 6px;
+}
+
+.alert-sender-name {
+	font-weight: 600;
+	font-size: 13px;
+	color: #FFD700;
+}
+
+.alert-amount {
+	font-weight: 700;
+	font-size: 13px;
+	color: #FF6B35;
+}
+
+.alert-msg {
+	font-size: 12px;
+	color: #AAA;
+	overflow: hidden;
+	text-overflow: ellipsis;
+	white-space: nowrap;
+	margin-top: 2px;
+}
+
+.alert-time {
+	font-size: 10px;
+	color: #666;
+	margin-top: 2px;
+}
+
+.alert-status-badge {
+	font-size: 10px;
+	padding: 2px 6px;
+	border-radius: 4px;
+	font-weight: 600;
+	flex-shrink: 0;
+
+	&.status-playing { background: #1B5E20; color: #A5D6A7; }
+	&.status-queued { background: #333; color: #999; }
+	&.status-failed { background: #B71C1C; color: #EF9A9A; }
+	&.status-delivered { background: #1A237E; color: #9FA8DA; }
+	&.status-skipped { background: #4A4A00; color: #999; }
+	&.status-ignored { background: #333; color: #666; }
+}
+
+/* 햄버거 메뉴 */
+.alert-menu-btn {
+	width: 28px;
+	height: 28px;
+	display: flex;
+	align-items: center;
+	justify-content: center;
+	border: none;
+	background: transparent;
+	color: #888;
+	cursor: pointer;
+	border-radius: 4px;
+	font-size: 16px;
+	flex-shrink: 0;
+
+	&:hover { background: #444; color: #FFF; }
+}
+
+.alert-menu-dropdown {
+	position: absolute;
+	right: 12px;
+	top: 40px;
+	background: #333;
+	border-radius: 8px;
+	padding: 4px 0;
+	z-index: 10;
+	box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
+	min-width: 120px;
+}
+
+.alert-menu-item {
+	padding: 8px 16px;
+	font-size: 13px;
+	color: #DDD;
+	cursor: pointer;
+	white-space: nowrap;
+
+	&:hover { background: #444; }
+	&.danger { color: #EF9A9A; }
+}
+
+.empty-list {
+	text-align: center;
+	color: #666;
+	padding: 40px 0;
+	font-size: 14px;
+}
+
+@keyframes playingPulse {
+	0%, 100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.3); }
+	50% { box-shadow: 0 0 0 4px rgba(76, 175, 80, 0); }
+}

+ 7 - 0
app/remote/layout.tsx

@@ -0,0 +1,7 @@
+export default function RemoteLayout({ children }: { children: React.ReactNode }) {
+	return (
+		<div style={{ margin: 0, padding: 0, background: '#1A1A2E', minHeight: '100vh' }}>
+			{children}
+		</div>
+	);
+}

+ 360 - 0
app/studio/Sidebar.tsx

@@ -0,0 +1,360 @@
+'use client';
+
+import { useState, useEffect, useCallback } from 'react';
+import Link from 'next/link';
+import Image from 'next/image';
+import { usePathname } from 'next/navigation';
+import { ChevronsUpDown } from 'lucide-react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import {
+	faGauge,
+	faHeart,
+	faChevronDown,
+	faBell,
+	faBullseye,
+	faTrophy,
+	faUsers,
+	faWallet,
+	faCoins,
+	faChartLine,
+	faArrowRightFromBracket,
+	faCalculator,
+	faBuildingColumns,
+	faFileLines,
+	faArrowLeft,
+	faCircleExclamation,
+} from '@fortawesome/free-solid-svg-icons';
+import {
+	Sidebar as SidebarRoot,
+	SidebarContent,
+	SidebarHeader,
+	SidebarMenu,
+	SidebarMenuButton,
+	SidebarMenuItem,
+	SidebarMenuSub,
+	SidebarMenuSubButton,
+	SidebarMenuSubItem,
+	SidebarGroup,
+	SidebarGroupContent,
+	useSidebar,
+} from '@/components/ui/sidebar';
+import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
+import {
+	DropdownMenu,
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuLabel,
+	DropdownMenuSeparator,
+	DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { fetchApi } from '@/lib/utils/client';
+import type { StudioSettingsResponse } from '@/types/response/studio/settings';
+import { User } from 'lucide-react';
+
+export default function Sidebar()
+{
+	const pathname = usePathname();
+	const { isMobile } = useSidebar();
+	const [channel, setChannel] = useState<Pick<StudioSettingsResponse, 'isYouTubeConnected'|'channelName'|'handle'|'thumbnailUrl'>|null>(null);
+
+	const isDonationPath = pathname.startsWith('/studio/donation');
+	const isWalletPath = pathname.startsWith('/studio/wallet');
+	const isSettlementPath = pathname.startsWith('/studio/settlement');
+
+	const readCookie = (key: string, fallback: boolean) => {
+		if (typeof document === 'undefined') return fallback;
+		const match = document.cookie.match(new RegExp(`(?:^|; )${key}=([^;]*)`));
+		if (!match) return fallback;
+		return match[1] !== 'false';
+	};
+
+	const [donationOpen, setDonationOpen] = useState(() => readCookie('studio_menu_donation', true) || isDonationPath);
+	const [walletOpen, setWalletOpen] = useState(() => readCookie('studio_menu_wallet', true) || isWalletPath);
+	const [settlementOpen, setSettlementOpen] = useState(() => readCookie('studio_menu_settlement', true) || isSettlementPath);
+
+	const saveCookie = useCallback((key: string, value: boolean) => {
+		document.cookie = `${key}=${value}; path=/studio; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
+	}, []);
+
+	const handleDonationToggle = useCallback((open: boolean) => {
+		setDonationOpen(open);
+		saveCookie('studio_menu_donation', open);
+	}, [saveCookie]);
+
+	const handleWalletToggle = useCallback((open: boolean) => {
+		setWalletOpen(open);
+		saveCookie('studio_menu_wallet', open);
+	}, [saveCookie]);
+
+	const handleSettlementToggle = useCallback((open: boolean) => {
+		setSettlementOpen(open);
+		saveCookie('studio_menu_settlement', open);
+	}, [saveCookie]);
+
+	const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
+
+	useEffect(() => {
+		fetchApi<StudioSettingsResponse>('/api/studio/settings')
+			.then(res => {
+				if (res.data) {
+					setChannel(res.data);
+				}
+			})
+			.catch(() => {});
+	}, []);
+
+	const isConnected = channel?.isYouTubeConnected === true;
+
+	return (
+		<SidebarRoot collapsible="icon">
+			<SidebarHeader>
+				{/* 상단 Studio 라벨 + 돌아가기 버튼 */}
+				<div className="flex items-center gap-1.5 px-2 py-1 group-data-[collapsible=icon]:hidden">
+					<Link
+						href="/"
+						className="flex size-6 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors"
+						title="뒤로가기"
+					>
+						<FontAwesomeIcon icon={faArrowLeft} className="text-xs" />
+					</Link>
+					<span className="text-xs font-semibold text-sidebar-foreground/50">Studio</span>
+				</div>
+
+				{/* YouTube 채널 카드 */}
+				<SidebarMenu>
+					<SidebarMenuItem>
+						<DropdownMenu>
+							<DropdownMenuTrigger asChild>
+								<SidebarMenuButton
+									size="lg"
+									className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
+									tooltip={channel?.channelName ?? 'YouTube 채널'}
+								>
+									{/* 아이콘 (collapse 시 이것만 표시) */}
+									<div className={`relative flex size-8 shrink-0 items-center justify-center rounded-lg ${!channel?.thumbnailUrl ? 'bg-sidebar-primary' : 'border'} text-sidebar-primary-foreground overflow-hidden`}>
+										{channel?.thumbnailUrl ? (
+											<Image
+												src={channel.thumbnailUrl}
+												alt={channel.channelName ?? 'YouTube'}
+												width={32}
+												height={32}
+												className="size-full object-cover"
+											/>
+										) : (
+											<span className="text-xs font-bold">
+												<User />
+											</span>
+										)}
+										{/* 미연동 경고 뱃지 */}
+										{channel !== null && !isConnected && (
+											<span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-background">
+												<FontAwesomeIcon icon={faCircleExclamation} className="text-[10px] text-amber-500" />
+											</span>
+										)}
+									</div>
+
+									{/* 텍스트 (collapse 시 hidden) */}
+									<div className="grid flex-1 text-left text-sm leading-tight">
+										<span className="truncate font-semibold">
+											{isConnected
+												? (channel?.channelName ?? '채널 이름')
+												: '채널 미연동'}
+										</span>
+										<span className="truncate text-xs text-sidebar-foreground/60">
+											{isConnected
+												? (channel?.handle ?? '')
+												: '채널을 연결해 주세요'}
+										</span>
+									</div>
+
+									<ChevronsUpDown className="ml-auto size-4 shrink-0" />
+								</SidebarMenuButton>
+							</DropdownMenuTrigger>
+
+							<DropdownMenuContent
+								className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
+								align="start"
+								side={isMobile ? 'bottom' : 'right'}
+								sideOffset={4}
+							>
+								{isConnected ? (
+									<>
+										<DropdownMenuLabel className="text-xs text-muted-foreground">
+											{channel?.channelName}
+										</DropdownMenuLabel>
+										<DropdownMenuSeparator />
+										<DropdownMenuItem asChild>
+											<Link href="/studio/settings">채널 정보 보기</Link>
+										</DropdownMenuItem>
+									</>
+								) : (
+									<>
+										<DropdownMenuLabel className="text-xs text-muted-foreground">
+											YouTube 채널이 연동되지 않았습니다
+										</DropdownMenuLabel>
+										<DropdownMenuSeparator />
+										<DropdownMenuItem asChild>
+											<Link href="/studio/settings">YouTube 연동하기</Link>
+										</DropdownMenuItem>
+									</>
+								)}
+							</DropdownMenuContent>
+						</DropdownMenu>
+					</SidebarMenuItem>
+				</SidebarMenu>
+			</SidebarHeader>
+
+			<SidebarContent>
+				<SidebarGroup>
+					<SidebarGroupContent>
+						<SidebarMenu>
+
+							{/* 대시보드 */}
+							<SidebarMenuItem>
+								<SidebarMenuButton asChild isActive={isActive('/studio/dashboard')} tooltip="대시보드">
+									<Link href="/studio/dashboard">
+										<FontAwesomeIcon icon={faGauge} />
+										<span>대시보드</span>
+									</Link>
+								</SidebarMenuButton>
+							</SidebarMenuItem>
+
+							{/* 후원 설정 */}
+							<Collapsible open={donationOpen} onOpenChange={handleDonationToggle}>
+								<SidebarMenuItem>
+									<CollapsibleTrigger asChild>
+										<SidebarMenuButton isActive={isDonationPath} tooltip="후원 설정">
+											<FontAwesomeIcon icon={faHeart} />
+											<span>후원 설정</span>
+											<FontAwesomeIcon
+												icon={faChevronDown}
+												className={`ml-auto text-xs transition-transform duration-200${donationOpen ? ' rotate-180' : ''}`}
+											/>
+										</SidebarMenuButton>
+									</CollapsibleTrigger>
+									<CollapsibleContent>
+										<SidebarMenuSub>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/alert')}>
+													<Link href="/studio/donation/alert">
+														<FontAwesomeIcon icon={faBell} />
+														<span>알림</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/goal')}>
+													<Link href="/studio/donation/goal">
+														<FontAwesomeIcon icon={faBullseye} />
+														<span>목표</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/rank')}>
+													<Link href="/studio/donation/rank">
+														<FontAwesomeIcon icon={faTrophy} />
+														<span>순위</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/crew')}>
+													<Link href="/studio/donation/crew">
+														<FontAwesomeIcon icon={faUsers} />
+														<span>크루 후원</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+										</SidebarMenuSub>
+									</CollapsibleContent>
+								</SidebarMenuItem>
+							</Collapsible>
+
+							{/* 지갑 */}
+							<Collapsible open={walletOpen} onOpenChange={handleWalletToggle}>
+								<SidebarMenuItem>
+									<CollapsibleTrigger asChild>
+										<SidebarMenuButton isActive={isWalletPath} tooltip="지갑">
+											<FontAwesomeIcon icon={faWallet} />
+											<span>지갑</span>
+											<FontAwesomeIcon
+												icon={faChevronDown}
+												className={`ml-auto text-xs transition-transform duration-200${walletOpen ? ' rotate-180' : ''}`}
+											/>
+										</SidebarMenuButton>
+									</CollapsibleTrigger>
+									<CollapsibleContent>
+										<SidebarMenuSub>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/balance')}>
+													<Link href="/studio/wallet/balance">
+														<FontAwesomeIcon icon={faCoins} />
+														<span>잔액</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/revenue')}>
+													<Link href="/studio/wallet/revenue">
+														<FontAwesomeIcon icon={faChartLine} />
+														<span>수익</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/withdraw')}>
+													<Link href="/studio/wallet/withdraw">
+														<FontAwesomeIcon icon={faArrowRightFromBracket} />
+														<span>출금</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+										</SidebarMenuSub>
+									</CollapsibleContent>
+								</SidebarMenuItem>
+							</Collapsible>
+
+							{/* 정산 */}
+							<Collapsible open={settlementOpen} onOpenChange={handleSettlementToggle}>
+								<SidebarMenuItem>
+									<CollapsibleTrigger asChild>
+										<SidebarMenuButton isActive={isSettlementPath} tooltip="정산">
+											<FontAwesomeIcon icon={faCalculator} />
+											<span>정산</span>
+											<FontAwesomeIcon
+												icon={faChevronDown}
+												className={`ml-auto text-xs transition-transform duration-200${settlementOpen ? ' rotate-180' : ''}`}
+											/>
+										</SidebarMenuButton>
+									</CollapsibleTrigger>
+									<CollapsibleContent>
+										<SidebarMenuSub>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/account')}>
+													<Link href="/studio/settlement/account">
+														<FontAwesomeIcon icon={faBuildingColumns} />
+														<span>계좌 관리</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+											<SidebarMenuSubItem>
+												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/tax')}>
+													<Link href="/studio/settlement/tax">
+														<FontAwesomeIcon icon={faFileLines} />
+														<span>세금계산서</span>
+													</Link>
+												</SidebarMenuSubButton>
+											</SidebarMenuSubItem>
+										</SidebarMenuSub>
+									</CollapsibleContent>
+								</SidebarMenuItem>
+							</Collapsible>
+
+						</SidebarMenu>
+					</SidebarGroupContent>
+				</SidebarGroup>
+			</SidebarContent>
+		</SidebarRoot>
+	);
+}

+ 33 - 0
app/studio/context.tsx

@@ -0,0 +1,33 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+
+type StudioContextValue = {
+	channelID: number|null;
+	memberID: number;
+};
+
+const StudioContext = createContext<StudioContextValue|null>(null);
+
+export function useStudioContext(): StudioContextValue {
+	const ctx = useContext(StudioContext);
+	if (!ctx) {
+		throw new Error('useStudioContext must be used within StudioProvider');
+	}
+
+	return ctx;
+}
+
+type Props = {
+	channelID: number|null;
+	memberID: number;
+	children: React.ReactNode;
+};
+
+export function StudioProvider({ channelID, memberID, children }: Props) {
+	return (
+		<StudioContext.Provider value={{ channelID, memberID }}>
+			{children}
+		</StudioContext.Provider>
+	);
+}

+ 14 - 0
app/studio/dashboard/page.tsx

@@ -0,0 +1,14 @@
+'use client';
+
+import './style.scss';
+
+export default function DashboardPage() {
+	return (
+		<div className="studio-page">
+			<div className="dashboard">
+				<h1 className="studio-page__title">대시보드</h1>
+				<p className="dashboard__placeholder">채널 통계 및 요약 정보가 표시될 예정입니다.</p>
+			</div>
+		</div>
+	);
+}

+ 8 - 0
app/studio/dashboard/style.scss

@@ -0,0 +1,8 @@
+.dashboard {
+	&__placeholder {
+		font-size: 0.875rem;
+		color: hsl(var(--muted-foreground));
+		line-height: 1.6;
+		margin-top: 8px;
+	}
+}

+ 389 - 0
app/studio/donation/alert/_components/AlertFormPanel.tsx

@@ -0,0 +1,389 @@
+'use client';
+
+import { useRef } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
+import { Checkbox } from '@/components/ui/checkbox';
+import { POPUP_EFFECTS, TEXT_EFFECTS, FONT_OPTIONS, MATCH_TYPES } from '../constants';
+import type { FormState, PendingFiles } from '../types';
+import {
+	ALERT_TITLE_MAX_LENGTH, ALERT_MESSAGE_MAX_LENGTH, ALERT_AMOUNT_MIN,
+	ALERT_DELAY_MIN, ALERT_DELAY_MAX, ALERT_DELAY_STEP,
+	ALERT_DURATION_MIN, ALERT_DURATION_MAX, ALERT_DURATION_STEP,
+	FONT_SIZE_MIN, FONT_SIZE_MAX, COLOR_HEX_MAX_LENGTH
+} from '@/constants/donation';
+
+type Props = {
+	form: FormState;
+	editingItem: AlertConfigItem|null;
+	saving: boolean;
+	pendingFiles: PendingFiles;
+	onFileSelect: (file: File, type: 'image'|'sound') => void;
+	onFormChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
+	onSave: () => void;
+	onCancel: () => void;
+};
+
+export default function AlertFormPanel({
+	form,
+	editingItem,
+	saving,
+	pendingFiles,
+	onFileSelect,
+	onFormChange,
+	onSave,
+	onCancel
+}: Props) {
+	const imageInputRef = useRef<HTMLInputElement>(null);
+	const soundInputRef = useRef<HTMLInputElement>(null);
+
+	// ── 폰트 그룹 렌더러 ─────────────────────────────
+	const renderFontGroup = (
+		label: string,
+		prefix: string,
+		familyField: 'nicknameFontFamily'|'amountFontFamily'|'messageFontFamily'|'templateFontFamily',
+		sizeField: 'nicknameFontSize'|'amountFontSize'|'messageFontSize'|'templateFontSize',
+		colorField: 'nicknameFontColor'|'amountFontColor'|'messageFontColor'|'templateFontColor',
+		defaultSize: number
+	) => (
+		<div className="alert-config__font-group">
+			<div className="alert-config__font-group-title">{label}</div>
+			<div className="alert-config__font-grid">
+				<div className="alert-config__field">
+					<label htmlFor={`${prefix}-family`} className="alert-config__field-label">글꼴</label>
+					<select
+						id={`${prefix}-family`}
+						className="alert-config__select"
+						value={form[familyField] ?? ''}
+						onChange={e => onFormChange(familyField, e.target.value || null)}
+					>
+						{FONT_OPTIONS.map(f => (
+							<option key={f.value} value={f.value}>{f.label}</option>
+						))}
+					</select>
+				</div>
+				<div className="alert-config__field">
+					<label htmlFor={`${prefix}-size`} className="alert-config__field-label">크기 (px)</label>
+					<input
+						id={`${prefix}-size`}
+						type="number"
+						className="alert-config__input"
+						min={FONT_SIZE_MIN}
+						max={FONT_SIZE_MAX}
+						value={form[sizeField]}
+						onChange={e => onFormChange(sizeField, parseInt(e.target.value) || defaultSize)}
+						title='Font Size'
+					/>
+				</div>
+				<div className="alert-config__field">
+					<label htmlFor={`${prefix}-color`} className="alert-config__field-label">색상</label>
+					<div className="alert-config__color-wrap">
+						<input
+							id={`${prefix}-color`}
+							type="color"
+							className="alert-config__color-input"
+							value={form[colorField]}
+							onChange={e => onFormChange(colorField, e.target.value)}
+						/>
+						<input
+							type="text"
+							className="alert-config__input alert-config__color-text"
+							value={form[colorField]}
+							onChange={e => onFormChange(colorField, e.target.value)}
+							maxLength={COLOR_HEX_MAX_LENGTH}
+							title='Font Color'
+						/>
+					</div>
+				</div>
+			</div>
+		</div>
+	);
+
+	return (
+		<main className="alert-config__form-panel">
+			{/* ── 기본 설정 ────────────────────────────── */}
+			<section className="alert-config__section">
+				<h3 className="alert-config__section-title">기본 설정</h3>
+				<div className="alert-config__section-body">
+					<div className="alert-config__field">
+						<label htmlFor="config-title" className="alert-config__field-label">제목</label>
+						<input
+							id="config-title"
+							type="text"
+							className="alert-config__input"
+							value={form.title}
+							onChange={e => onFormChange('title', e.target.value)}
+							placeholder="알림 제목 (선택)"
+							maxLength={ALERT_TITLE_MAX_LENGTH}
+						/>
+					</div>
+
+					<div className="alert-config__field-row">
+						<div className="alert-config__field">
+							<label htmlFor="config-amount" className="alert-config__field-label">금액 (원)</label>
+							<input
+								id="config-amount"
+								type="number"
+								className="alert-config__input"
+								min={ALERT_AMOUNT_MIN}
+								value={form.amount}
+								onChange={e => onFormChange('amount', parseInt(e.target.value) || 0)}
+							/>
+						</div>
+						<div className="alert-config__field">
+							<label className="alert-config__field-label">조건</label>
+							<div className="alert-config__radio-group">
+								{MATCH_TYPES.map(mt => (
+									<label key={mt.value} className="alert-config__radio-label">
+										<input
+											type="radio"
+											name="matchType"
+											value={mt.value}
+											checked={form.matchType === mt.value}
+											onChange={() => onFormChange('matchType', mt.value)}
+										/>
+										{mt.label}
+									</label>
+								))}
+							</div>
+						</div>
+					</div>
+
+					<div className="alert-config__field">
+						<label htmlFor="config-message" className="alert-config__field-label">메시지</label>
+						<input
+							id="config-message"
+							type="text"
+							className="alert-config__input"
+							value={form.message}
+							onChange={e => onFormChange('message', e.target.value)}
+							placeholder="{이름}님이 {금액}원을 후원했습니다!"
+							maxLength={ALERT_MESSAGE_MAX_LENGTH}
+						/>
+						<span className="alert-config__field-hint">
+							사용 가능한 변수: {'{이름}'}, {'{금액}'}
+						</span>
+					</div>
+
+					<div className="alert-config__field-row">
+						<div className="alert-config__field">
+							<label htmlFor="config-play-delay" className="alert-config__field-label">재생 지연 (초)</label>
+							<input
+								id="config-play-delay"
+								type="number"
+								className="alert-config__input"
+								min={ALERT_DELAY_MIN}
+								max={ALERT_DELAY_MAX}
+								step={ALERT_DELAY_STEP}
+								value={form.playDelaySec}
+								onChange={e => onFormChange('playDelaySec', parseFloat(e.target.value) || 0)}
+							/>
+						</div>
+						<div className="alert-config__field">
+							<label htmlFor="config-display-duration" className="alert-config__field-label">노출 시간 (초)</label>
+							<input
+								id="config-display-duration"
+								type="number"
+								className="alert-config__input"
+								min={ALERT_DURATION_MIN}
+								max={ALERT_DURATION_MAX}
+								step={ALERT_DURATION_STEP}
+								value={form.displayDurationSec}
+								onChange={e => onFormChange('displayDurationSec', parseFloat(e.target.value) || 0)}
+							/>
+						</div>
+					</div>
+
+					<div className="alert-config__field">
+						<label htmlFor="config-is-active" className="alert-config__checkbox-label">
+							<Checkbox
+								id="config-is-active"
+								checked={form.isActive}
+								onCheckedChange={v => onFormChange('isActive', !!v)}
+							/>
+							알림을 사용합니다.
+						</label>
+					</div>
+				</div>
+			</section>
+
+			{/* ── 효과 ────────────────────────────────── */}
+			<section className="alert-config__section">
+				<h3 className="alert-config__section-title">효과</h3>
+				<div className="alert-config__section-body">
+					<div className="alert-config__field-row">
+						<div className="alert-config__field">
+							<label htmlFor="config-popup-effect" className="alert-config__field-label">팝업 효과</label>
+							<select
+								id="config-popup-effect"
+								className="alert-config__select"
+								value={form.popupEffect ?? ''}
+								onChange={e => onFormChange('popupEffect', e.target.value || null)}
+							>
+								{POPUP_EFFECTS.map(e => (
+									<option key={e.value} value={e.value}>{e.label}</option>
+								))}
+							</select>
+						</div>
+						<div className="alert-config__field">
+							<label htmlFor="config-text-effect" className="alert-config__field-label">텍스트 효과</label>
+							<select
+								id="config-text-effect"
+								className="alert-config__select"
+								value={form.textEffect ?? ''}
+								onChange={e => onFormChange('textEffect', e.target.value || null)}
+							>
+								{TEXT_EFFECTS.map(e => (
+									<option key={e.value} value={e.value}>{e.label}</option>
+								))}
+							</select>
+						</div>
+					</div>
+				</div>
+			</section>
+
+			{/* ── 폰트 ────────────────────────────────── */}
+			<section className="alert-config__section">
+				<h3 className="alert-config__section-title">폰트</h3>
+				<div className="alert-config__section-body">
+					{renderFontGroup('이름', 'font-nickname', 'nicknameFontFamily', 'nicknameFontSize', 'nicknameFontColor', 24)}
+					{renderFontGroup('금액', 'font-amount', 'amountFontFamily', 'amountFontSize', 'amountFontColor', 24)}
+					{renderFontGroup('보낼 내용', 'font-message', 'messageFontFamily', 'messageFontSize', 'messageFontColor', 18)}
+					{renderFontGroup('알림 문구', 'font-template', 'templateFontFamily', 'templateFontSize', 'templateFontColor', 24)}
+				</div>
+			</section>
+
+			{/* ── 미디어 ───────────────────────────────── */}
+			<section className="alert-config__section">
+				<h3 className="alert-config__section-title">미디어</h3>
+				<div className="alert-config__section-body">
+
+					{/* 이미지 */}
+					<div className="alert-config__field">
+						<label htmlFor="config-enable-image" className="alert-config__checkbox-label">
+							<Checkbox
+								id="config-enable-image"
+								checked={form.enableImage}
+								onCheckedChange={v => onFormChange('enableImage', !!v)}
+							/>
+							이미지 사용
+						</label>
+					</div>
+
+					{form.enableImage && (
+						<div className="alert-config__media-upload">
+							{form.imageUrl && (
+								<div className="alert-config__media-preview">
+									<img src={form.imageUrl} alt="알림 이미지" />
+									<button
+										type="button"
+										className="alert-config__media-remove"
+										onClick={() => onFormChange('imageUrl', null)}
+										title="삭제"
+									>
+										<FontAwesomeIcon icon={faTrash} />
+									</button>
+								</div>
+							)}
+							<input
+								ref={imageInputRef}
+								type="file"
+								accept=".jpg,.jpeg,.png,.gif"
+								className="alert-config__file-hidden"
+								onChange={e => {
+									const file = e.target.files?.[0];
+									if (file) onFileSelect(file, 'image');
+									e.target.value = '';
+								}}
+								title='이미지 첨부'
+							/>
+							<button
+								type="button"
+								className="alert-config__btn"
+								onClick={() => imageInputRef.current?.click()}
+								disabled={saving}
+							>
+								<FontAwesomeIcon icon={faUpload} />
+								이미지 선택
+							</button>
+							{pendingFiles.image && (
+								<span className="alert-config__field-hint">{pendingFiles.image.name}</span>
+							)}
+							<span className="alert-config__field-hint">JPG, PNG, GIF (최대 20MB)</span>
+						</div>
+					)}
+
+					{/* 사운드 */}
+					<div className="alert-config__field">
+						<label htmlFor="config-enable-sound" className="alert-config__checkbox-label">
+							<Checkbox
+								id="config-enable-sound"
+								checked={form.enableSound}
+								onCheckedChange={v => onFormChange('enableSound', !!v)}
+							/>
+							사운드 사용
+						</label>
+					</div>
+					{form.enableSound && (
+						<div className="alert-config__media-upload">
+							{form.soundUrl && (
+								<div className="alert-config__media-preview alert-config__media-preview--audio">
+									<audio controls src={form.soundUrl} />
+									<button
+										type="button"
+										className="alert-config__media-remove"
+										onClick={() => onFormChange('soundUrl', null)}
+										title="삭제"
+									>
+										<FontAwesomeIcon icon={faTrash} />
+									</button>
+								</div>
+							)}
+							<input
+								ref={soundInputRef}
+								type="file"
+								accept=".mp3,.ogg,.wav,.m4a"
+								className="alert-config__file-hidden"
+								onChange={e => {
+									const file = e.target.files?.[0];
+									if (file) onFileSelect(file, 'sound');
+									e.target.value = '';
+								}}
+								title='사운드 첨부'
+							/>
+							<button
+								type="button"
+								className="alert-config__btn"
+								onClick={() => soundInputRef.current?.click()}
+								disabled={saving}
+							>
+								<FontAwesomeIcon icon={faUpload} />
+								사운드 선택
+							</button>
+							{pendingFiles.sound && (
+								<span className="alert-config__field-hint">{pendingFiles.sound.name}</span>
+							)}
+							<span className="alert-config__field-hint">MP3, OGG, WAV, M4A (최대 50MB)</span>
+						</div>
+					)}
+				</div>
+			</section>
+
+			{/* ── 하단 버튼 ────────────────────────────── */}
+			<div className="alert-config__form-footer flex-1 w-full sm:justify-end gap-2">
+				<button type="button" className="alert-config__btn flex-1 sm:flex-none" onClick={onCancel}>
+					취소
+				</button>
+				<button
+					type="button"
+					className="alert-config__btn alert-config__btn--primary flex-1 sm:flex-none"
+					onClick={onSave}
+					disabled={saving}
+				>
+					{saving ? '저장 중...' : (editingItem ? '수정하기' : '등록하기')}
+				</button>
+			</div>
+		</main>
+	);
+}

+ 287 - 0
app/studio/donation/alert/_components/AlertListPanel.tsx

@@ -0,0 +1,287 @@
+'use client';
+
+import { useEffect, useRef, useState } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faPlus, faImage, faVolumeHigh } from '@fortawesome/free-solid-svg-icons';
+import { Checkbox } from '@/components/ui/checkbox';
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
+import { PER_PAGE_OPTIONS } from '@/constants/donation';
+
+type Props = {
+	items: AlertConfigItem[];
+	loading: boolean;
+	saving: boolean;
+	checkedIDs: Set<number>;
+	setCheckedIDs: React.Dispatch<React.SetStateAction<Set<number>>>;
+	page: number;
+	setPage: React.Dispatch<React.SetStateAction<number>>;
+	perPage: number;
+	setPerPage: React.Dispatch<React.SetStateAction<number>>;
+	onNew: () => void;
+	onEdit: (item: AlertConfigItem) => void;
+	onBatchDelete: () => void;
+};
+
+export default function AlertListPanel({
+	items,
+	loading,
+	saving,
+	checkedIDs,
+	setCheckedIDs,
+	page,
+	setPage,
+	perPage,
+	setPerPage,
+	onNew,
+	onEdit,
+	onBatchDelete
+}: Props) {
+	const [playingAudio, setPlayingAudio] = useState<number|null>(null);
+	const audioRef = useRef<HTMLAudioElement|null>(null);
+
+	// ── 페이징 ───────────────────────────────────────
+	const totalPages = Math.max(1, Math.ceil(items.length / perPage));
+	const pagedItems = items.slice((page - 1) * perPage, page * perPage);
+
+	const handlePerPageChange = (value: number) => {
+		setPerPage(value);
+		setPage(1);
+	};
+
+	// ── 전체선택 ─────────────────────────────────────
+	const visibleIDs = pagedItems.map(i => i.id);
+	const checkedCount = visibleIDs.filter(id => checkedIDs.has(id)).length;
+	const allChecked = pagedItems.length > 0 && checkedCount === visibleIDs.length;
+	const isIndeterminate = checkedCount > 0 && checkedCount < visibleIDs.length;
+
+	const handleSelectAll = () => {
+		setCheckedIDs(prev => {
+			const next = new Set(prev);
+			if (allChecked) {
+				visibleIDs.forEach(id => next.delete(id));
+			} else {
+				visibleIDs.forEach(id => next.add(id));
+			}
+			return next;
+		});
+	};
+
+	const handleToggleCheck = (id: number) => {
+		setCheckedIDs(prev => {
+			const next = new Set(prev);
+			if (next.has(id)) {
+				next.delete(id);
+			} else {
+				next.add(id);
+			}
+			return next;
+		});
+	};
+
+	// ── 사운드 재생/정지 ─────────────────────────────
+	const handlePlaySound = (itemId: number, soundUrl: string) => {
+		// 이미 재생 중이면 정지
+		if (playingAudio === itemId && audioRef.current) {
+			audioRef.current.pause();
+			audioRef.current = null;
+			setPlayingAudio(null);
+			return;
+		}
+
+		// 기존 재생 정지
+		if (audioRef.current) {
+			audioRef.current.pause();
+			audioRef.current = null;
+		}
+
+		const audio = new Audio(soundUrl);
+		audioRef.current = audio;
+		setPlayingAudio(itemId);
+
+		audio.play().catch(() => {});
+		audio.addEventListener('ended', () => {
+			setPlayingAudio(null);
+			audioRef.current = null;
+		});
+	};
+
+	// unmount 시 오디오 정리
+	useEffect(() => {
+		return () => {
+			if (audioRef.current) {
+				audioRef.current.pause();
+				audioRef.current = null;
+			}
+		};
+	}, []);
+
+	return (
+		<div className="alert-config__list-panel">
+			<div className="alert-config__toolbar">
+				<div className="alert-config__toolbar-left">
+					<span className="alert-config__count">총 {items.length}개</span>
+					{checkedIDs.size > 0 && (
+						<span className="alert-config__count">({checkedIDs.size}개 선택)</span>
+					)}
+				</div>
+				<div className="alert-config__toolbar-right">
+					<select
+						value={perPage}
+						onChange={e => handlePerPageChange(Number(e.target.value))}
+						className="alert-config__per-page"
+						title="보여질 개수"
+					>
+						{PER_PAGE_OPTIONS.map(n => (
+							<option key={n} value={n}>{n}개씩</option>
+						))}
+					</select>
+					<button type="button" className="alert-config__btn" onClick={onNew}>
+						<FontAwesomeIcon icon={faPlus} />
+						추가
+					</button>
+					<button
+						type="button"
+						className="alert-config__btn alert-config__btn--danger"
+						onClick={onBatchDelete}
+						disabled={checkedIDs.size === 0 || saving}
+					>
+						삭제
+					</button>
+				</div>
+			</div>
+
+			<div className="alert-config__table-wrap">
+				{loading ? (
+					<div className="alert-config__empty">준비 중...</div>
+				) : items.length === 0 ? (
+					<div className="alert-config__empty">등록된 알림 설정이 없습니다.</div>
+				) : (
+					<table className="alert-config__table">
+						<thead>
+							<tr>
+								<th className="alert-config__th--check">
+									<Checkbox
+										checked={isIndeterminate ? 'indeterminate' : allChecked}
+										onCheckedChange={handleSelectAll}
+										aria-label="전체선택"
+									/>
+								</th>
+								<th>조건</th>
+								<th>금액</th>
+								<th>제목</th>
+								<th>보낼 내용</th>
+								<th>노출(초)</th>
+								<th>활성</th>
+								<th>미디어</th>
+								<th>비고</th>
+							</tr>
+						</thead>
+						<tbody>
+							{pagedItems.map(item => {
+								const isChecked = checkedIDs.has(item.id);
+								const hasImage = item.enableImage && item.imageUrl;
+								const hasSound = item.enableSound && item.soundUrl;
+								return (
+									<tr
+										key={item.id}
+										className={isChecked ? 'alert-config__row--checked' : ''}
+									>
+										<td className="alert-config__td--check">
+											<Checkbox
+												checked={isChecked}
+												onCheckedChange={() => handleToggleCheck(item.id)}
+												aria-label={`${item.id} 선택`}
+											/>
+										</td>
+										<td>
+											<span className={`alert-config__match-badge alert-config__match-badge--${item.matchType === 1 ? 'exact' : 'min'}`}>
+												{item.matchType === 1 ? '정확히' : '이상'}
+											</span>
+										</td>
+										<td>{item.amount.toLocaleString()}원</td>
+										<td>{item.title || <span className="text-muted-foreground">미입력</span>}</td>
+										<td className="alert-config__td--message">{item.message}</td>
+										<td>{item.displayDurationSec}초</td>
+										<td>
+											<span className={`alert-config__status-badge alert-config__status-badge--${item.isActive ? 'active' : 'inactive'}`}>
+												{item.isActive ? '활성' : '비활성'}
+											</span>
+										</td>
+										<td className="alert-config__td--media">
+											<div>
+												{hasImage && (
+													<button
+														type="button"
+														className="alert-config__media-btn alert-config__media-btn--image"
+														title="이미지 보기"
+														onClick={() => window.open(item.imageUrl!, '_blank')}
+													>
+														<FontAwesomeIcon icon={faImage} />
+													</button>
+												)}
+												{hasSound && (
+													<button
+														type="button"
+														className={`alert-config__media-btn alert-config__media-btn--sound${playingAudio === item.id ? ' alert-config__media-btn--playing' : ''}`}
+														title={playingAudio === item.id ? '사운드 정지' : '사운드 재생'}
+														onClick={() => handlePlaySound(item.id, item.soundUrl!)}
+													>
+														<FontAwesomeIcon icon={faVolumeHigh} />
+													</button>
+												)}
+												{!hasImage && !hasSound && (
+													<span className="text-muted-foreground">-</span>
+												)}
+											</div>
+										</td>
+										<td>
+											<button
+												type="button"
+												className="alert-config__btn alert-config__btn--sm"
+												onClick={() => onEdit(item)}
+												disabled={isChecked}
+											>
+												수정
+											</button>
+										</td>
+									</tr>
+								);
+							})}
+						</tbody>
+					</table>
+				)}
+			</div>
+
+			{totalPages > 1 && (
+				<div className="alert-config__pagination">
+					<button
+						type="button"
+						className="alert-config__page-btn"
+						disabled={page <= 1}
+						onClick={() => setPage(p => p - 1)}
+					>
+						◀
+					</button>
+					{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
+						<button
+							key={p}
+							type="button"
+							className={`alert-config__page-btn${p === page ? ' alert-config__page-btn--active' : ''}`}
+							onClick={() => setPage(p)}
+						>
+							{p}
+						</button>
+					))}
+					<button
+						type="button"
+						className="alert-config__page-btn"
+						disabled={page >= totalPages}
+						onClick={() => setPage(p => p + 1)}
+					>
+						▶
+					</button>
+				</div>
+			)}
+		</div>
+	);
+}

+ 106 - 0
app/studio/donation/alert/_components/AlertPreviewPanel.tsx

@@ -0,0 +1,106 @@
+'use client';
+
+import { useState } from 'react';
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faPlay } from '@fortawesome/free-solid-svg-icons';
+import {
+	DONATION_AMOUNT_MIN, DONATION_AMOUNT_MAX, DONATION_AMOUNT_STEP, DONATION_AMOUNT_DEFAULT,
+	ALERT_NAME_MAX_LENGTH, ALERT_MESSAGE_MAX_LENGTH,
+	PREVIEW_DEFAULT_NAME, PREVIEW_DEFAULT_MESSAGE
+} from '@/constants/donation';
+
+type Props = {
+	widgetToken: string|null;
+	iframeRef: React.RefObject<HTMLIFrameElement|null>;
+};
+
+export default function AlertPreviewPanel({ widgetToken, iframeRef }: Props) {
+	const [previewName, setPreviewName] = useState(PREVIEW_DEFAULT_NAME);
+	const [previewAmount, setPreviewAmount] = useState(DONATION_AMOUNT_DEFAULT);
+	const [previewMessage, setPreviewMessage] = useState(PREVIEW_DEFAULT_MESSAGE);
+
+	const handlePreview = () => {
+		if (!iframeRef.current?.contentWindow) {
+			return;
+		}
+
+		iframeRef.current.contentWindow.postMessage({
+			type: 'ALERT_TEST',
+			sendName: previewName || PREVIEW_DEFAULT_NAME,
+			amount: previewAmount || DONATION_AMOUNT_DEFAULT,
+			message: previewMessage || '',
+		}, window.location.origin);
+	};
+
+	return (
+		<aside className="alert-config__preview-panel">
+			{/* 미리보기 iframe */}
+			<div className="alert-config__widget">
+				<div className="alert-config__widget-label">미리보기</div>
+				{widgetToken ? (
+					<iframe
+						ref={iframeRef}
+						src={`/widget/alert/${widgetToken}?preview=1`}
+						className="alert-config__widget-frame"
+						title="알림 미리보기"
+					/>
+				) : (
+					<div className="alert-config__widget-empty">채널 정보를 불러오는 중...</div>
+				)}
+			</div>
+
+			{/* 미리보기 form */}
+			<fieldset className="alert-config__fieldset">
+				<legend className="alert-config__legend">미리보기</legend>
+				<div className="alert-config__preview-form">
+					<div className="alert-config__field">
+						<label htmlFor="preview-amount" className="alert-config__field-label">후원 금액 (원)</label>
+						<input
+							id="preview-amount"
+							type="number"
+							className="alert-config__input"
+							min={DONATION_AMOUNT_MIN}
+							max={DONATION_AMOUNT_MAX}
+							step={DONATION_AMOUNT_STEP}
+							value={previewAmount}
+							onChange={e => setPreviewAmount(parseInt(e.target.value) || DONATION_AMOUNT_DEFAULT)}
+						/>
+					</div>
+					<div className="alert-config__field">
+						<label htmlFor="preview-name" className="alert-config__field-label">이름</label>
+						<input
+							id="preview-name"
+							type="text"
+							className="alert-config__input"
+							maxLength={ALERT_NAME_MAX_LENGTH}
+							value={previewName}
+							onChange={e => setPreviewName(e.target.value)}
+							placeholder="후원자 이름"
+						/>
+					</div>
+					<div className="alert-config__field">
+						<label htmlFor="preview-message" className="alert-config__field-label">보낼 내용</label>
+						<input
+							id="preview-message"
+							type="text"
+							className="alert-config__input"
+							maxLength={ALERT_MESSAGE_MAX_LENGTH}
+							value={previewMessage}
+							onChange={e => setPreviewMessage(e.target.value)}
+							placeholder="후원 메시지"
+						/>
+					</div>
+					<button
+						type="button"
+						className="alert-config__btn alert-config__btn--primary alert-config__preview-btn"
+						onClick={handlePreview}
+						disabled={!widgetToken}
+					>
+						<FontAwesomeIcon icon={faPlay} />
+						미리보기
+					</button>
+				</div>
+			</fieldset>
+		</aside>
+	);
+}

+ 200 - 0
app/studio/donation/alert/add/page.tsx

@@ -0,0 +1,200 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { useRouter } from 'next/navigation';
+import Link from 'next/link';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import { useAlertConfigContext } from '../context';
+import { Separator } from '@/components/ui/separator';
+import AlertPreviewPanel from '../_components/AlertPreviewPanel';
+import AlertFormPanel from '../_components/AlertFormPanel';
+import { createEmptyForm } from '../types';
+import type { FormState, PendingFiles } from '../types';
+
+export default function AlertAddPage()
+{
+	const router = useRouter();
+	const { channelID, memberID } = useStudioContext();
+	const { widgetToken, setSaving, fetchList } = useAlertConfigContext();
+
+	const [form, setForm] = useState<FormState>(createEmptyForm());
+	const [pendingFiles, setPendingFiles] = useState<PendingFiles>({ image: null, sound: null });
+	const [localSaving, setLocalSaving] = useState(false);
+	const iframeRef = useRef<HTMLIFrameElement>(null);
+	const formRef = useRef<FormState>(form);
+	formRef.current = form;
+
+	// ── blob URL cleanup ─────────────────────────────
+	const cleanupBlobUrls = (f: FormState) => {
+		if (f.imageUrl?.startsWith('blob:')) {
+			URL.revokeObjectURL(f.imageUrl);
+		}
+
+		if (f.soundUrl?.startsWith('blob:')) {
+			URL.revokeObjectURL(f.soundUrl);
+		}
+	};
+
+	// unmount 시 cleanup
+	useEffect(() => {
+		return () => {
+			cleanupBlobUrls(formRef.current);
+		};
+	}, []);
+
+	// ── 폼 → iframe 미리보기 동기화 ─────────────────
+	useEffect(() => {
+		if (!iframeRef.current?.contentWindow) {
+			return;
+		}
+
+		iframeRef.current.contentWindow.postMessage({
+			type: 'ALERT_PREVIEW',
+			config: form,
+		}, window.location.origin);
+	}, [form]);
+
+	// ── 폼 필드 변경 ────────────────────────────────
+	const handleFormChange = <K extends keyof FormState>(field: K, value: FormState[K]) => {
+		setForm(prev => {
+			if ((field === 'imageUrl' || field === 'soundUrl') && typeof prev[field] === 'string' && (prev[field] as string).startsWith('blob:')) {
+				URL.revokeObjectURL(prev[field] as string);
+			}
+
+			return { ...prev, [field]: value };
+		});
+
+		if (field === 'imageUrl' && value === null) {
+			setPendingFiles(prev => ({ ...prev, image: null }));
+		}
+		if (field === 'soundUrl' && value === null) {
+			setPendingFiles(prev => ({ ...prev, sound: null }));
+		}
+	};
+
+	// ── 파일 업로드 헬퍼 ─────────────────────────────
+	const uploadFile = async (file: File, type: 'image'|'sound'): Promise<string> => {
+		const formData = new FormData();
+		formData.append('file', file);
+		formData.append('type', type);
+		formData.append('channelID', channelID!.toString());
+
+		const res = await fetchApi<{ url: string }>('/api/studio/donation/alert/config/upload', {
+			method: 'POST',
+			body: formData,
+		});
+
+		return res.data?.url ?? '';
+	};
+
+	// ── 저장 ─────────────────────────────────────────
+	const handleSave = async () => {
+		if (!channelID) {
+			return;
+		}
+
+		if (!form.message.trim()) {
+			alert('메시지를 입력해 주세요.');
+			return;
+		}
+		if (form.amount < 1) {
+			alert('금액은 1원 이상이어야 합니다.');
+			return;
+		}
+		if (form.displayDurationSec < 1) {
+			alert('노출 시간은 1초 이상이어야 합니다.');
+			return;
+		}
+
+		setLocalSaving(true);
+		setSaving(true);
+
+		try {
+			let finalImageUrl = form.imageUrl;
+			let finalSoundUrl = form.soundUrl;
+
+			if (pendingFiles.image) {
+				finalImageUrl = await uploadFile(pendingFiles.image, 'image');
+			}
+			if (pendingFiles.sound) {
+				finalSoundUrl = await uploadFile(pendingFiles.sound, 'sound');
+			}
+
+			const item = {
+				id: null,
+				...form,
+				imageUrl: finalImageUrl,
+				soundUrl: finalSoundUrl,
+				popupEffect: form.popupEffect || null,
+				textEffect: form.textEffect || null,
+				nicknameFontFamily: form.nicknameFontFamily || null,
+				amountFontFamily: form.amountFontFamily || null,
+				messageFontFamily: form.messageFontFamily || null,
+			};
+
+			await fetchApi('/api/studio/donation/alert/config/batch', {
+				method: 'POST',
+				body: { channelID, memberID, items: [item], deleteIDs: [] },
+			});
+
+			cleanupBlobUrls(form);
+
+			alert('등록되었습니다.');
+			fetchList();
+			router.push('/studio/donation/alert/list');
+		} catch (err) {
+			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setLocalSaving(false);
+			setSaving(false);
+		}
+	};
+
+	// ── 취소 ─────────────────────────────────────────
+	const handleCancel = () => {
+		cleanupBlobUrls(form);
+		router.push('/studio/donation/alert/list');
+	};
+
+	return (
+		<>
+			<div className="studio-page__title-row">
+				<h1 className="studio-page__title">후원 알림 추가</h1>
+				<Link href="/studio/donation/alert/list" className="alert-config__btn alert-config__btn--sm">< 목록으로</Link>
+			</div>
+			<div className='pt-5 pb-5'>
+				<Separator orientation="horizontal" />
+			</div>
+			<div className="alert-config__layout">
+				<AlertPreviewPanel
+					widgetToken={widgetToken}
+					iframeRef={iframeRef}
+				/>
+
+				<Separator orientation="vertical" />
+
+				<AlertFormPanel
+					form={form}
+					editingItem={null}
+					saving={localSaving}
+					pendingFiles={pendingFiles}
+					onFileSelect={(file, type) => {
+						const previewUrl = URL.createObjectURL(file);
+
+						if (type === 'image') {
+							setPendingFiles(prev => ({ ...prev, image: file }));
+							handleFormChange('imageUrl', previewUrl);
+						} else {
+							setPendingFiles(prev => ({ ...prev, sound: file }));
+							handleFormChange('soundUrl', previewUrl);
+						}
+					}}
+					onFormChange={handleFormChange}
+					onSave={handleSave}
+					onCancel={handleCancel}
+				/>
+			</div>
+		</>
+	);
+}

+ 165 - 0
app/studio/donation/alert/constants.ts

@@ -0,0 +1,165 @@
+// Animate.css 기반 팝업 효과
+export const POPUP_EFFECTS = [
+	{ label: '없음', value: '' },
+
+	// Attention Seekers
+	{ label: 'Bounce', value: 'bounce' },
+	{ label: 'Flash', value: 'flash' },
+	{ label: 'Pulse', value: 'pulse' },
+	{ label: 'Rubber Band', value: 'rubberBand' },
+	{ label: 'Shake X', value: 'shakeX' },
+	{ label: 'Shake Y', value: 'shakeY' },
+	{ label: 'Head Shake', value: 'headShake' },
+	{ label: 'Swing', value: 'swing' },
+	{ label: 'Tada', value: 'tada' },
+	{ label: 'Wobble', value: 'wobble' },
+	{ label: 'Jello', value: 'jello' },
+	{ label: 'Heart Beat', value: 'heartBeat' },
+
+	// Bouncing Entrances
+	{ label: 'Bounce In', value: 'bounceIn' },
+	{ label: 'Bounce In Down', value: 'bounceInDown' },
+	{ label: 'Bounce In Left', value: 'bounceInLeft' },
+	{ label: 'Bounce In Right', value: 'bounceInRight' },
+	{ label: 'Bounce In Up', value: 'bounceInUp' },
+
+	// Fading Entrances
+	{ label: 'Fade In', value: 'fadeIn' },
+	{ label: 'Fade In Down', value: 'fadeInDown' },
+	{ label: 'Fade In Left', value: 'fadeInLeft' },
+	{ label: 'Fade In Right', value: 'fadeInRight' },
+	{ label: 'Fade In Up', value: 'fadeInUp' },
+
+	// Flippers
+	{ label: 'Flip', value: 'flip' },
+	{ label: 'Flip In X', value: 'flipInX' },
+	{ label: 'Flip In Y', value: 'flipInY' },
+
+	// Rotating Entrances
+	{ label: 'Rotate In', value: 'rotateIn' },
+	{ label: 'Rotate In Down Left', value: 'rotateInDownLeft' },
+	{ label: 'Rotate In Down Right', value: 'rotateInDownRight' },
+
+	// Zoom Entrances
+	{ label: 'Zoom In', value: 'zoomIn' },
+	{ label: 'Zoom In Down', value: 'zoomInDown' },
+	{ label: 'Zoom In Left', value: 'zoomInLeft' },
+	{ label: 'Zoom In Right', value: 'zoomInRight' },
+	{ label: 'Zoom In Up', value: 'zoomInUp' },
+
+	// Sliding Entrances
+	{ label: 'Slide In Down', value: 'slideInDown' },
+	{ label: 'Slide In Left', value: 'slideInLeft' },
+	{ label: 'Slide In Right', value: 'slideInRight' },
+	{ label: 'Slide In Up', value: 'slideInUp' }
+];
+
+// 텍스트 효과 (반복 애니메이션)
+export const TEXT_EFFECTS = [
+	{ label: '없음', value: '' },
+
+	// Flippers
+	{ label: 'Flip', value: 'flip' },
+
+	// Common
+	{ label: 'Bounce', value: 'bounce' },
+	{ label: 'Flash', value: 'flash' },
+	{ label: 'Pulse', value: 'pulse' },
+	{ label: 'Rubber Band', value: 'rubberBand' },
+	{ label: 'Shake', value: 'shakeX' },
+	{ label: 'Swing', value: 'swing' },
+	{ label: 'Tada', value: 'tada' },
+	{ label: 'Wobble', value: 'wobble' },
+	{ label: 'Jello', value: 'jello' },
+	{ label: 'Wiggle', value: 'headShake' },
+];
+
+// 폰트 옵션 (무료 한국어 웹폰트)
+export const FONT_OPTIONS = [
+	{ label: '기본', value: '' },
+	{ label: '맑은 고딕', value: 'Malgun Gothic' },
+	{ label: 'Spoqa Han Sans', value: 'Spoqa Han Sans Neo' },
+
+	// 나눔 글꼴
+	{ label: '나눔고딕', value: 'Nanum Gothic' },
+	{ label: '나눔고딕 Eco', value: 'Nanum Gothic Eco' },
+	{ label: '나눔고딕코딩', value: 'Nanum Gothic Coding' },
+	{ label: '나눔명조', value: 'Nanum Myeongjo' },
+	{ label: '나눔명조 Eco', value: 'Nanum Myeongjo Eco' },
+	{ label: '나눔손글씨 붓', value: 'Nanum Brush Script' },
+	{ label: '나눔손글씨 펜', value: 'Nanum Pen Script' },
+	{ label: '나눔바른펜', value: 'NanumBarunpen' },
+
+	// 제주 서체
+	{ label: '제주고딕', value: 'Jeju Gothic' },
+	{ label: '제주명조', value: 'Jeju Myeongjo' },
+	{ label: '제주한라산', value: 'Jeju Hallasan' },
+
+	// 서울 서체
+	{ label: '서울남산 M', value: 'Seoul Namsan M' },
+	{ label: '서울남산 B', value: 'Seoul Namsan B' },
+	{ label: '서울한강 M', value: 'Seoul Hangang M' },
+	{ label: '서울한강 B', value: 'Seoul Hangang B' },
+
+	// 배달의민족 서체
+	{ label: '기랑해랑', value: 'KirangHaerang' },
+	{ label: '한나', value: 'Black Han Sans' },
+	{ label: '한나 Air', value: 'Black And White Picture' },
+	{ label: '한나는 11살', value: 'Hanna 11yrs old' },
+	{ label: '주아', value: 'Jua' },
+	{ label: '연성', value: 'Yeon Sung' },
+	{ label: '도현', value: 'Do Hyeon' },
+
+	// 고도 서체
+	{ label: '고도', value: 'Godo' },
+	{ label: '고도 라운드', value: 'Godo Rounded' },
+	{ label: '고도 마음', value: 'Godo Maum' },
+
+	// 기타
+	{ label: 'KoPub 바탕', value: 'KoPub Batang' },
+	{ label: 'Noto Sans KR', value: 'Noto Sans KR' },
+	{ label: '야놀자 야체', value: 'Yanolja Yache' },
+	{ label: '미생', value: 'Misaeng' },
+	{ label: '코마콘', value: 'Komacon' },
+	{ label: 'G마켓 산스 L', value: 'GmarketSans Light' },
+	{ label: 'G마켓 산스 M', value: 'GmarketSans Medium' },
+	{ label: 'G마켓 산스 B', value: 'GmarketSans Bold' },
+	{ label: '고양', value: 'Goyang' },
+	{ label: '빙그레', value: 'Binggrae' },
+	{ label: '스웨거', value: 'Swagger' }
+];
+
+// 매치 타입
+export const MATCH_TYPES = [
+	{ label: '이상', value: 0 },
+	{ label: '해당 금액', value: 1 }
+];
+
+// 기본값
+export const ALERT_DEFAULTS = {
+	title: '',
+	amount: 100,
+	matchType: 0,
+	message: '',
+	playDelaySec: 0,
+	displayDurationSec: 10,
+	popupEffect: null as string|null,
+	textEffect: null as string|null,
+	nicknameFontFamily: null as string|null,
+	nicknameFontSize: 24,
+	nicknameFontColor: '#FFD700',
+	amountFontFamily: null as string|null,
+	amountFontSize: 24,
+	amountFontColor: '#FF6B35',
+	messageFontFamily: null as string|null,
+	messageFontSize: 18,
+	messageFontColor: '#FFFFFF',
+	templateFontFamily: null as string|null,
+	templateFontSize: 24,
+	templateFontColor: '#FFFFFF',
+	enableImage: false,
+	imageUrl: null as string|null,
+	enableSound: false,
+	soundUrl: null as string|null,
+	isActive: true
+};

+ 24 - 0
app/studio/donation/alert/context.tsx

@@ -0,0 +1,24 @@
+'use client';
+
+import { createContext, useContext } from 'react';
+import type { Dispatch, SetStateAction } from 'react';
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
+
+export type AlertConfigContextValue = {
+	items: AlertConfigItem[];
+	widgetToken: string|null;
+	loading: boolean;
+	saving: boolean;
+	setSaving: Dispatch<SetStateAction<boolean>>;
+	fetchList: () => void;
+};
+
+export const AlertConfigContext = createContext<AlertConfigContextValue|null>(null);
+
+export function useAlertConfigContext(): AlertConfigContextValue {
+	const ctx = useContext(AlertConfigContext);
+	if (!ctx) {
+		throw new Error('useAlertConfigContext must be used within AlertConfigProvider');
+	}
+	return ctx;
+}

+ 225 - 0
app/studio/donation/alert/edit/[id]/page.tsx

@@ -0,0 +1,225 @@
+'use client';
+
+import { useState, useEffect, useRef } from 'react';
+import { useRouter, useParams } from 'next/navigation';
+import Link from 'next/link';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import { useAlertConfigContext } from '../../context';
+import { Separator } from '@/components/ui/separator';
+import AlertPreviewPanel from '../../_components/AlertPreviewPanel';
+import AlertFormPanel from '../../_components/AlertFormPanel';
+import { createEmptyForm } from '../../types';
+import type { FormState, PendingFiles } from '../../types';
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
+
+export default function AlertEditPage()
+{
+	const router = useRouter();
+	const { id } = useParams<{ id: string }>();
+	const numericId = parseInt(id);
+
+	const { channelID, memberID } = useStudioContext();
+	const { items, widgetToken, loading, setSaving } = useAlertConfigContext();
+
+	const [editingItem, setEditingItem] = useState<AlertConfigItem|null>(null);
+	const [form, setForm] = useState<FormState>(createEmptyForm());
+	const [formInitialized, setFormInitialized] = useState(false);
+	const [pendingFiles, setPendingFiles] = useState<PendingFiles>({ image: null, sound: null });
+	const [localSaving, setLocalSaving] = useState(false);
+	const iframeRef = useRef<HTMLIFrameElement>(null);
+	const formRef = useRef<FormState>(form);
+	formRef.current = form;
+
+	// ── items 로드 후 form 초기화 ────────────────────
+	useEffect(() => {
+		if (formInitialized || items.length === 0) {
+			return;
+		}
+
+		const found = items.find(item => item.id === numericId);
+		if (found) {
+			setEditingItem(found);
+			const { id, ...rest } = found;
+			void id;
+			setForm(rest);
+			setFormInitialized(true);
+		} else if (!loading) { // 준비 완료인데 못 찾음
+			alert('알림 설정을 찾을 수 없습니다.');
+			router.push('/studio/donation/alert/list');
+		}
+	}, [items, loading, numericId, formInitialized, router]);
+
+	// ── blob URL cleanup ─────────────────────────────
+	const cleanupBlobUrls = (f: FormState) => {
+		if (f.imageUrl?.startsWith('blob:')) {
+			URL.revokeObjectURL(f.imageUrl);
+		}
+
+		if (f.soundUrl?.startsWith('blob:')) {
+			URL.revokeObjectURL(f.soundUrl);
+		}
+	};
+
+	useEffect(() => {
+		return () => {
+			cleanupBlobUrls(formRef.current);
+		};
+	}, []);
+
+	// ── 폼 → iframe 미리보기 동기화 ─────────────────
+	useEffect(() => {
+		if (!iframeRef.current?.contentWindow) {
+			return;
+		}
+
+		iframeRef.current.contentWindow.postMessage({
+			type: 'ALERT_PREVIEW',
+			config: form,
+		}, window.location.origin);
+	}, [form]);
+
+	// ── 폼 필드 변경 ────────────────────────────────
+	const handleFormChange = <K extends keyof FormState>(field: K, value: FormState[K]) => {
+		setForm(prev => {
+			if ((field === 'imageUrl' || field === 'soundUrl') && typeof prev[field] === 'string' && (prev[field] as string).startsWith('blob:')) {
+				URL.revokeObjectURL(prev[field] as string);
+			}
+
+			return { ...prev, [field]: value };
+		});
+
+		if (field === 'imageUrl' && value === null) {
+			setPendingFiles(prev => ({ ...prev, image: null }));
+		}
+		if (field === 'soundUrl' && value === null) {
+			setPendingFiles(prev => ({ ...prev, sound: null }));
+		}
+	};
+
+	// ── 파일 업로드 헬퍼 ─────────────────────────────
+	const uploadFile = async (file: File, type: 'image'|'sound'): Promise<string> => {
+		const formData = new FormData();
+		formData.append('file', file);
+		formData.append('type', type);
+		formData.append('channelID', channelID!.toString());
+
+		const res = await fetchApi<{ url: string }>('/api/studio/donation/alert/config/upload', {
+			method: 'POST',
+			body: formData,
+		});
+		return res.data?.url ?? '';
+	};
+
+	// ── 저장 ─────────────────────────────────────────
+	const handleSave = async () => {
+		if (!channelID || !editingItem) {
+			return;
+		}
+
+		if (!form.message.trim()) {
+			alert('메시지를 입력해 주세요.');
+			return;
+		}
+		if (form.amount < 1) {
+			alert('금액은 1원 이상이어야 합니다.');
+			return;
+		}
+		if (form.displayDurationSec < 1) {
+			alert('노출 시간은 1초 이상이어야 합니다.');
+			return;
+		}
+
+		setLocalSaving(true);
+		setSaving(true);
+
+		try {
+			let finalImageUrl = form.imageUrl;
+			let finalSoundUrl = form.soundUrl;
+
+			if (pendingFiles.image) {
+				finalImageUrl = await uploadFile(pendingFiles.image, 'image');
+			}
+			if (pendingFiles.sound) {
+				finalSoundUrl = await uploadFile(pendingFiles.sound, 'sound');
+			}
+
+			const item = {
+				id: editingItem.id,
+				...form,
+				imageUrl: finalImageUrl,
+				soundUrl: finalSoundUrl,
+				popupEffect: form.popupEffect || null,
+				textEffect: form.textEffect || null,
+				nicknameFontFamily: form.nicknameFontFamily || null,
+				amountFontFamily: form.amountFontFamily || null,
+				messageFontFamily: form.messageFontFamily || null,
+			};
+
+			await fetchApi('/api/studio/donation/alert/config/batch', {
+				method: 'POST',
+				body: { channelID, memberID, items: [item], deleteIDs: [] },
+			});
+
+			cleanupBlobUrls(form);
+
+			alert('수정되었습니다.');
+		} catch (err) {
+			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setLocalSaving(false);
+			setSaving(false);
+		}
+	};
+
+	// ── 취소 ─────────────────────────────────────────
+	const handleCancel = () => {
+		cleanupBlobUrls(form);
+		router.push('/studio/donation/alert/list');
+	};
+
+	// ── 로딩 중 ──────────────────────────────────────
+	if (!formInitialized) {
+		return <div className="alert-config__loading">준비 중...</div>;
+	}
+
+	return (
+		<>
+		<div className="studio-page__title-row">
+			<h1 className="studio-page__title">후원 알림 수정</h1>
+			<Link href="/studio/donation/alert/list" className="alert-config__btn alert-config__btn--sm">< 목록으로</Link>
+		</div>
+		<div className='pt-5 pb-5'>
+			<Separator orientation="horizontal" />
+		</div>
+		<div className="alert-config__layout">
+			<AlertPreviewPanel
+				widgetToken={widgetToken}
+				iframeRef={iframeRef}
+			/>
+
+			<Separator orientation="vertical" />
+
+			<AlertFormPanel
+				form={form}
+				editingItem={editingItem}
+				saving={localSaving}
+				pendingFiles={pendingFiles}
+				onFileSelect={(file, type) => {
+					const previewUrl = URL.createObjectURL(file);
+					if (type === 'image') {
+						setPendingFiles(prev => ({ ...prev, image: file }));
+						handleFormChange('imageUrl', previewUrl);
+					} else {
+						setPendingFiles(prev => ({ ...prev, sound: file }));
+						handleFormChange('soundUrl', previewUrl);
+					}
+				}}
+				onFormChange={handleFormChange}
+				onSave={handleSave}
+				onCancel={handleCancel}
+			/>
+		</div>
+		</>
+	);
+}

+ 55 - 0
app/studio/donation/alert/layout.tsx

@@ -0,0 +1,55 @@
+'use client';
+
+import './style.scss';
+import { useState, useCallback, useEffect } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { AlertConfigResponse, AlertConfigItem } from '@/types/response/donation/alertConfig';
+import { AlertConfigContext } from './context';
+
+export default function AlertLayout({ children }: { children: React.ReactNode })
+{
+	const { channelID } = useStudioContext();
+
+	const [items, setItems] = useState<AlertConfigItem[]>([]);
+	const [widgetToken, setWidgetToken] = useState<string|null>(null);
+	const [loading, setLoading] = useState(true);
+	const [saving, setSaving] = useState(false);
+
+	const fetchList = useCallback(() => {
+		if (!channelID) {
+			setLoading(false);
+			return;
+		}
+
+		setLoading(true);
+
+		fetchApi<AlertConfigResponse>(`/api/studio/donation/alert/config/${channelID}`).then(res => {
+			setItems(res.data?.list ?? []);
+			setWidgetToken(res.data?.widgetToken ?? null);
+		}).catch(err => {
+			alert(err instanceof Error ? err.message : '불러오기 실패');
+		}).finally(() => setLoading(false));
+
+	}, [channelID]);
+
+	useEffect(() => {
+		fetchList();
+	}, [fetchList]);
+
+	if (!channelID) {
+		return (
+			<div className="studio-page">
+				<p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
+			</div>
+		);
+	}
+
+	return (
+		<AlertConfigContext.Provider value={{ items, widgetToken, loading, saving, setSaving, fetchList }}>
+			<div className="alert-config">
+				{children}
+			</div>
+		</AlertConfigContext.Provider>
+	);
+}

+ 74 - 0
app/studio/donation/alert/list/page.tsx

@@ -0,0 +1,74 @@
+'use client';
+
+import { useState } from 'react';
+import { useRouter } from 'next/navigation';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import { useAlertConfigContext } from '../context';
+import AlertListPanel from '../_components/AlertListPanel';
+import { DEFAULT_PER_PAGE } from '@/constants/donation';
+
+export default function AlertListPage()
+{
+	const router = useRouter();
+	const { channelID, memberID } = useStudioContext();
+	const { items, loading, saving, setSaving, fetchList } = useAlertConfigContext();
+
+	const [checkedIDs, setCheckedIDs] = useState<Set<number>>(new Set());
+	const [page, setPage] = useState(1);
+	const [perPage, setPerPage] = useState(DEFAULT_PER_PAGE);
+
+	const handleBatchDelete = async () => {
+		if (!channelID || checkedIDs.size === 0) {
+			return;
+		}
+		if (!confirm(`선택한 ${checkedIDs.size}개의 알림을 삭제하시겠습니까?`)) {
+			return;
+		}
+
+		setSaving(true);
+
+		try {
+
+			await fetchApi('/api/studio/donation/alert/config/batch', {
+				method: 'POST',
+				body: {
+					channelID,
+					memberID,
+					items: [],
+					deleteIDs: [...checkedIDs]
+				}
+			});
+
+			setCheckedIDs(new Set());
+			fetchList();
+		} catch (err) {
+			alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	return (
+		<>
+			<h1 className="studio-page__title">후원 알림</h1>
+			<div>
+				<br/>
+			</div>
+			<AlertListPanel
+				items={items}
+				loading={loading}
+				saving={saving}
+				checkedIDs={checkedIDs}
+				setCheckedIDs={setCheckedIDs}
+				page={page}
+				setPage={setPage}
+				perPage={perPage}
+				setPerPage={setPerPage}
+				onNew={() => router.push('/studio/donation/alert/add')}
+				onEdit={(item) => router.push(`/studio/donation/alert/edit/${item.id}`)}
+				onBatchDelete={handleBatchDelete}
+			/>
+		</>
+	);
+}

+ 5 - 0
app/studio/donation/alert/page.tsx

@@ -0,0 +1,5 @@
+import { redirect } from 'next/navigation';
+
+export default function AlertPage() {
+	redirect('/studio/donation/alert/list');
+}

+ 698 - 0
app/studio/donation/alert/style.scss

@@ -0,0 +1,698 @@
+.alert-config {
+	display: flex;
+	flex-direction: column;
+
+	// ── 목록 패널 ────────────────────────────────────
+	&__list-panel {
+		display: flex;
+		flex-direction: column;
+		gap: 0;
+	}
+
+	// ── 30/70 레이아웃 ────────────────────────────────
+	&__layout {
+		display: grid;
+		grid-template-columns: minmax(0, 500px) auto 1fr;
+		gap: 24px;
+		align-items: start;
+	}
+
+	// ── 좌측 패널 (미리보기) ─────────────────────────
+	&__preview-panel {
+		position: sticky;
+		top: 16px;
+		display: flex;
+		flex-direction: column;
+		gap: 16px;
+		max-height: calc(100vh - 48px);
+		overflow-y: auto;
+	}
+
+	// ── 우측 패널 (폼) ───────────────────────────────
+	&__form-panel {
+		display: flex;
+		flex-direction: column;
+		gap: 0;
+	}
+
+	// ── 섹션 (제목 좌 / 콘텐츠 우) ──────────────────
+	&__section {
+		display: grid;
+		grid-template-columns: 140px 1fr;
+		gap: 24px;
+		padding: 24px 0;
+		border-bottom: 1px solid hsl(var(--border));
+
+		&:first-of-type {
+			padding-top: 0;
+		}
+	}
+
+	&__section-title {
+		font-size: 0.875rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+		padding-top: 2px;
+		margin: 0;
+	}
+
+	&__section-body {
+		display: flex;
+		flex-direction: column;
+		gap: 17px;
+		max-width: 520px;
+	}
+
+	// ── fieldset (미리보기 패널용) ────────────────────
+	&__fieldset {
+		border: 1px solid hsl(var(--border));
+		border-radius: var(--radius);
+		padding: 16px 20px 20px;
+		margin: 0;
+
+		& + & {
+			margin-top: 0;
+		}
+	}
+
+	&__legend {
+		font-size: 1rem;
+		font-weight: 600;
+		color: hsl(var(--foreground));
+		padding: 0 8px;
+		margin-left: -4px;
+	}
+
+	// ── 미리보기 테스트 폼 ───────────────────────────
+	&__preview-form {
+		display: flex;
+		flex-direction: column;
+		gap: 12px;
+	}
+
+	&__preview-btn {
+		margin-top: 12px;
+		width: 100%;
+		justify-content: center;
+	}
+
+	// ── 위젯 미리보기 ────────────────────────────────
+	&__widget {
+		border: 1px solid hsl(var(--border));
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&__widget-label {
+		padding: 8px 14px;
+		font-size: 0.8125rem;
+		font-weight: 500;
+		color: hsl(var(--muted));
+		background:#0e0e1a;
+	}
+
+	&__widget-frame {
+		width: 100%;
+		height: clamp(200px, 30vw, 450px);
+		border: none;
+		background: transparent;
+	}
+
+	&__widget-empty {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		height: 200px;
+		color: hsl(var(--muted-foreground));
+		font-size: 0.875rem;
+	}
+
+	// ── 툴바 ─────────────────────────────────────────
+	&__toolbar {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: 8px;
+		margin-bottom: 12px;
+		flex-wrap: wrap;
+	}
+
+	&__toolbar-left {
+		display: flex;
+		align-items: baseline;
+		gap: 6px;
+	}
+
+	&__toolbar-right {
+		display: flex;
+		align-items: center;
+		gap: 6px;
+		flex-wrap: wrap;
+	}
+
+	&__count {
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__per-page {
+		padding: 9px 10px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		cursor: pointer;
+	}
+
+	// ── 버튼 ─────────────────────────────────────────
+	&__btn {
+		display: inline-flex;
+		align-items: center;
+		gap: 6px;
+		padding: 8px 16px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		font-weight: 500;
+		cursor: pointer;
+		white-space: nowrap;
+		transition: background-color 0.15s, color 0.15s;
+		justify-content: center;
+
+		&:not([class*="--"]):hover:not(:disabled) {
+			background: hsl(var(--accent));
+			color: hsl(var(--accent-foreground));
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+
+		&--sm {
+			padding: 4px 10px;
+			font-size: 0.75rem;
+		}
+
+		&--primary {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+
+			&:hover:not(:disabled) {
+				background: hsl(var(--primary) / 0.9);
+				color: hsl(var(--primary-foreground));
+			}
+		}
+
+		&--danger {
+			color: hsl(var(--destructive));
+			border-color: hsl(var(--destructive) / 0.5);
+
+			&:hover:not(:disabled) {
+				background: hsl(var(--destructive) / 0.1);
+			}
+		}
+	}
+
+	// ── 테이블 ───────────────────────────────────────
+	&__table-wrap {
+		border: 1px solid hsl(var(--border));
+		border-radius: var(--radius);
+		overflow: hidden;
+	}
+
+	&__table {
+		width: 100%;
+		border-collapse: collapse;
+		font-size: 0.8125rem;
+
+		th, td {
+			padding: 10px 12px;
+			text-align: left;
+			border-bottom: 1px solid hsl(var(--border));
+		}
+
+		th {
+			font-weight: 500;
+			color: hsl(var(--muted-foreground));
+			font-size: 0.75rem;
+			background: hsl(var(--muted) / 0.4);
+		}
+
+		tbody tr:last-child td {
+			border-bottom: none;
+		}
+
+		tbody tr {
+			transition: background 0.15s;
+		}
+
+		tbody tr:hover {
+			background: hsl(var(--muted) / 0.5);
+		}
+	}
+
+	&__th--check,
+	&__td--check {
+		width: 40px;
+		text-align: center;
+	}
+
+	&__td--message {
+		max-width: 200px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__td--media {
+		position: relative;
+
+		> div {
+			display: flex;
+			align-items: center;
+			gap: 4px;
+		}
+	}
+
+	&__media-btn {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 28px;
+		height: 28px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--muted-foreground));
+		font-size: 0.75rem;
+		cursor: pointer;
+		transition: color 0.15s, border-color 0.15s;
+
+		&:hover {
+			color: hsl(var(--foreground));
+			border-color: hsl(var(--foreground));
+		}
+
+		&--image:hover {
+			color: hsl(var(--primary));
+			border-color: hsl(var(--primary));
+		}
+
+		&--sound:hover {
+			color: hsl(var(--primary));
+			border-color: hsl(var(--primary));
+		}
+
+		&--playing {
+			color: hsl(var(--primary));
+			border-color: hsl(var(--primary));
+			background: hsl(var(--primary) / 0.1);
+		}
+	}
+
+	&__row--checked {
+		opacity: 0.5;
+
+		td {
+			text-decoration: line-through;
+		}
+	}
+
+	&__row--editing {
+		background: hsl(var(--primary) / 0.08);
+	}
+
+	// 뱃지
+	&__match-badge {
+		display: inline-flex;
+		align-items: center;
+		padding: 2px 8px;
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.6875rem;
+		font-weight: 500;
+		white-space: nowrap;
+
+		&--min {
+			background: hsl(var(--primary) / 0.1);
+			color: hsl(var(--primary));
+		}
+
+		&--exact {
+			background: hsl(217 91% 60% / 0.1);
+			color: hsl(217 91% 60%);
+		}
+	}
+
+	&__status-badge {
+		display: inline-flex;
+		align-items: center;
+		padding: 2px 8px;
+		border-radius: calc(var(--radius) - 2px);
+		font-size: 0.6875rem;
+		font-weight: 500;
+		white-space: nowrap;
+
+		&--active {
+			background: hsl(142 71% 45% / 0.1);
+			color: hsl(142 71% 45%);
+		}
+
+		&--inactive {
+			background: hsl(var(--muted));
+			color: hsl(var(--muted-foreground));
+		}
+	}
+
+	&__empty {
+		padding: 48px 16px;
+		text-align: center;
+		color: hsl(var(--muted-foreground));
+		font-size: 0.875rem;
+	}
+
+	// ── 페이징 ───────────────────────────────────────
+	&__pagination {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		gap: 4px;
+		margin-top: 12px;
+	}
+
+	&__page-btn {
+		min-width: 28px;
+		height: 28px;
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.75rem;
+		cursor: pointer;
+		transition: background-color 0.15s;
+
+		&:hover:not(:disabled) {
+			background: hsl(var(--accent));
+		}
+
+		&:disabled {
+			opacity: 0.5;
+			cursor: not-allowed;
+		}
+
+		&--active {
+			background: hsl(var(--primary));
+			color: hsl(var(--primary-foreground));
+			border-color: hsl(var(--primary));
+		}
+	}
+
+	// ── 폼 필드 ──────────────────────────────────────
+	&__field {
+		display: flex;
+		flex-direction: column;
+		gap: 6px;
+	}
+
+	&__field-label {
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: hsl(var(--foreground));
+	}
+
+	&__field-hint {
+		font-size: 0.8125rem;
+		color: hsl(var(--muted-foreground));
+	}
+
+	&__field-row {
+		display: grid;
+		grid-template-columns: 1fr 1fr;
+		gap: 10px;
+	}
+
+	&__input {
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		width: 100%;
+
+		&::placeholder {
+			color: hsl(var(--muted-foreground));
+		}
+	}
+
+	&__select {
+		padding: 8px 12px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		color: hsl(var(--foreground));
+		font-size: 0.875rem;
+		width: 100%;
+		cursor: pointer;
+	}
+
+	&__radio-group {
+		display: flex;
+		gap: 16px;
+		height: 100%;
+		justify-content: center;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+	}
+
+	&__radio-label {
+		display: inline-flex;
+		align-items: center;
+		gap: 6px;
+		font-size: 0.875rem;
+		color: hsl(var(--foreground));
+		cursor: pointer;
+	}
+
+	&__checkbox-label {
+		display: inline-flex;
+		align-items: center;
+		gap: 8px;
+		font-size: 0.875rem;
+		color: hsl(var(--foreground));
+		cursor: pointer;
+	}
+
+	// ── 폰트 그룹 ────────────────────────────────────
+	&__font-group {
+		&:not(:first-child) {
+			margin-top: 12px;
+			padding-top: 12px;
+			border-top: 1px solid hsl(var(--border));
+		}
+	}
+
+	&__font-group-title {
+		font-size: 0.875rem;
+		font-weight: 500;
+		color: hsl(var(--muted-foreground));
+		margin-bottom: 10px;
+	}
+
+	&__font-grid {
+		display: grid;
+		grid-template-columns: 1fr 80px 1fr;
+		gap: 12px;
+		align-items: start;
+	}
+
+	// ── 색상 입력 ─────────────────────────────────────
+	&__color-wrap {
+		display: flex;
+		gap: 8px;
+		align-items: center;
+	}
+
+	&__color-input {
+		width: 36px;
+		height: 36px;
+		padding: 2px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--background));
+		cursor: pointer;
+		flex-shrink: 0;
+
+		&::-webkit-color-swatch-wrapper {
+			padding: 0;
+		}
+
+		&::-webkit-color-swatch {
+			border: none;
+			border-radius: calc(var(--radius) - 4px);
+		}
+	}
+
+	&__color-text {
+		flex: 1;
+		min-width: 0;
+	}
+
+	// ── 미디어 업로드 ─────────────────────────────────
+	&__media-upload {
+		display: flex;
+		flex-direction: column;
+		gap: 8px;
+		margin-bottom: 16px;
+
+		&:last-of-type {
+			margin-bottom: 0;
+		}
+	}
+
+	&__media-preview {
+		display: flex;
+		align-items: start;
+		gap: 8px;
+		padding: 8px;
+		border: 1px solid hsl(var(--border));
+		border-radius: calc(var(--radius) - 2px);
+		background: hsl(var(--muted) / 0.3);
+
+		img {
+			max-width: 120px;
+			max-height: 80px;
+			object-fit: contain;
+			border-radius: calc(var(--radius) - 4px);
+		}
+
+		audio {
+			max-width: 280px;
+			height: 36px;
+		}
+
+		&--audio {
+			flex-direction: row;
+		}
+	}
+
+	&__media-remove {
+		display: inline-flex;
+		align-items: center;
+		justify-content: center;
+		width: 28px;
+		height: 28px;
+		border: 1px solid hsl(var(--destructive) / 0.5);
+		border-radius: calc(var(--radius) - 2px);
+		background: none;
+		color: hsl(var(--destructive));
+		font-size: 0.75rem;
+		cursor: pointer;
+		flex-shrink: 0;
+		margin-left: auto;
+		transition: background 0.15s;
+
+		&:hover {
+			background: hsl(var(--destructive) / 0.1);
+		}
+	}
+
+	&__file-hidden {
+		display: none;
+	}
+
+	// ── 폼 하단 ──────────────────────────────────────
+	&__form-footer {
+		display: flex;
+		justify-content: center;
+		gap: 8px;
+		padding-top: 20px;
+		margin-top: 4px;
+	}
+}
+
+// ── 반응형 ────────────────────────────────────────────
+@media (max-width: 1380px) {
+	.alert-config {
+		&__layout {
+			grid-template-columns: minmax(0, 500px) auto 1fr;
+			gap: 24px;
+			align-items: start;
+		}
+
+		&__section {
+			grid-template-columns: 1fr;
+			gap: 5px;
+		}
+
+		&__section-title {
+			margin-bottom: 9px;
+		}
+	}
+}
+
+@media (max-width: 1120px) {
+	.alert-config {
+		&__layout {
+			grid-template-columns: 2fr auto 3fr;
+		}
+	}
+}
+
+@media (max-width: 768px) {
+	.alert-config {
+		&__layout {
+			grid-template-columns: 1fr;
+		}
+
+		&__preview-panel {
+			position: static;
+			max-height: none;
+		}
+
+		&__section {
+			grid-template-columns: 1fr;
+			gap: 8px;
+		}
+
+		&__section-title {
+			margin-bottom: 12px;
+		}
+
+		&__section-body {
+			max-width: none;
+		}
+
+		&__field-row {
+			grid-template-columns: 1fr;
+		}
+
+		&__font-grid {
+			grid-template-columns: 1fr;
+		}
+
+		&__media-upload {
+			padding-left: 0;
+		}
+
+		&__toolbar {
+			flex-direction: column;
+			align-items: flex-start;
+		}
+
+		&__table-wrap {
+			overflow-x: auto;
+		}
+
+		&__table {
+			min-width: 892px;
+		}
+	}
+}

+ 11 - 0
app/studio/donation/alert/types.ts

@@ -0,0 +1,11 @@
+import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
+import { ALERT_DEFAULTS } from './constants';
+
+export type FormState = Omit<AlertConfigItem, 'id'>;
+
+export type PendingFiles = {
+	image: File|null;
+	sound: File|null;
+};
+
+export const createEmptyForm = (): FormState => ({ ...ALERT_DEFAULTS, message: '{이름}님이 {금액}원을 후원했습니다!' });

+ 190 - 0
app/studio/donation/crew/page.tsx

@@ -0,0 +1,190 @@
+'use client';
+
+import './style.scss';
+import { useState, useEffect, useCallback } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { useStudioContext } from '@/app/studio/context';
+import type { CrewListResponse, CrewItem } from '@/types/response/crew/list';
+
+const EMPTY_FORM = {
+	name: '',
+	description: '',
+	minAmount: '' as string|number,
+	isActive: true
+};
+
+export default function CrewConfigPage() {
+	const { channelID } = useStudioContext();
+	const [items, setItems] = useState<CrewItem[]>([]);
+	const [loading, setLoading] = useState(true);
+	const [error, setError] = useState('');
+	const [modal, setModal] = useState<{ open: boolean; editing: CrewItem|null }>({ open: false, editing: null });
+	const [form, setForm] = useState(EMPTY_FORM);
+	const [saving, setSaving] = useState(false);
+
+	useEffect(() => {
+		if (error) { alert(error); setError(''); }
+	}, [error]);
+
+	const fetchList = useCallback(() => {
+		if (!channelID) {
+			setLoading(false);
+			return;
+		}
+
+		setLoading(true);
+
+		fetchApi<CrewListResponse>(`/api/studio/crew/list/${channelID}`).then(res => {
+			setItems(res.data?.list ?? []);
+		})
+		.catch(err => setError(err.message))
+		.finally(() => setLoading(false));
+
+	}, [channelID]);
+
+	useEffect(() => { fetchList(); }, [fetchList]);
+
+	const openAdd = () => {
+		setForm(EMPTY_FORM);
+		setModal({ open: true, editing: null });
+	};
+
+	const openEdit = (item: CrewItem) => {
+		setForm({
+			name: item.name,
+			description: item.description ?? '',
+			minAmount: item.minAmount ?? '',
+			isActive: item.isActive
+		});
+		setModal({ open: true, editing: item });
+	};
+
+	const closeModal = () => setModal({ open: false, editing: null });
+
+	const handleSave = async () => {
+		if (!form.name.trim()) {
+			alert('크루명을 입력해 주세요.');
+			return;
+		}
+
+		setSaving(true);
+
+		try {
+			const body = {
+				channelID,
+				id: modal.editing?.id ?? undefined,
+				name: form.name,
+				description: form.description || undefined,
+				minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
+				isActive: form.isActive
+			};
+
+			const res = await fetchApi('/api/studio/crew/save', {
+				method: 'POST',
+				headers: { 'Content-Type': 'application/json' },
+				body: JSON.stringify(body)
+			});
+
+			closeModal();
+			fetchList();
+		} catch (err: unknown) {
+			setError(err instanceof Error ? err.message : '저장에 실패했습니다.');
+		} finally {
+			setSaving(false);
+		}
+	};
+
+	return (
+		<div className="studio-page">
+			<div className="studio-page__header">
+				<h1 className="studio-page__title">크루 후원 설정</h1>
+				<button type="button" className="studio-page__btn studio-page__btn--primary" onClick={openAdd}>
+					+ 크루 추가
+				</button>
+			</div>
+
+			<div className="studio-page__table-wrap">
+				{loading ? (
+					<p className="studio-page__empty">준비 중...</p>
+				) : (
+					<table className="studio-page__table">
+						<thead>
+							<tr>
+								<th>크루명</th>
+								<th>설명</th>
+								<th>최소 후원금</th>
+								<th>멤버 수</th>
+								<th>활성</th>
+								<th>작업</th>
+							</tr>
+						</thead>
+						<tbody>
+							{items.length === 0 ? (
+								<tr>
+									<td colSpan={6} className="studio-page__empty">
+										등록된 크루가 없습니다.
+									</td>
+								</tr>
+							) : (
+								items.map(item => (
+									<tr key={item.id}>
+										<td className="crew-config__name">{item.name}</td>
+										<td className="crew-config__desc">{item.description ?? '-'}</td>
+										<td>{item.minAmount ? `${item.minAmount.toLocaleString()}원` : '-'}</td>
+										<td>{item.memberCount}명</td>
+										<td>
+											<span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>
+												{item.isActive ? '활성' : '비활성'}
+											</span>
+										</td>
+										<td>
+											<div className="studio-page__actions">
+												<button type="button" className="studio-page__btn" onClick={() => openEdit(item)}>수정</button>
+											</div>
+										</td>
+									</tr>
+								))
+							)}
+						</tbody>
+					</table>
+				)}
+			</div>
+
+			{modal.open && (
+				<div className="studio-modal">
+					<div className="studio-modal__overlay" onClick={closeModal} />
+					<div className="studio-modal__box">
+						<h2 className="studio-modal__title">{modal.editing ? '크루 수정' : '크루 추가'}</h2>
+
+						<div className="studio-modal__field">
+							<label>크루명 *</label>
+							<input type="text" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} title='크루명' />
+						</div>
+						<div className="studio-modal__field">
+							<label>설명</label>
+							<input type="text" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} title='설명' />
+						</div>
+						<div className="studio-modal__field">
+							<label>최소 후원금 (원)</label>
+							<input type="number" min={0} placeholder="미설정" value={form.minAmount} onChange={e => setForm(f => ({ ...f, minAmount: e.target.value }))} />
+							<p className="studio-modal__hint">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
+						</div>
+						<div className="studio-modal__field">
+							<label className="studio-modal__checkbox-label">
+								<input type="checkbox" checked={form.isActive} onChange={e => setForm(f => ({ ...f, isActive: e.target.checked }))} />
+								활성화
+							</label>
+						</div>
+
+						<div className="studio-modal__footer">
+							<button type="button" className="studio-page__btn" onClick={closeModal}>취소</button>
+							<button type="button" className="studio-page__btn studio-page__btn--primary" onClick={handleSave} disabled={saving}>
+								{saving ? '저장 중...' : '저장'}
+							</button>
+						</div>
+					</div>
+				</div>
+			)}
+		</div>
+	);
+}

+ 13 - 0
app/studio/donation/crew/style.scss

@@ -0,0 +1,13 @@
+.crew-config {
+	&__name {
+		font-weight: 500;
+	}
+
+	&__desc {
+		max-width: 180px;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+		color: var(--muted-foreground);
+	}
+}

+ 274 - 0
app/studio/donation/goal/_components/GoalFormPanel.tsx

@@ -0,0 +1,274 @@
+'use client';
+
+import type { GoalConfigItem } from '@/types/response/donation/goalConfig';
+import { Checkbox } from '@/components/ui/checkbox';
+import { FONT_OPTIONS } from '../../alert/constants';
+import { GOAL_STYLES } from '../types';
+import type { FormState } from '../types';
+import {
+	GOAL_BAR_HEIGHT_MIN, GOAL_BAR_HEIGHT_MAX,
+	FONT_SIZE_MIN, FONT_SIZE_MAX, COLOR_HEX_MAX_LENGTH
+} from '@/constants/donation';
+
+type Props = {
+	form: FormState;
+	editingItem: GoalConfigItem|null;
+	saving: boolean;
+	onFormChange: <K extends keyof FormState>(field: K, value: FormState[K]) => void;
+	onSave: () => void;
+	onCancel: () => void;
+};
+
+export default function GoalFormPanel({
+	form,
+	editingItem,
+	saving,
+	onFormChange,
+	onSave,
+	onCancel
+}: Props) {
+	// ── 색상 그룹 렌더러 ─────────────────────────────
+	const renderColorField = (
+		label: string,
+		id: string,
+		colorField: 'barColor'|'barBackgroundColor'|'titleFontColor'|'amountFontColor',
+	) => (
+		<div className="goal-config__field">
+			<label htmlFor={id} className="goal-config__field-label">{label}</label>
+			<div className="goal-config__color-wrap">
+				<input
+					id={id}
+					type="color"
+					className="goal-config__color-input"
+					value={form[colorField]}
+					onChange={e => onFormChange(colorField, e.target.value)}
+				/>
+				<input
+					type="text"
+					className="goal-config__input goal-config__color-text"
+					value={form[colorField]}
+					onChange={e => onFormChange(colorField, e.target.value)}
+					maxLength={COLOR_HEX_MAX_LENGTH}
+					title={label}
+				/>
+			</div>
+		</div>
+	);
+
+	// ── 폰트 그룹 렌더러 ─────────────────────────────
+	const renderFontGroup = (
+		label: string,
+		prefix: string,
+		familyField: 'titleFontFamily'|'amountFontFamily',
+		sizeField: 'titleFontSizePx'|'amountFontSizePx',
+		colorField: 'titleFontColor'|'amountFontColor',
+		defaultSize: number
+	) => (
+		<div className="goal-config__font-group">
+			<div className="goal-config__font-group-title">{label}</div>
+			<div className="goal-config__font-grid">
+				<div className="goal-config__field">
+					<label htmlFor={`${prefix}-family`} className="goal-config__field-label">글꼴</label>
+					<select
+						id={`${prefix}-family`}
+						className="goal-config__select"
+						value={form[familyField] ?? ''}
+						onChange={e => onFormChange(familyField, e.target.value || null)}
+					>
+						{FONT_OPTIONS.map(f => (
+							<option key={f.value} value={f.value}>{f.label}</option>
+						))}
+					</select>
+				</div>
+				<div className="goal-config__field">
+					<label htmlFor={`${prefix}-size`} className="goal-config__field-label">크기 (px)</label>
+					<input
+						id={`${prefix}-size`}
+						type="number"
+						className="goal-config__input"
+						min={FONT_SIZE_MIN}
+						max={FONT_SIZE_MAX}
+						value={form[sizeField]}
+						onChange={e => onFormChange(sizeField, parseInt(e.target.value) || defaultSize)}
+						title="Font Size"
+					/>
+				</div>
+				<div className="goal-config__field">
+					<label htmlFor={`${prefix}-color`} className="goal-config__field-label">색상</label>
+					<div className="goal-config__color-wrap">
+						<input
+							id={`${prefix}-color`}
+							type="color"
+							className="goal-config__color-input"
+							value={form[colorField]}
+							onChange={e => onFormChange(colorField, e.target.value)}
+						/>
+						<input
+							type="text"
+							className="goal-config__input goal-config__color-text"
+							value={form[colorField]}
+							onChange={e => onFormChange(colorField, e.target.value)}
+							maxLength={COLOR_HEX_MAX_LENGTH}
+							title="Font Color"
+						/>
+					</div>
+				</div>
+			</div>
+		</div>
+	);
+
+	return (
+		<main className="goal-config__form-panel">
+			{/* ── 기본 설정 ────────────────────────────── */}
+			<section className="goal-config__section">
+				<h3 className="goal-config__section-title">기본 설정</h3>
+				<div className="goal-config__section-body">
+					<div className="goal-config__field">
+						<label htmlFor="goal-title" className="goal-config__field-label">제목</label>
+						<input
+							id="goal-title"
+							type="text"
+							className="goal-config__input"
+							value={form.title}
+							onChange={e => onFormChange('title', e.target.value)}
+							placeholder="후원 목표"
+						/>
+					</div>
+
+					<div className="goal-config__field">
+						<label htmlFor="goal-style" className="goal-config__field-label">스타일</label>
+						<select
+							id="goal-style"
+							className="goal-config__select"
+							value={form.style}
+							onChange={e => onFormChange('style', parseInt(e.target.value))}
+						>
+							{GOAL_STYLES.map(s => (
+								<option key={s.value} value={s.value}>{s.label}</option>
+							))}
+						</select>
+					</div>
+
+					<div className="goal-config__field-row">
+						<div className="goal-config__field">
+							<label htmlFor="goal-startAmount" className="goal-config__field-label">시작금액 (원)</label>
+							<input
+								id="goal-startAmount"
+								type="number"
+								className="goal-config__input"
+								min={0}
+								value={form.startAmount}
+								onChange={e => onFormChange('startAmount', parseInt(e.target.value) || 0)}
+							/>
+						</div>
+						<div className="goal-config__field">
+							<label htmlFor="goal-targetAmount" className="goal-config__field-label">목표금액 (원)</label>
+							<input
+								id="goal-targetAmount"
+								type="number"
+								className="goal-config__input"
+								min={1}
+								value={form.targetAmount}
+								onChange={e => onFormChange('targetAmount', parseInt(e.target.value) || 0)}
+							/>
+						</div>
+					</div>
+
+					<div className="goal-config__field-row">
+						<div className="goal-config__field">
+							<label htmlFor="goal-startAt" className="goal-config__field-label">시작일시</label>
+							<input
+								id="goal-startAt"
+								type="text"
+								className="goal-config__input"
+								value={form.startAt ?? ''}
+								onChange={e => onFormChange('startAt', e.target.value)}
+								placeholder="2026.03.30 14:00"
+								maxLength={16}
+							/>
+						</div>
+						<div className="goal-config__field">
+							<label htmlFor="goal-endAt" className="goal-config__field-label">종료일시</label>
+							<input
+								id="goal-endAt"
+								type="text"
+								className="goal-config__input"
+								value={form.endAt ?? ''}
+								onChange={e => onFormChange('endAt', e.target.value)}
+								placeholder="2026.03.31 14:00"
+								maxLength={16}
+							/>
+						</div>
+					</div>
+
+					<div className="goal-config__field">
+						<label className="goal-config__checkbox-label">
+							<Checkbox
+								checked={form.isShowPercent}
+								onCheckedChange={v => onFormChange('isShowPercent', !!v)}
+							/>
+							퍼센트 표시
+						</label>
+					</div>
+
+					<div className="goal-config__field">
+						<label className="goal-config__checkbox-label">
+							<Checkbox
+								checked={form.isActive}
+								onCheckedChange={v => onFormChange('isActive', !!v)}
+							/>
+							활성화
+						</label>
+					</div>
+				</div>
+			</section>
+
+			{/* ── 바 설정 ─────────────────────────────── */}
+			<section className="goal-config__section">
+				<h3 className="goal-config__section-title">바 설정</h3>
+				<div className="goal-config__section-body">
+					<div className="goal-config__field-row">
+						{renderColorField('바 색상', 'goal-barColor', 'barColor')}
+						{renderColorField('바 배경색', 'goal-barBgColor', 'barBackgroundColor')}
+					</div>
+					<div className="goal-config__field">
+						<label htmlFor="goal-barHeight" className="goal-config__field-label">바 높이 (px)</label>
+						<input
+							id="goal-barHeight"
+							type="number"
+							className="goal-config__input"
+							min={GOAL_BAR_HEIGHT_MIN}
+							max={GOAL_BAR_HEIGHT_MAX}
+							value={form.barHeightPx}
+							onChange={e => onFormChange('barHeightPx', parseInt(e.target.value) || 24)}
+						/>
+					</div>
+				</div>
+			</section>
+
+			{/* ── 폰트 ────────────────────────────────── */}
+			<section className="goal-config__section">
+				<h3 className="goal-config__section-title">폰트</h3>
+				<div className="goal-config__section-body">
+					{renderFontGroup('제목', 'font-title', 'titleFontFamily', 'titleFontSizePx', 'titleFontColor', 16)}
+					{renderFontGroup('현황', 'font-amount', 'amountFontFamily', 'amountFontSizePx', 'amountFontColor', 14)}
+				</div>
+			</section>
+
+			{/* ── 하단 버튼 ────────────────────────────── */}
+			<div className="goal-config__form-footer flex-1 w-full sm:justify-end gap-2">
+				<button type="button" className="goal-config__btn flex-1 sm:flex-none" onClick={onCancel}>
+					취소
+				</button>
+				<button
+					type="button"
+					className="goal-config__btn goal-config__btn--primary flex-1 sm:flex-none"
+					onClick={onSave}
+					disabled={saving}
+				>
+					{saving ? '저장 중...' : (editingItem ? '수정하기' : '등록하기')}
+				</button>
+			</div>
+		</main>
+	);
+}

+ 229 - 0
app/studio/donation/goal/_components/GoalListPanel.tsx

@@ -0,0 +1,229 @@
+'use client';
+
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { faPlus } from '@fortawesome/free-solid-svg-icons';
+import { Checkbox } from '@/components/ui/checkbox';
+import type { GoalConfigItem } from '@/types/response/donation/goalConfig';
+import { PER_PAGE_OPTIONS } from '@/constants/donation';
+import { GOAL_STYLES, formatDateTime } from '../types';
+
+type Props = {
+	items: GoalConfigItem[];
+	loading: boolean;
+	saving: boolean;
+	checkedIDs: Set<number>;
+	setCheckedIDs: React.Dispatch<React.SetStateAction<Set<number>>>;
+	page: number;
+	setPage: React.Dispatch<React.SetStateAction<number>>;
+	perPage: number;
+	setPerPage: React.Dispatch<React.SetStateAction<number>>;
+	onNew: () => void;
+	onEdit: (item: GoalConfigItem) => void;
+	onBatchDelete: () => void;
+};
+
+export default function GoalListPanel({
+	items,
+	loading,
+	saving,
+	checkedIDs,
+	setCheckedIDs,
+	page,
+	setPage,
+	perPage,
+	setPerPage,
+	onNew,
+	onEdit,
+	onBatchDelete
+}: Props) {
+	// ── 페이징 ───────────────────────────────────────
+	const totalPages = Math.max(1, Math.ceil(items.length / perPage));
+	const pagedItems = items.slice((page - 1) * perPage, page * perPage);
+
+	const handlePerPageChange = (value: number) => {
+		setPerPage(value);
+		setPage(1);
+	};
+
+	// ── 전체선택 ─────────────────────────────────────
+	const visibleIDs = pagedItems.map(i => i.id);
+	const checkedCount = visibleIDs.filter(id => checkedIDs.has(id)).length;
+	const allChecked = pagedItems.length > 0 && checkedCount === visibleIDs.length;
+	const isIndeterminate = checkedCount > 0 && checkedCount < visibleIDs.length;
+
+	const handleSelectAll = () => {
+		setCheckedIDs(prev => {
+			const next = new Set(prev);
+			if (allChecked) {
+				visibleIDs.forEach(id => next.delete(id));
+			} else {
+				visibleIDs.forEach(id => next.add(id));
+			}
+			return next;
+		});
+	};
+
+	const handleToggleCheck = (id: number) => {
+		setCheckedIDs(prev => {
+			const next = new Set(prev);
+			if (next.has(id)) {
+				next.delete(id);
+			} else {
+				next.add(id);
+			}
+			return next;
+		});
+	};
+
+	return (
+		<div className="goal-config__list-panel">
+			<div className="goal-config__toolbar">
+				<div className="goal-config__toolbar-left">
+					<span className="goal-config__count">총 {items.length}개</span>
+					{checkedIDs.size > 0 && (
+						<span className="goal-config__count">({checkedIDs.size}개 선택)</span>
+					)}
+				</div>
+				<div className="goal-config__toolbar-right">
+					<select
+						value={perPage}
+						onChange={e => handlePerPageChange(Number(e.target.value))}
+						className="goal-config__per-page"
+						title="보여질 개수"
+					>
+						{PER_PAGE_OPTIONS.map(n => (
+							<option key={n} value={n}>{n}개씩</option>
+						))}
+					</select>
+					<button type="button" className="goal-config__btn" onClick={onNew}>
+						<FontAwesomeIcon icon={faPlus} />
+						추가
+					</button>
+					<button
+						type="button"
+						className="goal-config__btn goal-config__btn--danger"
+						onClick={onBatchDelete}
+						disabled={checkedIDs.size === 0 || saving}
+					>
+						삭제
+					</button>
+				</div>
+			</div>
+
+			<div className="goal-config__table-wrap">
+				{loading ? (
+					<div className="goal-config__empty">준비 중...</div>
+				) : items.length === 0 ? (
+					<div className="goal-config__empty">등록된 목표 설정이 없습니다.</div>
+				) : (
+					<table className="goal-config__table">
+						<thead>
+							<tr>
+								<th className="goal-config__th--check">
+									<Checkbox
+										checked={isIndeterminate ? 'indeterminate' : allChecked}
+										onCheckedChange={handleSelectAll}
+										aria-label="전체선택"
+									/>
+								</th>
+								<th>제목</th>
+								<th>현황</th>
+								<th>시작금액</th>
+								<th>목표금액</th>
+								<th>기간</th>
+								<th>스타일</th>
+								<th>활성</th>
+								<th>비고</th>
+							</tr>
+						</thead>
+						<tbody>
+							{pagedItems.map(item => {
+								const isChecked = checkedIDs.has(item.id);
+								const percent = item.targetAmount > 0 ? Math.min((item.startAmount / item.targetAmount) * 100, 100) : 0;
+								return (
+									<tr
+										key={item.id}
+										className={isChecked ? 'goal-config__row--checked' : ''}
+									>
+										<td className="goal-config__td--check">
+											<Checkbox
+												checked={isChecked}
+												onCheckedChange={() => handleToggleCheck(item.id)}
+												aria-label={`${item.id} 선택`}
+											/>
+										</td>
+										<td>{item.title}</td>
+										<td className="goal-config__td--bar">
+											<div className="goal-config__mini-bar" style={{ background: item.barBackgroundColor }}>
+												<div
+													className="goal-config__mini-bar-fill"
+													style={{ width: `${percent}%`, background: item.barColor }}
+												/>
+												<span className="goal-config__mini-bar-text">
+													{item.startAmount.toLocaleString()} / {item.targetAmount.toLocaleString()}
+													{item.isShowPercent && ` (${Math.round(percent)}%)`}
+												</span>
+											</div>
+										</td>
+										<td>{item.startAmount.toLocaleString()}원</td>
+										<td>{item.targetAmount.toLocaleString()}원</td>
+										<td className="goal-config__td--date">
+											{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}
+										</td>
+										<td>{GOAL_STYLES.find(s => s.value === item.style)?.label ?? item.style}</td>
+										<td>
+											<span className={`goal-config__status-badge goal-config__status-badge--${item.isActive ? 'active' : 'inactive'}`}>
+												{item.isActive ? '활성' : '비활성'}
+											</span>
+										</td>
+										<td>
+											<button
+												type="button"
+												className="goal-config__btn goal-config__btn--sm"
+												onClick={() => onEdit(item)}
+												disabled={isChecked}
+											>
+												수정
+											</button>
+										</td>
+									</tr>
+								);
+							})}
+						</tbody>
+					</table>
+				)}
+			</div>
+
+			{totalPages > 1 && (
+				<div className="goal-config__pagination">
+					<button
+						type="button"
+						className="goal-config__page-btn"
+						disabled={page <= 1}
+						onClick={() => setPage(p => p - 1)}
+					>
+						◀
+					</button>
+					{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
+						<button
+							key={p}
+							type="button"
+							className={`goal-config__page-btn${p === page ? ' goal-config__page-btn--active' : ''}`}
+							onClick={() => setPage(p)}
+						>
+							{p}
+						</button>
+					))}
+					<button
+						type="button"
+						className="goal-config__page-btn"
+						disabled={page >= totalPages}
+						onClick={() => setPage(p => p + 1)}
+					>
+						▶
+					</button>
+				</div>
+			)}
+		</div>
+	);
+}

Some files were not shown because too many files changed in this diff