Преглед на файлове

Phase B: remove donation/settlement UI, hide channel features behind flag

- Delete studio donation/settlement/wallet, widget/remote/ranking/crew routes (144 files)
- Hide channel sidebar/live/watch/studio behind NEXT_PUBLIC_FEATURE_CHANNEL
- npm run build green

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 преди 3 седмици
родител
ревизия
0485aad048
променени са 100 файла, в които са добавени 88 реда и са изтрити 14066 реда
  1. 1 1
      app/(auth)/login/google/complete/page.tsx
  2. 0 147
      app/(main)/(account)/change-privacy/page.tsx
  3. 0 89
      app/(main)/(account)/change-privacy/style.scss
  4. 2 6
      app/(main)/(account)/charge-logs/page.tsx
  5. 0 148
      app/(main)/(account)/donation-logs/page.tsx
  6. 0 217
      app/(main)/(account)/donation-logs/style.scss
  7. 0 160
      app/(main)/(account)/my-titles/page.tsx
  8. 0 193
      app/(main)/(account)/my-titles/style.scss
  9. 0 2
      app/(main)/(account)/navTabs.tsx
  10. 5 15
      app/(main)/(account)/profile/page.tsx
  11. 5 5
      app/(main)/(account)/sns-link/style.scss
  12. 1 95
      app/(main)/channel/[identifier]/style.scss
  13. 0 94
      app/(main)/crew/consent/[sessionID]/page.tsx
  14. 0 61
      app/(main)/crew/consent/[sessionID]/style.scss
  15. 0 210
      app/(main)/crew/invite/[id]/page.tsx
  16. 0 64
      app/(main)/crew/invite/[id]/style.scss
  17. 0 115
      app/(main)/crew/join/page.tsx
  18. 0 75
      app/(main)/crew/join/style.scss
  19. 10 5
      app/(main)/layout.tsx
  20. 0 8
      app/(main)/notification/page.tsx
  21. 13 9
      app/(main)/page.tsx
  22. 0 30
      app/(main)/ranking/_components/CategoryFilter.tsx
  23. 0 26
      app/(main)/ranking/_components/MemberGradeBadge.tsx
  24. 0 42
      app/(main)/ranking/_components/MyRankBar.tsx
  25. 0 32
      app/(main)/ranking/_components/PeriodFilter.tsx
  26. 0 93
      app/(main)/ranking/_components/Podium.tsx
  27. 0 141
      app/(main)/ranking/_components/RankingList.tsx
  28. 0 103
      app/(main)/ranking/_components/RankingTabs.tsx
  29. 0 116
      app/(main)/ranking/_components/RankingView.tsx
  30. 0 5
      app/(main)/ranking/creator/page.tsx
  31. 0 5
      app/(main)/ranking/donor/page.tsx
  32. 0 268
      app/(main)/ranking/hall-of-fame/page.tsx
  33. 0 16
      app/(main)/ranking/layout.tsx
  34. 0 5
      app/(main)/ranking/page.tsx
  35. 0 105
      app/(main)/ranking/rising/page.tsx
  36. 0 880
      app/(main)/ranking/style.scss
  37. 1 13
      app/(main)/user/[sid]/_component/UserProfileStats.tsx
  38. 3 65
      app/(main)/watch/[identifier]/ChatSidebar.tsx
  39. 0 365
      app/(main)/watch/[identifier]/DonationModal.tsx
  40. 0 125
      app/(main)/watch/[identifier]/DonationToast.tsx
  41. 0 130
      app/(main)/watch/[identifier]/LeaderboardPanel.tsx
  42. 2 26
      app/(main)/watch/[identifier]/WatchView.tsx
  43. 1 18
      app/(main)/watch/[identifier]/YouTubeChatIframe.tsx
  44. 4 59
      app/(main)/watch/[identifier]/chat-sidebar.scss
  45. 0 454
      app/(main)/watch/[identifier]/donation-modal.scss
  46. 0 89
      app/(main)/watch/[identifier]/donation-toast.scss
  47. 0 207
      app/(main)/watch/[identifier]/leaderboard-panel.scss
  48. 2 2
      app/(main)/watch/[identifier]/style.scss
  49. 0 35
      app/(main)/watch/[identifier]/youtube-chat-iframe.scss
  50. 0 70
      app/api/channel-titles/[[...path]]/route.ts
  51. 0 18
      app/api/crew/[...path]/route.ts
  52. 0 46
      app/api/donation/[...path]/route.ts
  53. 0 12
      app/api/ranking/[[...path]]/route.ts
  54. 0 11
      app/api/widget/[...path]/route.ts
  55. 1 1
      app/auth/sns/google/callback/style.scss
  56. 15 6
      app/component/Header.tsx
  57. 8 5
      app/component/Layout.tsx
  58. 2 7
      app/component/Profile.tsx
  59. 1 1
      app/component/payButton.scss
  60. 4 4
      app/globals.scss
  61. 0 693
      app/remote/[widgetToken]/page.tsx
  62. 0 744
      app/remote/[widgetToken]/style.scss
  63. 0 7
      app/remote/layout.tsx
  64. 7 198
      app/studio/Sidebar.tsx
  65. 0 294
      app/studio/dashboard/page.tsx
  66. 0 360
      app/studio/dashboard/style.scss
  67. 0 150
      app/studio/donation-code/page.tsx
  68. 0 389
      app/studio/donation/alert/_components/AlertFormPanel.tsx
  69. 0 390
      app/studio/donation/alert/_components/AlertListPanel.tsx
  70. 0 129
      app/studio/donation/alert/_components/AlertPreviewPanel.tsx
  71. 0 186
      app/studio/donation/alert/add/page.tsx
  72. 0 165
      app/studio/donation/alert/constants.ts
  73. 0 25
      app/studio/donation/alert/context.tsx
  74. 0 211
      app/studio/donation/alert/edit/[id]/page.tsx
  75. 0 55
      app/studio/donation/alert/layout.tsx
  76. 0 74
      app/studio/donation/alert/list/page.tsx
  77. 0 5
      app/studio/donation/alert/page.tsx
  78. 0 791
      app/studio/donation/alert/style.scss
  79. 0 11
      app/studio/donation/alert/types.ts
  80. 0 460
      app/studio/donation/crew/[id]/_components/CrewMembersTab.tsx
  81. 0 169
      app/studio/donation/crew/[id]/_components/CrewSessionTab.tsx
  82. 0 105
      app/studio/donation/crew/[id]/_components/CrewSettingsTab.tsx
  83. 0 240
      app/studio/donation/crew/[id]/_components/CrewWidgetTab.tsx
  84. 0 104
      app/studio/donation/crew/[id]/page.tsx
  85. 0 585
      app/studio/donation/crew/[id]/style.scss
  86. 0 224
      app/studio/donation/crew/page.tsx
  87. 0 41
      app/studio/donation/crew/style.scss
  88. 0 419
      app/studio/donation/crew/widget/_components/CrewWidgetFormPanel.tsx
  89. 0 127
      app/studio/donation/crew/widget/_components/CrewWidgetListPanel.tsx
  90. 0 225
      app/studio/donation/crew/widget/_components/CrewWidgetPreviewPanel.tsx
  91. 0 30
      app/studio/donation/crew/widget/add/page.tsx
  92. 0 35
      app/studio/donation/crew/widget/constants.ts
  93. 0 24
      app/studio/donation/crew/widget/context.tsx
  94. 0 44
      app/studio/donation/crew/widget/edit/[id]/page.tsx
  95. 0 50
      app/studio/donation/crew/widget/layout.tsx
  96. 0 8
      app/studio/donation/crew/widget/list/page.tsx
  97. 0 15
      app/studio/donation/crew/widget/page.tsx
  98. 0 581
      app/studio/donation/crew/widget/style.scss
  99. 0 51
      app/studio/donation/crew/widget/types.ts
  100. 0 297
      app/studio/donation/goal/_components/GoalFormPanel.tsx

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

@@ -17,7 +17,7 @@ export default function Page()
 			<span className="auth-logo inline-block">
 				<Logo size="lg" />
 			</span>
-			<div className="w-7 h-7 border-[3px] border-gray-200 border-t-[var(--color-donate)] rounded-full animate-spin" />
+			<div className="w-7 h-7 border-[3px] border-gray-200 border-t-[var(--color-accent)] rounded-full animate-spin" />
 			<p className="text-sm text-[var(--text-muted)]">Google 로그인 중...</p>
 		</div>
 	);

+ 0 - 147
app/(main)/(account)/change-privacy/page.tsx

@@ -1,147 +0,0 @@
-'use client';
-
-import './style.scss';
-import Link from 'next/link';
-import { useState, useEffect } from 'react';
-import { useMemberContext } from '@/contexts/memberProvider';
-import { ChangePrivacyRequest } from '@/types/request/account';
-import { fetchApi } from '@/lib/utils/client';
-import Loading from '@/app/component/Loading';
-
-export default function ChangePrivacy()
-{
-	const { member, setMember } = useMemberContext();
-	const [error, setError] = useState<string>('');
-	const [loading, setLoading] = useState<boolean>(false);
-	const [privacy, setPrivacy] = useState({
-		isRankingPublic: member?.memberApprove.isRankingPublic || false,
-		isDonationPublic: member?.memberApprove.isDonationPublic || false
-	});
-
-	useEffect(() => {
-		if (error) {
-			alert(error);
-			setError('');
-		}
-	}, [error]);
-
-	useEffect(() => {
-		if (member) {
-
-			const approve = member.memberApprove;
-			setPrivacy({
-				isRankingPublic: approve?.isRankingPublic ?? false,
-				isDonationPublic: approve?.isDonationPublic ?? false
-			});
-		}
-	}, [member]);
-
-	const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
-		e.preventDefault();
-
-		if (!member) {
-			return;
-		}
-
-		setLoading(true);
-
-		fetchApi('/api/mypage/privacy-settings', {
-			method: 'POST',
-			body: {
-				IsRankingPublic: privacy.isRankingPublic,
-				IsDonationPublic: privacy.isDonationPublic
-			} as ChangePrivacyRequest
-		}).then(() => {
-			member.memberApprove.isRankingPublic = privacy.isRankingPublic;
-			member.memberApprove.isDonationPublic = privacy.isDonationPublic;
-			setMember(member);
-			localStorage.setItem('member', JSON.stringify(member));
-			alert("공개 설정이 변경되었습니다.");
-
-		}).catch(err => {
-			setError(err.message);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}
-
-	const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
-		const { name, checked } = e.target;
-
-		setPrivacy((prev) => ({
-			...prev,
-			[name]: checked
-		}));
-	};
-
-	return (
-		<div id="changePrivacy">
-			{ loading && <Loading /> }
-
-			<h1>정보 공개 설정</h1>
-			<form method="post" acceptCharset="utf-8" autoComplete="off" onSubmit={handleSubmit}>
-				<table className="table-auto max-xl:w-full lg:w-[600px]">
-					<caption>
-						외부에 노출되는 내 정보 공개 범위를 설정합니다.
-					</caption>
-					<colgroup>
-						<col width="30%"/>
-						<col width="60%"/>
-					</colgroup>
-					<tbody>
-						<tr>
-							<th>순위 공개</th>
-							<td>
-								<label>
-									<input
-										type="checkbox"
-										name="isRankingPublic"
-										checked={privacy.isRankingPublic}
-										onChange={handleChange}
-									/>
-									순위에 내 별명을 공개합니다.
-								</label>
-							</td>
-						</tr>
-						<tr>
-							<th>후원 총액 공개</th>
-							<td>
-								<label>
-									<input
-										type="checkbox"
-										name="isDonationPublic"
-										checked={privacy.isDonationPublic}
-										onChange={handleChange}
-									/>
-									내 프로필에 후원 총액을 공개합니다.
-								</label>
-							</td>
-						</tr>
-					</tbody>
-					<tfoot>
-						<tr>
-							<td colSpan={2}>
-								<div className="flex justify-center gap-2">
-									<button type="submit" className="btn btn-primary">확인</button>
-									<Link href="/profile" className="btn btn-default">취소</Link>
-								</div>
-							</td>
-						</tr>
-					</tfoot>
-				</table>
-			</form>
-
-			<br />
-			<dl className="max-xl:w-full lg:w-[600px]">
-				<dt hidden>&nbsp;</dt>
-				<dd>
-					<ol>
-						<li>순위 비공개 선택 시 순위 화면에 별명이 익명으로 표시됩니다.</li>
-						<li>후원 총액 비공개 선택 시 내 프로필에서 후원 총액 항목이 숨겨집니다. (본인만 확인 가능)</li>
-						<li>공개 여부와 무관하게 순위·후원은 계속 집계됩니다.</li>
-					</ol>
-				</dd>
-			</dl>
-		</div>
-	);
-}

+ 0 - 89
app/(main)/(account)/change-privacy/style.scss

@@ -1,89 +0,0 @@
-#changePrivacy {
-	padding: 25px 32px 32px 32px;
-
-	h1 {
-		font-size: 22px;
-		margin-bottom: 20px;
-	}
-
-	table {
-		border: 1px solid var(--border-strong);
-
-		caption {
-			text-align: left;
-			font-size: 16px;
-			margin-bottom: 10px;
-		}
-
-		tbody {
-			tr {
-				th, td {
-					text-align: left;
-					font-size: 14px;
-					font-weight: normal;
-					padding: 12px 13px;
-				}
-
-				th {
-					background: var(--bg-elevated);
-					border: 1px solid var(--border-strong);
-				}
-
-				td {
-					border-bottom: 1px solid var(--border-strong);
-
-					input[type="checkbox"] {
-						transform: scale(1.3);
-						margin-right: 8px;
-					}
-
-					label {
-						cursor: pointer;
-					}
-				}
-			}
-		}
-
-		tfoot {
-			background: var(--bg-elevated);
-
-			tr {
-				td {
-					text-align: center;
-					padding: 8px;
-
-					button, a {
-						padding: 6px 20px;
-					}
-				}
-			}
-		}
-	}
-
-	dl {
-		background-color: var(--bg-subtle);
-		border: 1px solid var(--border-strong);
-		line-height: 1.5;
-		padding: 15px 16px 12px 16px;
-
-		dt, dd {
-			font-size: 14px;
-		}
-
-		dt {
-			font-weight: bold;
-			margin-bottom: 7px;
-		}
-
-		dd {
-			ol {
-				list-style: disc;
-				padding: 0 20px;
-			}
-		}
-	}
-
-	blockquote {
-		line-height: 1.9;
-	}
-}

+ 2 - 6
app/(main)/(account)/charge-logs/page.tsx

@@ -30,8 +30,6 @@ const METHOD_MAP: Record<string, string> = {
 
 const TX_TYPE_MAP: Record<string, string> = {
 	Charge: '충전',
-	DonationIn: '후원 받음',
-	DonationOut: '후원 보냄',
 	RewardEarned: '보상 적립',
 	Spend: '사용',
 	Refund: '환불',
@@ -41,14 +39,12 @@ const TX_TYPE_MAP: Record<string, string> = {
 	OrderPay: '상점 결제',
 	OrderRefund: '주문 환불',
 	OrderChannelReward: '상점 채널 보상',
-	OrderRefundChannelReward: '상점 보상 차감',
-	WithdrawalStoreRevenue: '출금'
+	OrderRefundChannelReward: '상점 보상 차감'
 };
 
 const BALANCE_TYPE_MAP: Record<string, string> = {
 	PgCharged: 'PG 충전',
 	Deposit: '예치금',
-	Donation: '후원',
 	Reward: '보상',
 	Airdrop: '에어드랍',
 	Locked: '잠금',
@@ -57,7 +53,7 @@ const BALANCE_TYPE_MAP: Record<string, string> = {
 };
 
 // 적립류 (+), 사용류 (-) 분류 — UI 표시용 부호
-const EARN_TX_TYPES = new Set(['Charge', 'DonationIn', 'RewardEarned', 'Unlock', 'OrderChannelReward', 'OrderRefund', 'Refund']);
+const EARN_TX_TYPES = new Set(['Charge', 'RewardEarned', 'Unlock', 'OrderChannelReward', 'OrderRefund', 'Refund']);
 
 export default function ChargeLogs()
 {

+ 0 - 148
app/(main)/(account)/donation-logs/page.tsx

@@ -1,148 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useState, useEffect } from 'react';
-import { LoginLogType } from '@/constants/common';
-import { fetchApi, getDateTime } from '@/lib/utils/client';
-import type { DonationLogsResponse } from '@/types/response/account/donationLogs';
-import Loading from '@/app/component/Loading';
-import Pagination from '@/app/component/Pagination';
-import NavTabs from '../navTabs';
-
-export default function DonationLogs()
-{
-	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<DonationLogsResponse>({
-		total: 0,
-		page: 1,
-		perPage: 20,
-		list: []
-	});
-
-	useEffect(() => {
-		if (error) {
-			alert(error);
-			setError('');
-		}
-	}, [error]);
-
-	useEffect(() => {
-		setLoading(true);
-
-		fetchApi<DonationLogsResponse>(`/api/mypage/donation-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 }
-	];
-
-	return (
-		<>
-		<NavTabs />
-
-		<div id="donationLogs">
-			{loading && <Loading />}
-
-			<h1>후원 내역</h1>
-			<div className="donation-logs__header">
-				<div className="donation-logs__summary">합계: {data.total}</div>
-				<div className="donation-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="donation-logs__list">
-				<article>
-					<ul>
-						<li>일시</li>
-						<li>보낸 이</li>
-						<li>받는 이</li>
-						<li>전달 내용</li>
-						<li>사용 포인트(P)</li>
-						<li>잔여 포인트(P)</li>
-					</ul>
-				</article>
-				<article>
-					{data.list.length > 0 ? (
-						data.list.map((row) => (
-							<section key={row.id}>
-								{/* PC */}
-								<ol>
-									<li>{getDateTime(row.createdAt)}</li>
-									<li className="donation-logs__send-name">
-										{row.sendName}
-										{row.isTest && <span className="badge badge--test">테스트</span>}
-									</li>
-									<li className="donation-logs__receiver">
-										{row.receiverChannelName ?? '-'}
-										{row.isCrew && <span className="badge badge--crew">합방</span>}
-									</li>
-									<li className="donation-logs__message" title={row.message ?? ''}>
-										{row.message?.trim() ? row.message : '-'}
-									</li>
-									<li className="amount-minus">-{row.amount.toLocaleString()}P</li>
-									<li className="balance-after">
-										{row.balanceAfter != null ? `${row.balanceAfter.toLocaleString()}P` : '-'}
-									</li>
-								</ol>
-
-								{/* Mobile */}
-								<dl hidden>
-									<dt>
-										<div className='flex justify-between'>
-											<div>
-												{row.receiverChannelName ?? '-'} 에게
-												{row.isCrew && <span className="badge badge--crew">합방</span>}
-												{row.isTest && <span className="badge badge--test">테스트</span>}
-											</div>
-											<div className="amount-minus">-{row.amount.toLocaleString()}P</div>
-										</div>
-									</dt>
-									<dd>
-										<ul>
-											<li>{getDateTime(row.createdAt)}</li>
-											<li>보낸 이: {row.sendName}</li>
-											<li>잔여 {row.balanceAfter != null ? `${row.balanceAfter.toLocaleString()}P` : '-'}</li>
-										</ul>
-										{row.message?.trim() && (
-											<p className="donation-logs__message-mobile">{row.message}</p>
-										)}
-									</dd>
-								</dl>
-							</section>
-						))
-					) : (
-						<p className="empty">후원 내역이 없습니다.</p>
-					)}
-				</article>
-			</section>
-
-			{data.list.length > 0 && (
-				<Pagination total={data.total} page={page} perPage={20} onChange={setPage} />
-			)}
-		</div>
-		</>
-	);
-}

+ 0 - 217
app/(main)/(account)/donation-logs/style.scss

@@ -1,217 +0,0 @@
-#donationLogs {
-	padding: 0 32px 32px 32px;
-
-	h1 {
-		font-size: 22px;
-		margin-bottom: 10px;
-	}
-
-	.donation-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(--text-link);
-					border-bottom-color: var(--text-link);
-				}
-
-				&.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(120px, 14%, 150px)
-						clamp(90px, 12%, 120px)
-						clamp(110px, 14%, 160px)
-						1fr
-						clamp(90px, 12%, 120px)
-						clamp(90px, 12%, 120px);
-					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(120px, 14%, 150px)
-							clamp(90px, 12%, 120px)
-							clamp(110px, 14%, 160px)
-							1fr
-							clamp(90px, 12%, 120px)
-							clamp(90px, 12%, 120px);
-						column-gap: 0.75rem;
-						align-items: center;
-
-						> li {
-							text-align: center;
-							font-size: 0.875rem;
-							word-break: break-word;
-						}
-					}
-
-					.donation-logs__message {
-						display: -webkit-box;
-						-webkit-line-clamp: 2;
-						-webkit-box-orient: vertical;
-						overflow: hidden;
-						text-align: left !important;
-						color: var(--text-secondary);
-						font-size: 0.85rem;
-					}
-
-					.donation-logs__send-name,
-					.donation-logs__receiver {
-						display: inline-flex;
-						align-items: center;
-						justify-content: center;
-						gap: 0.25rem;
-						flex-wrap: wrap;
-					}
-
-					// Mobile
-					dl {
-						padding: 0 0.25rem;
-
-						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;
-									}
-								}
-							}
-
-							.donation-logs__message-mobile {
-								margin-top: 0.4rem;
-								font-size: 0.85rem;
-								color: var(--text-secondary);
-								white-space: pre-wrap;
-								word-break: break-word;
-							}
-						}
-					}
-
-					@media (max-width: 1024px) {
-						ol {
-							display: none;
-						}
-						dl {
-							display: block;
-						}
-					}
-				}
-
-				> .empty {
-					text-align: center;
-					padding: 2.5rem;
-					color: var(--text-muted);
-				}
-			}
-
-			.amount-minus {
-				font-weight: 600;
-				color: var(--color-danger) !important;
-			}
-
-			.balance-after {
-				color: var(--text-secondary);
-			}
-
-			.badge {
-				display: inline-block;
-				padding: 1px 6px;
-				border-radius: 4px;
-				font-size: 0.6875rem;
-				font-weight: 600;
-				line-height: 1.4;
-
-				&--crew {
-					background: rgba(139, 92, 246, 0.12);
-					color: #7c3aed;
-				}
-
-				&--test {
-					background: rgba(249, 115, 22, 0.12);
-					color: #ea580c;
-				}
-			}
-		}
-	}
-}

+ 0 - 160
app/(main)/(account)/my-titles/page.tsx

@@ -1,160 +0,0 @@
-'use client';
-
-import './style.scss';
-import type { CSSProperties } from 'react';
-import { useCallback, useEffect, useState } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import useAuth from '@/hooks/useAuth';
-import Loading from '@/app/component/Loading';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Lock, Check } from 'lucide-react';
-import type { ChannelTitleGroup, MyTitlesResponse } from '@/types/response/studio/titles';
-import NavTabs from '../navTabs';
-
-export default function MyTitlesPage()
-{
-	const { isAuthenticated } = useAuth();
-	const [groups, setGroups] = useState<ChannelTitleGroup[]>([]);
-	const [loading, setLoading] = useState(true);
-	const [filter, setFilter] = useState<'all'|'acquired'|'locked'>('all');
-
-	const load = useCallback(() => {
-		if (!isAuthenticated) {
-			return;
-		}
-
-		setLoading(true);
-
-		fetchApi<MyTitlesResponse>('/api/channel-titles/my', { silent: true }).then((res) => {
-			setGroups(res.data?.groups ?? []);
-		}).catch(() => {
-			setGroups([]);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}, [isAuthenticated]);
-
-	useEffect(() => {
-		load();
-	}, [load]);
-
-	const handleSelect = (channelID: number, titleID: number|null) => {
-		fetchApi('/api/channel-titles/my/select', {
-			method: 'POST',
-			body: { ChannelID: channelID, TitleID: titleID }
-		}).then(() => {
-			load();
-		}).catch((err) => {
-			alert(err instanceof Error ? err.message : '선택에 실패했습니다.');
-		});
-	};
-
-	if (!isAuthenticated) {
-		return (
-			<div id="myTitlesPage">
-				<p className="my-titles__empty">로그인 후 이용해 주세요.</p>
-			</div>
-		);
-	}
-
-	return (
-		<>
-		<NavTabs />
-
-		<div id="myTitlesPage">
-			<header className="my-titles__header">
-				<h1>칭호 관리</h1>
-				<p>각 채널에 후원한 금액에 따라 자동 획득되는 칭호를 관리합니다.</p>
-			</header>
-
-			<div className="my-titles__filter" role="tablist">
-				<button type="button" role="tab" aria-selected={filter === 'all' ? 'true' : 'false'} className={filter === 'all' ? 'my-titles__filter-btn--active' : ''} onClick={() => setFilter('all')}>전체</button>
-				<button type="button" role="tab" aria-selected={filter === 'acquired' ? 'true' : 'false'} className={filter === 'acquired' ? 'my-titles__filter-btn--active' : ''} onClick={() => setFilter('acquired')}>획득</button>
-				<button type="button" role="tab" aria-selected={filter === 'locked' ? 'true' : 'false'} className={filter === 'locked' ? 'my-titles__filter-btn--active' : ''} onClick={() => setFilter('locked')}>미획득</button>
-			</div>
-
-			{loading ? <Loading type={2} /> : (
-				groups.length === 0 ? (
-					<p className="my-titles__empty">후원 기록이 있는 채널이 아직 없습니다.</p>
-				) : (
-					<div className="my-titles__groups">
-						{groups.map((group) => {
-							const visibleTitles = group.titles.filter((t) => {
-								if (filter === 'acquired') return t.isAcquired;
-								if (filter === 'locked') return !t.isAcquired;
-								return true;
-							});
-
-							if (visibleTitles.length === 0) {
-								return null;
-							}
-
-							return (
-								<section key={group.channelID} className="my-titles__group">
-									<header className="my-titles__group-header">
-										<Avatar className="h-10 w-10">
-											{group.channelThumb ? <AvatarImage src={group.channelThumb} alt={group.channelName} /> : null}
-											<AvatarFallback>{group.channelName?.[0] ?? '?'}</AvatarFallback>
-										</Avatar>
-										<div>
-											<strong>{group.channelName}</strong>
-											<span>내 누적 {group.cumulativeAmount.toLocaleString('ko-KR')}원 · {group.donationCount}회</span>
-										</div>
-									</header>
-
-									<div className="my-titles__cards">
-										{visibleTitles.map((title) => (
-											<article
-												key={title.id}
-												className={`my-titles__card ${title.isAcquired ? 'my-titles__card--acquired' : 'my-titles__card--locked'} ${title.isSelected ? 'my-titles__card--selected' : ''}`}
-											>
-												<div className="my-titles__card-badge" style={{ '--badge-color': title.color } as CSSProperties}>
-													{title.name}
-												</div>
-												<div className="my-titles__card-info">
-													{title.description ? <em>{title.description}</em> : null}
-													<span>누적 {title.minAmount.toLocaleString('ko-KR')}원 이상</span>
-													{!title.isAcquired && title.remainingAmount > 0 ? (
-														<small className="my-titles__card-remaining">
-															<Lock size={12} /> 앞으로 {title.remainingAmount.toLocaleString('ko-KR')}원
-														</small>
-													) : null}
-												</div>
-												<div className="my-titles__card-actions">
-													{title.isAcquired ? (
-														title.isSelected ? (
-															<button
-																type="button"
-																className="my-titles__card-btn my-titles__card-btn--selected"
-																onClick={() => handleSelect(group.channelID, null)}
-															>
-																<Check size={14} /> 사용 중
-															</button>
-														) : (
-															<button
-																type="button"
-																className="my-titles__card-btn"
-																onClick={() => handleSelect(group.channelID, title.id)}
-															>
-																사용하기
-															</button>
-														)
-													) : (
-														<span className="my-titles__card-locked-label">
-															<Lock size={14} /> 잠김
-														</span>
-													)}
-												</div>
-											</article>
-										))}
-									</div>
-								</section>
-							);
-						})}
-					</div>
-				)
-			)}
-		</div>
-		</>
-	);
-}

+ 0 - 193
app/(main)/(account)/my-titles/style.scss

@@ -1,193 +0,0 @@
-#myTitlesPage {
-	max-width: 960px;
-	margin: 0 32px 32px 32px;
-	padding: 0;
-
-	@media (max-width: 768px) {
-		margin: 16px 0 32px;
-	}
-}
-
-.my-titles {
-	&__header {
-		margin-bottom: 1.5rem;
-
-		h1 {
-			font-size: 1.5rem;
-			font-weight: 700;
-			margin: 0 0 0.25rem;
-		}
-
-		p {
-			font-size: 0.9rem;
-			color: hsl(var(--muted-foreground));
-			margin: 0;
-		}
-	}
-
-	&__filter {
-		display: flex;
-		gap: 0.5rem;
-		margin-bottom: 1.25rem;
-
-		button {
-			padding: 0.4rem 1rem;
-			font-size: 0.88rem;
-			font-weight: 500;
-			background: hsl(var(--secondary));
-			color: hsl(var(--muted-foreground));
-			border-radius: 9999px;
-		}
-
-		&-btn--active {
-			background: hsl(var(--primary)) !important;
-			color: hsl(var(--primary-foreground)) !important;
-		}
-	}
-
-	&__empty {
-		padding: 3rem 1rem;
-		text-align: center;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__groups {
-		display: flex;
-		flex-direction: column;
-		gap: 1.5rem;
-	}
-
-	&__group {
-		padding: 1.25rem;
-		background: hsl(var(--background));
-		border: 1px solid hsl(var(--border));
-		border-radius: 10px;
-
-		&-header {
-			display: flex;
-			align-items: center;
-			gap: 0.75rem;
-			padding-bottom: 1rem;
-			margin-bottom: 1rem;
-			border-bottom: 1px solid hsl(var(--border));
-
-			div {
-				display: flex;
-				flex-direction: column;
-				gap: 0.15rem;
-
-				strong {
-					font-size: 1rem;
-				}
-
-				span {
-					font-size: 0.82rem;
-					color: hsl(var(--muted-foreground));
-				}
-			}
-		}
-	}
-
-	&__cards {
-		display: grid;
-		grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
-		gap: 0.75rem;
-	}
-
-	&__card {
-		position: relative;
-		display: flex;
-		flex-direction: column;
-		gap: 0.5rem;
-		padding: 1rem;
-		background: hsl(var(--background));
-		border: 1px solid hsl(var(--border));
-		border-radius: 8px;
-		transition: transform 0.15s ease, border-color 0.15s ease;
-
-		&:hover:not(&--locked) {
-			transform: translateY(-2px);
-			border-color: hsl(var(--primary) / 0.5);
-		}
-
-		&--locked {
-			opacity: 0.55;
-		}
-
-		&--selected {
-			border-color: hsl(var(--primary));
-			background: hsl(var(--primary) / 0.05);
-		}
-
-		&-badge {
-			display: inline-block;
-			padding: 0.35rem 0.7rem;
-			font-weight: 600;
-			font-size: 0.85rem;
-			color: #fff;
-			border-radius: 9999px;
-			width: fit-content;
-			background-color: var(--badge-color, transparent);
-		}
-
-		&-info {
-			display: flex;
-			flex-direction: column;
-			gap: 0.25rem;
-
-			em {
-				font-size: 0.85rem;
-				font-style: normal;
-				color: hsl(var(--foreground));
-			}
-
-			span {
-				font-size: 0.82rem;
-				color: hsl(var(--muted-foreground));
-			}
-		}
-
-		&-remaining {
-			display: inline-flex;
-			align-items: center;
-			gap: 0.25rem;
-			font-size: 0.78rem;
-			color: hsl(var(--muted-foreground));
-		}
-
-		&-actions {
-			display: flex;
-			align-items: center;
-			justify-content: flex-end;
-		}
-
-		&-btn {
-			padding: 0.35rem 0.75rem;
-			font-size: 0.82rem;
-			font-weight: 600;
-			background: hsl(var(--primary));
-			color: hsl(var(--primary-foreground));
-			border-radius: 6px;
-
-			&--selected {
-				display: inline-flex;
-				align-items: center;
-				gap: 0.25rem;
-				background: hsl(var(--secondary));
-				color: hsl(var(--foreground));
-			}
-		}
-
-		&-locked-label {
-			display: inline-flex;
-			align-items: center;
-			gap: 0.25rem;
-			padding: 0.35rem 0.75rem;
-			font-size: 0.82rem;
-			font-weight: 500;
-			color: hsl(var(--muted-foreground));
-			background: hsl(var(--muted) / 0.5);
-			border-radius: 6px;
-		}
-	}
-}

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

@@ -35,8 +35,6 @@ export default function NavTabs()
 					{ href: "/inventory", label: "보관함" },
 					{ href: "/orders", label: "주문 목록" },
 					{ href: "/charge-logs", label: "포인트 내역" },
-					{ href: "/donation-logs", label: "후원 내역" },
-					{ href: "/my-titles", label: "칭호 관리" },
 					{ href: "/my-posts", label: "작성 게시글" },
 					{ href: "/my-comments", label: "작성 댓글" },
 					{ href: "/exp-logs", label: "경험치 내역" },

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

@@ -10,6 +10,7 @@ import useAuth from '@/hooks/useAuth';
 import { fetchApi, getDateTime, stripHtmlTags } from '@/lib/utils/client';
 import { SnsLinkItem, SnsListResponse } from '@/types/sns';
 import NavTabs from '../navTabs';
+import { FEATURE_CHANNEL } from '@/constants/features';
 
 type ProviderMeta = {
 	provider: 'Google' | 'Naver' | 'Kakao';
@@ -155,20 +156,6 @@ export default function Profile()
 								<Link href="/change-approve" className="btn btn-default">수신 설정</Link>
 							</td>
 						</tr>
-						<tr>
-							<th>정보 공개</th>
-							<td>
-								<div className="flex items-center gap-4 flex-wrap">
-									<label className="flex items-center gap-2 text-gray-700">
-										<input type="checkbox" checked={member.memberApprove.isRankingPublic} readOnly disabled className="accent-gray-500 cursor-not-allowed" />
-										순위 공개
-									</label>
-								</div>
-							</td>
-							<td>
-								<Link href="/change-privacy" className="btn btn-default">공개 설정</Link>
-							</td>
-						</tr>
 					</tbody>
 				</table>
 
@@ -236,7 +223,10 @@ export default function Profile()
 					</tbody>
 				</table>
 
-				<Link href='/studio' className='text-sky-600 hover:text-red-600 hover:underline'>채널 관리</Link>
+				{/* 채널 기능 OFF 시 스튜디오 진입 링크 미노출 */}
+				{FEATURE_CHANNEL && (
+					<Link href='/studio' className='text-sky-600 hover:text-red-600 hover:underline'>채널 관리</Link>
+				)}
 			</div>
 
 			<br />

+ 5 - 5
app/(main)/(account)/sns-link/style.scss

@@ -30,7 +30,7 @@
 		margin-bottom: 16px;
 		background: var(--bg-subtle);
 		border: 1px solid var(--border-default);
-		border-left: 3px solid var(--color-donate);
+		border-left: 3px solid var(--color-accent);
 		border-radius: 6px;
 		font-size: 0.875rem;
 		color: var(--text-primary);
@@ -71,7 +71,7 @@
 		transition: border-color 0.15s, background 0.15s;
 
 		&--connected {
-			border-color: var(--color-donate);
+			border-color: var(--color-accent);
 			background: var(--bg-elevated);
 		}
 
@@ -188,8 +188,8 @@
 		}
 
 		&--on {
-			background: var(--color-donate);
-			border-color: var(--color-donate);
+			background: var(--color-accent);
+			border-color: var(--color-accent);
 			color: #fff;
 			padding: 0 6px 0 14px;
 			flex-direction: row-reverse;
@@ -205,7 +205,7 @@
 		}
 
 		&:not(.sns-link__toggle--disabled):hover {
-			border-color: var(--color-donate);
+			border-color: var(--color-accent);
 		}
 	}
 

+ 1 - 95
app/(main)/channel/[identifier]/style.scss

@@ -153,7 +153,7 @@
 		}
 	}
 
-	// ── 공통 액션 버튼 base (구독/팔로우/후원 모두) ──
+	// ── 공통 액션 버튼 base (구독/팔로우) ──
 	&__action {
 		display: inline-flex;
 		align-items: center;
@@ -199,87 +199,6 @@
 		gap: 6px;
 	}
 
-	// 후원 — 그래디언트 + ripple + shimmer 효과
-	&__donate-btn {
-		position: relative;
-		background: linear-gradient(135deg, #f59e0b, #dc2626);
-		color: #fff;
-		overflow: hidden;
-		isolation: isolate;
-		box-shadow: 0 2px 8px rgba(220, 38, 38, 0.2);
-		transition: box-shadow 0.3s ease, transform 0.2s ease;
-
-		// 내부 쉬머 라이트 스윕
-		&::before {
-			content: '';
-			position: absolute;
-			inset: 0;
-			background: linear-gradient(120deg, transparent 30%, rgba(255, 255, 255, 0.5) 50%, transparent 70%);
-			transform: translateX(-120%);
-			transition: transform 0.9s ease;
-			pointer-events: none;
-			z-index: 1;
-		}
-
-		// 외부 물결 링 (hover 시 expanding pulse)
-		&::after {
-			content: '';
-			position: absolute;
-			inset: -4px;
-			border-radius: inherit;
-			border: 2px solid rgba(245, 158, 11, 0.55);
-			opacity: 0;
-			pointer-events: none;
-			z-index: -1;
-		}
-
-		// 내부 span이 pseudo 위에 오도록
-		> span {
-			position: relative;
-			z-index: 2;
-		}
-
-		&:hover,
-		&:focus-visible {
-			transform: translateY(-1px);
-			box-shadow:
-				0 6px 20px rgba(220, 38, 38, 0.35),
-				0 0 0 4px rgba(245, 158, 11, 0.15);
-
-			&::before {
-				transform: translateX(120%);
-			}
-
-			&::after {
-				animation: channel-page__donate-ripple 1.4s ease-out infinite;
-			}
-		}
-
-		&:focus-visible {
-			outline: 2px solid #f59e0b;
-			outline-offset: 2px;
-		}
-
-		&:active {
-			transform: translateY(0);
-			box-shadow: 0 2px 6px rgba(220, 38, 38, 0.25);
-		}
-
-		@media (prefers-reduced-motion: reduce) {
-			transition: none;
-
-			&::before,
-			&::after {
-				display: none;
-			}
-
-			&:hover,
-			&:focus-visible {
-				transform: none;
-			}
-		}
-	}
-
 	// ── 라이브 ───────────────────────────────────────
 	&__live {
 		display: flex;
@@ -500,16 +419,3 @@
 	50% { opacity: 0.4; }
 }
 
-@keyframes channel-page__donate-ripple {
-	0% {
-		transform: scale(1);
-		opacity: 0.6;
-	}
-	80% {
-		opacity: 0;
-	}
-	100% {
-		transform: scale(1.5);
-		opacity: 0;
-	}
-}

+ 0 - 94
app/(main)/crew/consent/[sessionID]/page.tsx

@@ -1,94 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useState, useEffect } from 'react';
-import { useParams } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-
-type ConsentInfo = {
-	crewSessionID: number;
-	title: string;
-	crewName: string;
-	isConsented: boolean;
-	crewMemberID: number;
-};
-
-export default function CrewConsentPage()
-{
-	const { sessionID } = useParams<{ sessionID: string }>();
-	const [info, setInfo] = useState<ConsentInfo|null>(null);
-	const [loading, setLoading] = useState(true);
-	const [agreed, setAgreed] = useState(false);
-	const [submitting, setSubmitting] = useState(false);
-	const [done, setDone] = useState(false);
-
-	useEffect(() => {
-		fetchApi<ConsentInfo>(`/api/crew/consent/info/${sessionID}`)
-			.then(res => {
-				const data = res.data;
-				setInfo(data ?? null);
-				if (data?.isConsented) setDone(true);
-			})
-			.catch(() => {})
-			.finally(() => setLoading(false));
-	}, [sessionID]);
-
-	const handleConsent = async () => {
-		if (!info || !agreed) return;
-		setSubmitting(true);
-		try {
-			await fetchApi('/api/crew/session/consent', {
-				method: 'POST',
-				body: {
-					crewSessionID: info.crewSessionID,
-					crewMemberID: info.crewMemberID
-				}
-			});
-			setDone(true);
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '동의 처리에 실패했습니다.');
-		} finally {
-			setSubmitting(false);
-		}
-	};
-
-	if (loading) return <div className="crew-consent"><p>준비 중...</p></div>;
-	if (!info) return <div className="crew-consent"><p>세션 정보를 찾을 수 없습니다.</p></div>;
-
-	return (
-		<div className="crew-consent">
-			<h1 className="crew-consent__title">크루 방송 참여 동의</h1>
-			<p className="crew-consent__subtitle">크루장이 방송 참여를 요청했습니다</p>
-
-			<div className="crew-consent__info">
-				<div className="crew-consent__row">
-					<span className="crew-consent__row-label">크루</span>
-					<span className="crew-consent__row-value">{info.crewName}</span>
-				</div>
-				<div className="crew-consent__row">
-					<span className="crew-consent__row-label">방송 제목</span>
-					<span className="crew-consent__row-value">{info.title}</span>
-				</div>
-			</div>
-
-			{done ? (
-				<div className="crew-consent__done">✓ 동의가 완료되었습니다. 전원 동의 시 방송이 시작됩니다.</div>
-			) : (
-				<>
-					<div className="crew-consent__check">
-						<input type="checkbox" id="agree" checked={agreed} onChange={e => setAgreed(e.target.checked)} />
-						<label htmlFor="agree">크루 방송 참여에 동의합니다</label>
-					</div>
-					<button
-						type="button"
-						className="studio-page__btn studio-page__btn--primary"
-						onClick={handleConsent}
-						disabled={!agreed || submitting}
-					>
-						{submitting ? '처리 중...' : '동의하기'}
-					</button>
-				</>
-			)}
-		</div>
-	);
-}

+ 0 - 61
app/(main)/crew/consent/[sessionID]/style.scss

@@ -1,61 +0,0 @@
-.crew-consent {
-	max-width: 480px;
-	margin: 40px auto;
-	padding: 32px;
-	background: var(--card);
-	border: 1px solid var(--border);
-	border-radius: 12px;
-	text-align: center;
-
-	&__title {
-		font-size: 1.25rem;
-		font-weight: 600;
-		margin-bottom: 8px;
-	}
-
-	&__subtitle {
-		color: var(--muted-foreground);
-		font-size: 0.875rem;
-		margin-bottom: 24px;
-	}
-
-	&__info {
-		padding: 16px;
-		background: var(--accent);
-		border-radius: 8px;
-		margin-bottom: 24px;
-		text-align: left;
-	}
-
-	&__row {
-		display: flex;
-		justify-content: space-between;
-		padding: 4px 0;
-		font-size: 0.875rem;
-
-		&-label {
-			color: var(--muted-foreground);
-		}
-
-		&-value {
-			font-weight: 500;
-		}
-	}
-
-	&__check {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		gap: 8px;
-		margin-bottom: 20px;
-		font-size: 0.875rem;
-	}
-
-	&__done {
-		padding: 16px;
-		background: hsl(var(--primary) / 0.1);
-		border-radius: 8px;
-		color: var(--primary);
-		font-weight: 500;
-	}
-}

+ 0 - 210
app/(main)/crew/invite/[id]/page.tsx

@@ -1,210 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useEffect, useState } from 'react';
-import { useParams, useRouter } from 'next/navigation';
-import { CheckCircle2, XCircle, AlertTriangle, Clock } from 'lucide-react';
-import { fetchApi } from '@/lib/utils/client';
-import { Button } from '@/components/ui/button';
-
-interface InvitationResponse {
-	id: number;
-	crewID: number;
-	crewName: string;
-	inviterChannelName: string;
-	nickname: string;
-	status: number; // 0=Pending, 1=Accepted, 2=Declined, 3=Expired
-	invitedAt: string;
-	expiresAt: string;
-	isExpired: boolean;
-}
-
-const STATUS_ACCEPTED = 1;
-const STATUS_DECLINED = 2;
-const STATUS_EXPIRED = 3;
-
-export default function CrewInviteAcceptPage()
-{
-	const router = useRouter();
-	const { id } = useParams<{ id: string }>();
-	const invitationID = parseInt(id);
-
-	const [loading, setLoading] = useState(true);
-	const [loadError, setLoadError] = useState<string|null>(null);
-	const [invitation, setInvitation] = useState<InvitationResponse|null>(null);
-	const [processing, setProcessing] = useState(false);
-	const [done, setDone] = useState<'accepted'|'declined'|null>(null);
-
-	useEffect(() => {
-		let cancelled = false;
-
-		(async () => {
-			try {
-				const res = await fetchApi<InvitationResponse>(`/api/crew/invite/${invitationID}`, { method: 'GET', silent: true });
-
-				if (cancelled) {
-					return;
-				}
-
-				if (!res.success || !res.data) {
-					setLoadError(res.message || res.errors?.[0]?.description || '초대를 찾을 수 없습니다.');
-					return;
-				}
-
-				setInvitation(res.data);
-			} catch (err) {
-				if (!cancelled) {
-					setLoadError(err instanceof Error ? err.message : '초대 조회에 실패했습니다.');
-				}
-			} finally {
-				if (!cancelled) {
-					setLoading(false);
-				}
-			}
-		})();
-
-		return () => {
-			cancelled = true;
-		};
-	}, [invitationID]);
-
-	const handleAccept = async () => {
-		if (processing) {
-			return;
-		}
-
-		setProcessing(true);
-
-		try {
-			await fetchApi(`/api/crew/invite/${invitationID}/accept`, {
-				method: 'POST',
-				body: {}
-			});
-			setDone('accepted');
-		} catch (err) {
-			alert(err instanceof Error ? err.message : '수락에 실패했습니다.');
-		} finally {
-			setProcessing(false);
-		}
-	};
-
-	const handleDecline = async () => {
-		if (processing) {
-			return;
-		}
-
-		if (!confirm('이 초대를 거절하시겠습니까?')) {
-			return;
-		}
-
-		setProcessing(true);
-
-		try {
-			await fetchApi(`/api/crew/invite/${invitationID}/decline`, { method: 'POST', body: {} });
-			setDone('declined');
-		} catch (err) {
-			alert(err instanceof Error ? err.message : '거절에 실패했습니다.');
-		} finally {
-			setProcessing(false);
-		}
-	};
-
-	// 1) 로딩
-	if (loading) {
-		return (
-			<div className="crew-invite-page">
-				<p className="crew-invite-page__desc">초대 정보를 불러오는 중...</p>
-			</div>
-		);
-	}
-
-	// 2) 로드 실패 (NotFound, Forbidden 등)
-	if (loadError || !invitation) {
-		return (
-			<div className="crew-invite-page">
-				<div className="crew-invite-page__icon crew-invite-page__icon--danger">
-					<AlertTriangle size={48} />
-				</div>
-				<h1 className="crew-invite-page__title">초대를 확인할 수 없습니다</h1>
-				<p className="crew-invite-page__desc">{loadError || '초대를 찾을 수 없습니다.'}</p>
-				<Button type="button" onClick={() => router.push('/')}>확인</Button>
-			</div>
-		);
-	}
-
-	// 3) 액션 완료 직후
-	if (done) {
-		return (
-			<div className="crew-invite-page">
-				<div className={`crew-invite-page__icon crew-invite-page__icon--${done === 'accepted' ? 'success' : 'danger'}`}>
-					{done === 'accepted' ? <CheckCircle2 size={48} /> : <XCircle size={48} />}
-				</div>
-				<h1 className="crew-invite-page__title">
-					{done === 'accepted' ? '크루 초대를 수락했습니다' : '크루 초대를 거절했습니다'}
-				</h1>
-				<p className="crew-invite-page__desc">
-					{done === 'accepted'
-						? '초대장을 수락하여 크루원으로 등록되었습니다.'
-						: '초대 요청이 거절 처리되었습니다.'
-					}
-				</p>
-				<Button type="button" onClick={() => router.push('/')}>확인</Button>
-			</div>
-		);
-	}
-
-	// 4) 이미 처리된 초대 (Accepted / Declined)
-	if (invitation.status === STATUS_ACCEPTED || invitation.status === STATUS_DECLINED) {
-		const isAccepted = invitation.status === STATUS_ACCEPTED;
-		return (
-			<div className="crew-invite-page">
-				<div className={`crew-invite-page__icon crew-invite-page__icon--${isAccepted ? 'success' : 'danger'}`}>
-					{isAccepted ? <CheckCircle2 size={48} /> : <XCircle size={48} />}
-				</div>
-				<h1 className="crew-invite-page__title">
-					{isAccepted ? '이미 수락한 초대입니다' : '이미 거절한 초대입니다'}
-				</h1>
-				<p className="crew-invite-page__desc">
-					이 초대장은 더 이상 다시 처리할 수 없습니다.
-				</p>
-				<Button type="button" onClick={() => router.push('/')}>확인</Button>
-			</div>
-		);
-	}
-
-	// 5) 만료
-	if (invitation.status === STATUS_EXPIRED || invitation.isExpired) {
-		return (
-			<div className="crew-invite-page">
-				<div className="crew-invite-page__icon crew-invite-page__icon--muted">
-					<Clock size={48} />
-				</div>
-				<h1 className="crew-invite-page__title">만료된 초대입니다</h1>
-				<p className="crew-invite-page__desc">
-					이 초대는 응답 가능한 시간이 지났습니다.<br />
-					크리에이터에게 다시 초대를 요청해 주세요.
-				</p>
-				<Button type="button" onClick={() => router.push('/')}>확인</Button>
-			</div>
-		);
-	}
-
-	// 6) Pending — 정상 액션
-	return (
-		<div className="crew-invite-page">
-			<h1 className="crew-invite-page__title">크루 초대</h1>
-			<p className="crew-invite-page__desc">
-				<strong>{invitation.inviterChannelName}</strong>로부터 <strong>{invitation.crewName}</strong> 크루 초대를 받았습니다.<br />
-				수락하면 해당 크루의 크루원으로 등록됩니다.
-			</p>
-			<div className="crew-invite-page__actions">
-				<Button type="button" onClick={handleAccept} disabled={processing}>
-					{processing ? '처리 중...' : '수락'}
-				</Button>
-				<Button type="button" variant="outline" onClick={handleDecline} disabled={processing}>
-					{processing ? '처리 중...' : '거절'}
-				</Button>
-			</div>
-		</div>
-	);
-}

+ 0 - 64
app/(main)/crew/invite/[id]/style.scss

@@ -1,64 +0,0 @@
-.crew-invite-page {
-	max-width: 480px;
-	margin: 80px auto;
-	padding: 32px 24px;
-	border: 1px solid var(--border-default);
-	border-radius: 12px;
-	background: var(--bg-page);
-	text-align: center;
-
-	@media (max-width: 768px) {
-		margin: 32px 16px;
-		padding: 24px 16px;
-	}
-
-	&__icon {
-		display: flex;
-		justify-content: center;
-		margin: 0 0 16px;
-
-		&--success {
-			color: var(--green-500, #10b981);
-		}
-
-		&--danger {
-			color: var(--red-500, #ef4444);
-		}
-
-		&--muted {
-			color: var(--text-secondary, #6b7280);
-		}
-	}
-
-	&__title {
-		font-size: 1.375rem;
-		font-weight: 700;
-		color: var(--text-primary);
-		margin: 0 0 12px;
-	}
-
-	&__desc {
-		font-size: 0.9375rem;
-		color: var(--text-secondary);
-		line-height: 1.6;
-		margin: 0 0 24px;
-	}
-
-	&__actions {
-		display: flex;
-		gap: 12px;
-		justify-content: center;
-
-		button {
-			min-width: 100px;
-		}
-
-		@media (max-width: 480px) {
-			flex-direction: column;
-
-			button {
-				width: 100%;
-			}
-		}
-	}
-}

+ 0 - 115
app/(main)/crew/join/page.tsx

@@ -1,115 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
-
-export default function CrewJoinPage()
-{
-	const router = useRouter();
-	const [inviteCode, setInviteCode] = useState('');
-	const [nickname, setNickname] = useState('');
-	const [consent, setConsent] = useState(false);
-	const [processing, setProcessing] = useState(false);
-	const [done, setDone] = useState<string|null>(null);
-
-	const handleJoin = async () => {
-		if (processing) {
-			return;
-		}
-
-		if (!inviteCode.trim()) {
-			alert('초대 코드를 입력해 주세요.');
-			return;
-		}
-
-		if (!nickname.trim()) {
-			alert('크루 안에서 사용할 별명을 입력해 주세요.');
-			return;
-		}
-
-		if (!consent) {
-			alert('크루 방송 합류 동의가 필요합니다.');
-			return;
-		}
-
-		setProcessing(true);
-
-		try {
-			const res = await fetchApi<{ crewMemberID: number; crewName: string }>('/api/crew/join', {
-				method: 'POST',
-				body: { inviteCode: inviteCode.trim(), nickname: nickname.trim() }
-			});
-
-			setDone(res.data?.crewName ?? '크루');
-		} catch (err) {
-			alert(err instanceof Error ? err.message : '가입에 실패했습니다.');
-		} finally {
-			setProcessing(false);
-		}
-	};
-
-	if (done) {
-		return (
-			<div className="crew-join-page">
-				<h1 className="crew-join-page__title">✅ 크루 가입이 완료되었습니다</h1>
-				<p className="crew-join-page__desc">
-					<strong>{done}</strong> 크루의 크루원으로 등록되었습니다.<br />
-					이제 해당 크리에이터의 크루 방송에 참여하실 수 있습니다.
-				</p>
-				<Button type="button" onClick={() => router.push('/')}>홈으로</Button>
-			</div>
-		);
-	}
-
-	return (
-		<div className="crew-join-page">
-			<h1 className="crew-join-page__title">크루 합류</h1>
-			<p className="crew-join-page__desc">
-				크리에이터로부터 받은 <strong>초대 코드</strong>를 입력해 크루에 합류합니다.<br />
-				코드 입력 시 해당 크리에이터의 <strong>크루 방송 합류에 동의</strong>하는 것으로 처리됩니다.
-			</p>
-
-			<div className="crew-join-page__form">
-				<div className="crew-join-page__field">
-					<Label htmlFor="invite-code">초대 코드</Label>
-					<Input
-						id="invite-code"
-						value={inviteCode}
-						onChange={e => setInviteCode(e.target.value)}
-						placeholder="발급받은 초대 코드를 붙여 넣어 주세요"
-						autoComplete="off"
-					/>
-				</div>
-
-				<div className="crew-join-page__field">
-					<Label htmlFor="nickname">크루 내 별명</Label>
-					<Input
-						id="nickname"
-						value={nickname}
-						onChange={e => setNickname(e.target.value)}
-						placeholder="크루 안에서 사용할 별명"
-						maxLength={80}
-					/>
-				</div>
-
-				<label className="crew-join-page__consent" htmlFor="consent">
-					<Checkbox id="consent" checked={consent} onCheckedChange={v => setConsent(!!v)} />
-					<span>크루 방송 합류에 동의합니다.</span>
-				</label>
-			</div>
-
-			<div className="crew-join-page__actions">
-				<Button type="button" variant="outline" onClick={() => router.push('/')}>취소</Button>
-				<Button type="button" onClick={handleJoin} disabled={processing}>
-					{processing ? '처리 중...' : '합류'}
-				</Button>
-			</div>
-		</div>
-	);
-}

+ 0 - 75
app/(main)/crew/join/style.scss

@@ -1,75 +0,0 @@
-.crew-join-page {
-	max-width: 520px;
-	margin: 80px auto;
-	padding: 32px 28px;
-	border: 1px solid var(--border-default);
-	border-radius: 12px;
-	background: var(--bg-page);
-
-	@media (max-width: 768px) {
-		margin: 32px 16px;
-		padding: 24px 16px;
-	}
-
-	&__title {
-		font-size: 1.375rem;
-		font-weight: 700;
-		color: var(--text-primary);
-		margin: 0 0 12px;
-		text-align: center;
-	}
-
-	&__desc {
-		font-size: 0.9375rem;
-		color: var(--text-secondary);
-		line-height: 1.6;
-		margin: 0 0 24px;
-		text-align: center;
-
-		strong {
-			color: var(--text-primary);
-			font-weight: 600;
-		}
-	}
-
-	&__form {
-		display: flex;
-		flex-direction: column;
-		gap: 16px;
-		margin-bottom: 24px;
-	}
-
-	&__field {
-		display: flex;
-		flex-direction: column;
-		gap: 6px;
-	}
-
-	&__consent {
-		display: flex;
-		align-items: center;
-		gap: 8px;
-		font-size: 0.9375rem;
-		color: var(--text-primary);
-		cursor: pointer;
-		user-select: none;
-	}
-
-	&__actions {
-		display: flex;
-		gap: 12px;
-		justify-content: center;
-
-		button {
-			min-width: 100px;
-		}
-
-		@media (max-width: 480px) {
-			flex-direction: column-reverse;
-
-			button {
-				width: 100%;
-			}
-		}
-	}
-}

+ 10 - 5
app/(main)/layout.tsx

@@ -2,14 +2,19 @@ import Layout from '@/app/component/Layout';
 import { fetchJson } from '@/lib/utils/server';
 import { ChannelListResponse } from '@/types/response/channel/list';
 import { ChannelListItem } from '@/types/channel';
+import { FEATURE_CHANNEL } from '@/constants/features';
 
 export default async function MainLayout({ children }: { children: React.ReactNode }) {
 
-	const res = await fetchJson<ChannelListResponse>('/api/channel/list');
-	const initialChannels: ChannelListItem[] = (res.data?.channels ?? []).map((ch, idx) => ({
-		...ch,
-		_offlineRank: idx
-	}));
+	// 채널 기능 OFF: 사이드바 미노출이므로 채널 목록 조회 생략
+	let initialChannels: ChannelListItem[] = [];
+	if (FEATURE_CHANNEL) {
+		const res = await fetchJson<ChannelListResponse>('/api/channel/list');
+		initialChannels = (res.data?.channels ?? []).map((ch, idx) => ({
+			...ch,
+			_offlineRank: idx
+		}));
+	}
 
 	return (
 		<Layout initialChannels={initialChannels}>

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

@@ -7,14 +7,6 @@ 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: '시스템'
 };

+ 13 - 9
app/(main)/page.tsx

@@ -4,6 +4,7 @@ import type { LiveChannelListResponse } from '@/types/response/channel/live-list
 import HomeBannerCarousel from './_components/HomeBannerCarousel';
 import LiveChannelGrid from './_components/LiveChannelGrid';
 import LiveChannelEmpty from './_components/LiveChannelEmpty';
+import { FEATURE_CHANNEL } from '@/constants/features';
 import './_components/home.scss';
 
 const BANNER_POSITION_CODE = 'main-top';
@@ -33,9 +34,10 @@ async function loadLiveChannels(): Promise<{ channels: LiveChannelListResponse['
 }
 
 export default async function Home() {
+	// 채널 기능 OFF: 라이브 채널 섹션 미노출이므로 조회 생략
 	const [banners, live] = await Promise.all([
 		loadActiveBanners(),
-		loadLiveChannels()
+		FEATURE_CHANNEL ? loadLiveChannels() : Promise.resolve({ channels: [], total: 0 })
 	]);
 
 	return (
@@ -43,14 +45,16 @@ export default async function Home() {
 			<div className="home__inner">
 				<HomeBannerCarousel items={banners} />
 
-				{live.channels.length === 0 ? (
-					<LiveChannelEmpty />
-				) : (
-					<LiveChannelGrid
-						initialChannels={live.channels}
-						initialTotal={live.total}
-						pageSize={LIVE_PAGE_SIZE}
-					/>
+				{FEATURE_CHANNEL && (
+					live.channels.length === 0 ? (
+						<LiveChannelEmpty />
+					) : (
+						<LiveChannelGrid
+							initialChannels={live.channels}
+							initialTotal={live.total}
+							pageSize={LIVE_PAGE_SIZE}
+						/>
+					)
 				)}
 			</div>
 		</div>

+ 0 - 30
app/(main)/ranking/_components/CategoryFilter.tsx

@@ -1,30 +0,0 @@
-'use client';
-
-interface Props {
-	value: string;
-	onChange: (category: string) => void;
-}
-
-const CATEGORIES = [
-	{ key: 'all', label: '전체' },
-	{ key: 'game', label: '게임' },
-	{ key: 'virtual', label: '버츄얼' },
-	{ key: 'talk', label: '토크' },
-	{ key: 'music', label: '음악' },
-	{ key: 'food', label: '먹방' }
-];
-
-export default function CategoryFilter({ value, onChange }: Props) {
-	return (
-		<select
-			title="카테고리 선택"
-			className="ranking__filter-select"
-			value={value}
-			onChange={(e) => onChange(e.target.value)}
-		>
-			{CATEGORIES.map((c) => (
-				<option key={c.key} value={c.key}>{c.label}</option>
-			))}
-		</select>
-	);
-}

+ 0 - 26
app/(main)/ranking/_components/MemberGradeBadge.tsx

@@ -1,26 +0,0 @@
-'use client';
-
-import type { CSSProperties } from 'react';
-import type { MemberGradeDto } from '@/types/response/ranking';
-
-interface Props {
-	grade: MemberGradeDto|null|undefined;
-}
-
-export default function MemberGradeBadge({ grade }: Props) {
-	if (!grade) {
-		return null;
-	}
-
-	const badgeStyle = grade.textColor ? ({ '--grade-color': grade.textColor } as CSSProperties) : undefined;
-
-	return (
-		<span className="ranking__grade-badge" style={badgeStyle} title={grade.korName}>
-			{grade.image ? (
-				// eslint-disable-next-line @next/next/no-img-element
-				<img src={grade.image} alt="" className="ranking__grade-icon" />
-			) : null}
-			<span className="ranking__grade-name">{grade.korName}</span>
-		</span>
-	);
-}

+ 0 - 42
app/(main)/ranking/_components/MyRankBar.tsx

@@ -1,42 +0,0 @@
-'use client';
-
-import { User } from 'lucide-react';
-import type { MyRankInfo, RankingType } from '@/types/response/ranking';
-
-interface Props {
-	info: MyRankInfo;
-	type: RankingType;
-}
-
-function formatAmount(amount: number): string {
-	if (amount >= 100000000) {
-		return `${(amount / 100000000).toFixed(1)}억`;
-	}
-
-	if (amount >= 10000) {
-		return `${(amount / 10000).toFixed(1)}만`;
-	}
-
-	return amount.toLocaleString('ko-KR');
-}
-
-export default function MyRankBar({ info, type }: Props) {
-	const suffix = type === 'donor' ? 'P' : '원';
-	return (
-		<aside className="ranking__my-bar" role="status">
-			<div className="ranking__my-bar-icon">
-				<User size={18} />
-			</div>
-			<div className="ranking__my-bar-text">
-				<strong>내 순위</strong>
-				<span>{info.rank}위</span>
-				{info.rankChange !== null && info.rankChange !== 0 ? (
-					<em className={info.rankChange > 0 ? 'ranking__my-bar-up' : 'ranking__my-bar-down'}>
-						{info.rankChange > 0 ? `▲${info.rankChange}` : `▼${Math.abs(info.rankChange)}`}
-					</em>
-				) : null}
-			</div>
-			<div className="ranking__my-bar-amount">{formatAmount(info.moneyAmount)}{suffix}</div>
-		</aside>
-	);
-}

+ 0 - 32
app/(main)/ranking/_components/PeriodFilter.tsx

@@ -1,32 +0,0 @@
-'use client';
-
-import type { RankingPeriod } from '@/types/response/ranking';
-
-interface Props {
-	value: RankingPeriod;
-	onChange: (period: RankingPeriod) => void;
-}
-
-const PERIODS: { key: RankingPeriod; label: string }[] = [
-	{ key: 'today', label: '오늘' },
-	{ key: 'yesterday', label: '어제' },
-	{ key: 'week', label: '이번 주' },
-	{ key: 'month', label: '이번 달' },
-	{ key: 'last-month', label: '지난 달' },
-	{ key: 'all', label: '전체' }
-];
-
-export default function PeriodFilter({ value, onChange }: Props) {
-	return (
-		<select
-			title="기간 선택"
-			className="ranking__filter-select"
-			value={value}
-			onChange={(e) => onChange(e.target.value as RankingPeriod)}
-		>
-			{PERIODS.map((p) => (
-				<option key={p.key} value={p.key}>{p.label}</option>
-			))}
-		</select>
-	);
-}

+ 0 - 93
app/(main)/ranking/_components/Podium.tsx

@@ -1,93 +0,0 @@
-'use client';
-
-import Link from 'next/link';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Crown, Medal } from 'lucide-react';
-import type { RankingEntry, RankingType } from '@/types/response/ranking';
-
-interface Props {
-	top3: RankingEntry[];
-	type: RankingType;
-}
-
-function formatAmount(amount: number): string {
-	if (amount >= 100000000) {
-		return `${(amount / 100000000).toFixed(1)}억`;
-	}
-
-	if (amount >= 10000) {
-		return `${(amount / 10000).toFixed(1)}만`;
-	}
-
-	return amount.toLocaleString('ko-KR');
-}
-
-function WinnerCard({ entry, rank, type }: { entry: RankingEntry|undefined; rank: 1|2|3; type: RankingType }) {
-	const modifier = rank === 1 ? 'first' : rank === 2 ? 'second' : 'third';
-	const linkHref = entry && !entry.isAnonymous ? `/channel/${entry.memberID}` : '#';
-
-	if (!entry) {
-		return (
-			<div className={`ranking-podium__winner ranking-podium__winner--${modifier} ranking-podium__winner--empty`}>
-				<div className="ranking-podium__rank-number">{rank}</div>
-				<Avatar className="ranking-podium__avatar">
-					<AvatarFallback>-</AvatarFallback>
-				</Avatar>
-				<div className="ranking-podium__name">대기 중</div>
-				<div className="ranking-podium__amount">-</div>
-			</div>
-		);
-	}
-
-	return (
-		<div className={`ranking-podium__winner ranking-podium__winner--${modifier}`}>
-			{rank === 1 && (
-				<>
-					<div className="ranking-podium__shimmer" aria-hidden="true" />
-					<Crown className="ranking-podium__crown" size={44} aria-hidden="true" />
-				</>
-			)}
-			{rank !== 1 && (
-				<Medal className="ranking-podium__medal" size={32} aria-hidden="true" />
-			)}
-			<div className="ranking-podium__rank-number">{rank}</div>
-			<Link href={linkHref} className="ranking-podium__link">
-				<Avatar className="ranking-podium__avatar">
-					{(entry.channelThumb ?? entry.memberThumb) ? <AvatarImage src={(entry.channelThumb ?? entry.memberThumb)!} alt={entry.memberName} /> : null}
-					<AvatarFallback>{entry.memberName?.[0] ?? '?'}</AvatarFallback>
-				</Avatar>
-				<div className="ranking-podium__name">{entry.memberName}</div>
-				{entry.channelName && entry.channelName !== entry.memberName ? (
-					<div className="ranking-podium__channel">{entry.channelName}</div>
-				) : null}
-			</Link>
-			<div className="ranking-podium__amount">
-				{formatAmount(entry.moneyAmount)}
-				{type === 'donor' ? 'P' : '원'}
-			</div>
-			{type !== 'donor' && entry.donorCount > 0 ? (
-				<div className="ranking-podium__meta">후원자 {entry.donorCount.toLocaleString('ko-KR')}명</div>
-			) : null}
-		</div>
-	);
-}
-
-export default function Podium({ top3, type }: Props) {
-	if (top3.length === 0) {
-		return null;
-	}
-
-	const first = top3.find((e) => e.rank === 1);
-	const second = top3.find((e) => e.rank === 2);
-	const third = top3.find((e) => e.rank === 3);
-
-	return (
-		<div className="ranking-podium" role="region" aria-label="상위 3위 시상대">
-			<div className="ranking-podium__stage">
-				<WinnerCard entry={second} rank={2} type={type} />
-				<WinnerCard entry={first} rank={1} type={type} />
-				<WinnerCard entry={third} rank={3} type={type} />
-			</div>
-		</div>
-	);
-}

+ 0 - 141
app/(main)/ranking/_components/RankingList.tsx

@@ -1,141 +0,0 @@
-'use client';
-
-import type { CSSProperties } from 'react';
-import Link from 'next/link';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { ArrowUp, ArrowDown, Minus } from 'lucide-react';
-import MemberGradeBadge from './MemberGradeBadge';
-import type { RankingEntry, RankingType } from '@/types/response/ranking';
-
-interface Props {
-	entries: RankingEntry[];
-	type: RankingType;
-	viewerMemberID?: number|null;
-}
-
-function formatAmount(amount: number): string {
-	if (amount >= 100000000) {
-		return `${(amount / 100000000).toFixed(1)}억`;
-	}
-
-	if (amount >= 10000) {
-		return `${(amount / 10000).toFixed(1)}만`;
-	}
-
-	return amount.toLocaleString('ko-KR');
-}
-
-function formatDuration(sec: number): string {
-	if (sec <= 0) {
-		return '-';
-	}
-
-	const hours = Math.floor(sec / 3600);
-
-	if (hours >= 10000) {
-		return `${Math.floor(hours / 10000)}만h`;
-	}
-
-	if (hours >= 1000) {
-		return `${(hours / 1000).toFixed(1)}K h`;
-	}
-
-	return `${hours}h`;
-}
-
-function RankChange({ change }: { change: number|null }) {
-	if (change === null || change === undefined) {
-		return <span className="ranking__change ranking__change--new">NEW</span>;
-	}
-
-	if (change > 0) {
-		return (
-			<span className="ranking__change ranking__change--up">
-				<ArrowUp size={12} />{change}
-			</span>
-		);
-	}
-
-	if (change < 0) {
-		return (
-			<span className="ranking__change ranking__change--down">
-				<ArrowDown size={12} />{Math.abs(change)}
-			</span>
-		);
-	}
-
-	return (
-		<span className="ranking__change ranking__change--same">
-			<Minus size={12} />
-		</span>
-	);
-}
-
-export default function RankingList({ entries, type, viewerMemberID }: Props)
-{
-	if (entries.length === 0) {
-		return <p className="ranking__empty">집계된 순위가 없습니다.</p>;
-	}
-
-	const topScore = entries[0]?.score ?? entries[0]?.moneyAmount ?? 1;
-
-	return (
-		<div className="ranking__list">
-			<div className="ranking__list-header">
-				<span className='text-center'>순위</span>
-				<span className='text-center'>변동</span>
-				<span className='ps-2'>회원</span>
-				<span className='text-center'>진행도</span>
-				<span className='text-center'>금액</span>
-				{type === 'donor' ? null : <span className='text-center'>방송</span>}
-			</div>
-
-			{entries.map((entry) => {
-				const isMine = viewerMemberID != null && viewerMemberID === entry.memberID;
-				const progress = topScore > 0 ? Math.min(100, (entry.moneyAmount / topScore) * 100) : 0;
-				const targetHref = entry.isAnonymous ? '#' : `/channel/${entry.memberID}`;
-
-				return (
-					<div key={`${entry.memberID}-${entry.rank}`} className={`ranking__row ${isMine ? 'ranking__row--mine' : ''}`}>
-						<div className="ranking__row-rank">#{entry.rank}</div>
-						<div className="ranking__row-change">
-							<RankChange change={entry.rankChange} />
-						</div>
-						<div className="ranking__row-member">
-							<Link href={targetHref} className="ranking__row-member-link">
-								<Avatar className="h-8 w-8">
-									{(entry.channelThumb ?? entry.memberThumb) ? <AvatarImage src={(entry.channelThumb ?? entry.memberThumb)!} alt={entry.memberName} /> : null}
-									<AvatarFallback>{entry.memberName?.[0] ?? '?'}</AvatarFallback>
-								</Avatar>
-								<span className="ranking__row-name">
-									<span className="ranking__row-name-main">
-										{entry.memberName}
-										<MemberGradeBadge grade={entry.memberGrade} />
-									</span>
-									{entry.channelName && entry.channelName !== entry.memberName ? (
-										<small className="ranking__row-channel">{entry.channelName}</small>
-									) : null}
-								</span>
-							</Link>
-						</div>
-						<div className="ranking__row-progress">
-							<div className="ranking__row-progress-bar" style={{ '--progress': `${progress}%` } as CSSProperties}>
-								<span />
-							</div>
-						</div>
-						<div className="ranking__row-amount">
-							{formatAmount(entry.moneyAmount)}
-							{type === 'donor' ? 'P' : '원'}
-						</div>
-						{type !== 'donor' ? (
-							<div className="ranking__row-broadcast">
-								<span>{formatDuration(entry.broadcastSec)}</span>
-								<small>{formatAmount(entry.viewCount)} 조회</small>
-							</div>
-						) : null}
-					</div>
-				);
-			})}
-		</div>
-	);
-}

+ 0 - 103
app/(main)/ranking/_components/RankingTabs.tsx

@@ -1,103 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useRef } from 'react';
-import Link from 'next/link';
-import { Trophy, Users, Heart, TrendingUp, Crown, ChevronLeft, ChevronRight } from 'lucide-react';
-import useDragScroll from '@/hooks/useDragScroll';
-
-type RankingTabKey = 'comprehensive'|'creator'|'donor'|'rising'|'hall-of-fame';
-
-interface Props {
-	active: RankingTabKey;
-}
-
-const SCROLL_AMOUNT = 120;
-
-const TABS: { key: RankingTabKey; href: string; label: string; icon: React.ElementType }[] = [
-	{ key: 'hall-of-fame', href: '/ranking/hall-of-fame', label: '명예의 전당', icon: Crown },
-	{ key: 'comprehensive', href: '/ranking', label: '종합', icon: Trophy },
-	{ key: 'creator', href: '/ranking/creator', label: '크리에이터', icon: Users },
-	{ key: 'donor', href: '/ranking/donor', label: '후원자', icon: Heart },
-	{ key: 'rising', href: '/ranking/rising', label: '급상승', icon: TrendingUp }
-];
-
-export default function RankingTabs({ active }: Props) {
-	const dragScroll = useDragScroll<HTMLElement>();
-	const activeItemRef = useRef<HTMLAnchorElement>(null);
-
-	// 활성 탭(active) 변경 시 뷰포트 중앙으로 스크롤해 모바일에서 현재 위치 가시화
-	useEffect(() => {
-		const target = activeItemRef.current;
-		if (!target) {
-			return;
-		}
-
-		const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-		target.scrollIntoView({
-			behavior: prefersReduced ? 'auto' : 'smooth',
-			inline: 'center',
-			block: 'nearest'
-		});
-	}, [active]);
-
-	const scroll = useCallback((dir: 'left'|'right') => {
-		const el = dragScroll.ref.current;
-		if (!el) {
-			return;
-		}
-
-		const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
-		el.scrollBy({
-			left: dir === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT,
-			behavior: prefersReduced ? 'auto' : 'smooth'
-		});
-	}, [dragScroll.ref]);
-
-	return (
-		<div className="ranking__tabs-wrap">
-			<button
-				type="button"
-				className="ranking__tabs-arrow ranking__tabs-arrow--left"
-				onClick={() => scroll('left')}
-				aria-label="이전 탭으로 스크롤"
-			>
-				<ChevronLeft size={18} />
-			</button>
-			<nav
-				className="ranking__tabs"
-				aria-label="순위 탭"
-				ref={dragScroll.ref}
-				onMouseDown={dragScroll.onMouseDown}
-				onMouseMove={dragScroll.onMouseMove}
-				onMouseUp={dragScroll.onMouseUp}
-				onMouseLeave={dragScroll.onMouseLeave}
-			>
-				{TABS.map((tab) => {
-					const Icon = tab.icon;
-					const isActive = tab.key === active;
-					return (
-						<Link
-							key={tab.key}
-							href={tab.href}
-							className={`ranking__tabs-item ${isActive ? 'ranking__tabs-item--active' : ''}`}
-							aria-current={isActive ? 'page' : undefined}
-							ref={isActive ? activeItemRef : undefined}
-							draggable={false}
-						>
-							<Icon size={18} />
-							<span>{tab.label}</span>
-						</Link>
-					);
-				})}
-			</nav>
-			<button
-				type="button"
-				className="ranking__tabs-arrow ranking__tabs-arrow--right"
-				onClick={() => scroll('right')}
-				aria-label="다음 탭으로 스크롤"
-			>
-				<ChevronRight size={18} />
-			</button>
-		</div>
-	);
-}

+ 0 - 116
app/(main)/ranking/_components/RankingView.tsx

@@ -1,116 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import useAuth from '@/hooks/useAuth';
-import { useMemberContext } from '@/contexts/memberProvider';
-import Loading from '@/app/component/Loading';
-import RankingTabs from './RankingTabs';
-import PeriodFilter from './PeriodFilter';
-import CategoryFilter from './CategoryFilter';
-import Podium from './Podium';
-import RankingList from './RankingList';
-import MyRankBar from './MyRankBar';
-import type { RankingResponse, RankingPeriod, RankingType } from '@/types/response/ranking';
-
-interface Props {
-	type: 'comprehensive'|'creator'|'donor';
-	basePath: string;
-}
-
-export default function RankingView({ type, basePath }: Props)
-{
-	const router = useRouter();
-	const searchParams = useSearchParams();
-	const { isAuthenticated } = useAuth();
-	const { member } = useMemberContext();
-
-	const initialPeriod = (searchParams.get('period') as RankingPeriod) || 'month';
-	const initialCategory = searchParams.get('category') || 'all';
-
-	const [period, setPeriod] = useState<RankingPeriod>(initialPeriod);
-	const [category, setCategory] = useState<string>(initialCategory);
-	const [data, setData] = useState<RankingResponse|null>(null);
-	const [loading, setLoading] = useState(true);
-
-	const viewerMemberID = isAuthenticated ? member?.id ?? null : null;
-
-	const loadData = useCallback(() => {
-		setLoading(true);
-		const qs = new URLSearchParams();
-		qs.set('type', type);
-		qs.set('period', period);
-		qs.set('category', category);
-		qs.set('page', '1');
-		qs.set('perPage', '50');
-
-		fetchApi<RankingResponse>(`/api/ranking?${qs.toString()}`, { silent: true }).then((res) => {
-			setData(res.data ?? null);
-		}).catch(() => {
-			setData(null);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}, [type, period, category]);
-
-	useEffect(() => {
-		loadData();
-	}, [loadData]);
-
-	useEffect(() => {
-		const qs = new URLSearchParams();
-
-		if (period !== 'month') {
-			qs.set('period', period);
-		}
-
-		if (category !== 'all') {
-			qs.set('category', category);
-		}
-
-		const query = qs.toString();
-		router.replace(query ? `${basePath}?${query}` : basePath, { scroll: false });
-	}, [period, category, basePath, router]);
-
-	const asRankingType: RankingType = type;
-
-	const snapshotLabel = useMemo(() => {
-		if (!data?.snapshotAt) {
-			return null;
-		}
-		const d = new Date(data.snapshotAt);
-		return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} 기준`;
-	}, [data?.snapshotAt]);
-
-	return (
-		<div className="ranking">
-			<header className="ranking__header">
-				<h1>순위</h1>
-			</header>
-
-			<RankingTabs active={type} />
-
-			<div className="ranking__filters">
-				<PeriodFilter value={period} onChange={setPeriod} />
-				{type !== 'donor' ? <CategoryFilter value={category} onChange={setCategory} /> : null}
-			</div>
-
-			{loading ? (
-				<Loading type={2} />
-			) : (
-				<>
-					{data && data.top3.length > 0 ? (
-						<Podium top3={data.top3} type={asRankingType} />
-					) : null}
-
-					{snapshotLabel ? <p className="ranking__list-note">{snapshotLabel}</p> : null}
-
-					<RankingList entries={data?.list ?? []} type={asRankingType} viewerMemberID={viewerMemberID} />
-
-					{data?.myRank ? <MyRankBar info={data.myRank} type={asRankingType} /> : null}
-				</>
-			)}
-		</div>
-	);
-}

+ 0 - 5
app/(main)/ranking/creator/page.tsx

@@ -1,5 +0,0 @@
-import RankingView from '../_components/RankingView';
-
-export default function RankingCreatorPage() {
-	return <RankingView type="creator" basePath="/ranking/creator" />;
-}

+ 0 - 5
app/(main)/ranking/donor/page.tsx

@@ -1,5 +0,0 @@
-import RankingView from '../_components/RankingView';
-
-export default function RankingDonorPage() {
-	return <RankingView type="donor" basePath="/ranking/donor" />;
-}

+ 0 - 268
app/(main)/ranking/hall-of-fame/page.tsx

@@ -1,268 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useMemo, useState } from 'react';
-import type { CSSProperties } from 'react';
-import Link from 'next/link';
-import { useRouter, useSearchParams } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Crown, Medal, Trophy } from 'lucide-react';
-import Loading from '@/app/component/Loading';
-import RankingTabs from '../_components/RankingTabs';
-import Podium from '../_components/Podium';
-import type { HallOfFameResponse, HallPeriod, HallEntry, RankingEntry } from '@/types/response/ranking';
-
-const PERIOD_ITEMS: { key: HallPeriod; label: string }[] = [
-	{ key: 'today', label: '오늘' },
-	{ key: 'week', label: '1주일' },
-	{ key: 'month', label: '1개월' },
-	{ key: 'quarter', label: '3개월' },
-	{ key: 'half', label: '6개월' },
-	{ key: 'all', label: '전체' }
-];
-
-function formatAmount(amount: number): string {
-	if (amount >= 100000000) {
-		return `${(amount / 100000000).toFixed(1)}억`;
-	}
-
-	if (amount >= 10000) {
-		return `${(amount / 10000).toFixed(1)}만`;
-	}
-
-	return amount.toLocaleString('ko-KR');
-}
-
-function rankTier(rank: number): 1|2|3 {
-	if (rank <= 10) {
-		return 1;
-	}
-
-	if (rank <= 50) {
-		return 2;
-	}
-
-	return 3;
-}
-
-// Podium 은 RankingEntry 타입을 받는다. HallEntry → 최소 RankingEntry 형태로 어댑트.
-function adaptToRankingEntry(entry: HallEntry): RankingEntry {
-	return {
-		rank: entry.rank,
-		previousRank: null,
-		rankChange: null,
-		memberID: entry.memberID,
-		memberName: entry.memberName,
-		memberThumb: entry.memberThumb,
-		channelName: entry.channelName,
-		channelThumb: null,
-		category: null,
-		score: entry.cumulativeAmount,
-		moneyAmount: entry.cumulativeAmount,
-		donorCount: entry.donorCount,
-		broadcastSec: 0,
-		viewCount: 0,
-		likeCount: 0,
-		isAnonymous: entry.isAnonymous,
-		memberGrade: null
-	};
-}
-
-export default function HallOfFamePage() {
-	const router = useRouter();
-	const searchParams = useSearchParams();
-
-	const initialPeriod = ((): HallPeriod => {
-		const p = searchParams.get('period') as HallPeriod|null;
-		return p && PERIOD_ITEMS.some(x => x.key === p) ? p : 'all';
-	})();
-
-	const [period, setPeriod] = useState<HallPeriod>(initialPeriod);
-	const [data, setData] = useState<HallOfFameResponse|null>(null);
-	const [loading, setLoading] = useState(true);
-
-	const load = useCallback(() => {
-		setLoading(true);
-		const qs = new URLSearchParams();
-		qs.set('period', period);
-		qs.set('limit', '100');
-
-		fetchApi<HallOfFameResponse>(`/api/ranking/hall-of-fame?${qs.toString()}`, { silent: true }).then((res) => {
-			setData(res.data ?? null);
-		}).catch(() => {
-			setData(null);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}, [period]);
-
-	useEffect(() => {
-		load();
-	}, [load]);
-
-	useEffect(() => {
-		const qs = new URLSearchParams();
-
-		if (period !== 'all') {
-			qs.set('period', period);
-		}
-
-		const query = qs.toString();
-		router.replace(query ? `/ranking/hall-of-fame?${query}` : '/ranking/hall-of-fame', { scroll: false });
-	}, [period, router]);
-
-	const top3 = useMemo(() => {
-		if (!data) {
-			return [];
-		}
-
-		return data.top100.slice(0, 3).map(adaptToRankingEntry);
-	}, [data]);
-
-	const restEntries = useMemo(() => {
-		if (!data) {
-			return [];
-		}
-
-		return data.top100;
-	}, [data]);
-
-	const topAmount = restEntries[0]?.cumulativeAmount ?? 1;
-
-	return (
-		<div className="ranking">
-			<header className="ranking__header">
-				<h1>명예의 전당</h1>
-				<p className="ranking__header-sub">후원자 누적 명예의 전당 — 후원해주신 분들을 기립니다</p>
-			</header>
-
-			<RankingTabs active="hall-of-fame" />
-
-			<div className="ranking__hall-controls">
-				<div className="ranking__hall-period" role="group" aria-label="검색 기간">
-					{PERIOD_ITEMS.map(item => (
-						<button
-							key={item.key}
-							type="button"
-							className={`ranking__hall-period-btn${period === item.key ? ' ranking__hall-period-btn--active' : ''}`}
-							aria-pressed={period === item.key}
-							onClick={() => setPeriod(item.key)}
-						>
-							{item.label}
-						</button>
-					))}
-				</div>
-			</div>
-
-			{loading ? (
-				<Loading type={2} />
-			) : (
-				<>
-					<p className="ranking__list-note">후원자 누적 후원 기준 TOP 100</p>
-
-					{top3.length > 0 ? (
-						<Podium top3={top3} type="donor" />
-					) : null}
-
-					<section className="ranking__hall-top100">
-						<h2>
-							<Trophy size={18} aria-hidden="true" />
-							TOP 100
-						</h2>
-
-						{restEntries.length > 0 ? (
-							<div className="ranking__list">
-								<div className="ranking__list-header ranking__list-header--aligned">
-									<span>순위</span>
-									<span>등급</span>
-									<span>후원자</span>
-									<span>진행도</span>
-									<span>누적 금액</span>
-									<span>후원 채널</span>
-								</div>
-
-								{restEntries.map(entry => {
-									const tier = rankTier(entry.rank);
-									const progress = topAmount > 0
-										? Math.min(100, (entry.cumulativeAmount / topAmount) * 100)
-										: 0;
-									const isMedal = entry.rank <= 3;
-									const rowClass = isMedal
-										? `ranking__row ranking__row--rank-${entry.rank}`
-										: 'ranking__row';
-
-									const linkHref = entry.isAnonymous ? '#' : `/channel/${entry.memberID}`;
-
-									return (
-										<div key={`${entry.memberID}-${entry.rank}`} className={rowClass}>
-											<div className="ranking__row-rank">
-												{isMedal ? (
-													<span className={`ranking__medal-badge ranking__medal-badge--${entry.rank}`} aria-label={`${entry.rank}위`}>
-														{entry.rank === 1 ? <Crown size={16} /> : <Medal size={16} />}
-														<strong>#{entry.rank}</strong>
-													</span>
-												) : (
-													<>#{entry.rank}</>
-												)}
-											</div>
-											<div className="ranking__row-change">
-												{entry.gradeKorName ? (
-													<span
-														className="ranking__grade-badge"
-														style={entry.gradeTextColor ? ({ '--grade-color': entry.gradeTextColor } as CSSProperties) : undefined}
-													>
-														{entry.gradeImage ? (
-															// eslint-disable-next-line @next/next/no-img-element
-															<img src={entry.gradeImage} alt="" className="ranking__grade-icon" />
-														) : null}
-														{entry.gradeKorName}
-													</span>
-												) : null}
-											</div>
-											<div className="ranking__row-member">
-												<Link
-													href={linkHref}
-													className="ranking__row-member-link"
-													onClick={entry.isAnonymous ? (e) => e.preventDefault() : undefined}
-													aria-disabled={entry.isAnonymous || undefined}
-												>
-													<Avatar className="h-8 w-8">
-														{entry.memberThumb ? <AvatarImage src={entry.memberThumb} alt={entry.memberName} /> : null}
-														<AvatarFallback>{entry.memberName?.[0] ?? '?'}</AvatarFallback>
-													</Avatar>
-													<span className="ranking__row-name">
-														<span className="ranking__row-name-main">{entry.memberName}</span>
-													</span>
-												</Link>
-											</div>
-											<div className="ranking__row-progress">
-												<div
-													className={`ranking__row-progress-bar ranking__row-progress-bar--tier-${tier}`}
-													style={{ '--progress': `${progress}%` } as CSSProperties}
-													role="progressbar"
-													aria-valuenow={Math.round(progress)}
-													aria-valuemin={0}
-													aria-valuemax={100}
-												>
-													<span />
-												</div>
-											</div>
-											<div className="ranking__row-amount ranking__row-amount--accent">
-												{formatAmount(entry.cumulativeAmount)}원
-											</div>
-											<div className="ranking__row-broadcast ranking__row-broadcast--accent">
-												<span>{entry.donorCount.toLocaleString('ko-KR')}개</span>
-											</div>
-										</div>
-									);
-								})}
-							</div>
-						) : (
-							<p className="ranking__empty">아직 집계된 데이터가 없습니다.</p>
-						)}
-					</section>
-				</>
-			)}
-		</div>
-	);
-}

+ 0 - 16
app/(main)/ranking/layout.tsx

@@ -1,16 +0,0 @@
-import type { Metadata } from 'next';
-import type { ReactNode } from 'react';
-import './style.scss';
-
-export const metadata: Metadata = {
-	title: 'DPOT 순위',
-	description: '크리에이터·후원자 순위'
-};
-
-export default function RankingLayout({ children }: { children: ReactNode }) {
-	return (
-		<div id="rankingPage">
-			{children}
-		</div>
-	);
-}

+ 0 - 5
app/(main)/ranking/page.tsx

@@ -1,5 +0,0 @@
-import RankingView from './_components/RankingView';
-
-export default function RankingComprehensivePage() {
-	return <RankingView type="comprehensive" basePath="/ranking" />;
-}

+ 0 - 105
app/(main)/ranking/rising/page.tsx

@@ -1,105 +0,0 @@
-'use client';
-
-import { useCallback, useEffect, useState } from 'react';
-import Link from 'next/link';
-import { fetchApi } from '@/lib/utils/client';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { TrendingUp } from 'lucide-react';
-import Loading from '@/app/component/Loading';
-import RankingTabs from '../_components/RankingTabs';
-import type { RisingResponse } from '@/types/response/ranking';
-
-function formatAmount(amount: number): string {
-	if (amount >= 100000000) {
-		return `${(amount / 100000000).toFixed(1)}억`;
-	}
-
-	if (amount >= 10000) {
-		return `${(amount / 10000).toFixed(1)}만`;
-	}
-
-	return amount.toLocaleString('ko-KR');
-}
-
-export default function RankingRisingPage() {
-	const [data, setData] = useState<RisingResponse|null>(null);
-	const [loading, setLoading] = useState(true);
-
-	const load = useCallback(() => {
-		setLoading(true);
-
-		fetchApi<RisingResponse>('/api/ranking/rising?limit=20', { silent: true }).then((res) => {
-			setData(res.data ?? null);
-		}).catch(() => {
-			setData(null);
-		}).finally(() => {
-			setLoading(false);
-		});
-	}, []);
-
-	useEffect(() => {
-		load();
-	}, [load]);
-
-	return (
-		<div className="ranking">
-			<header className="ranking__header">
-				<h1>급상승 채널</h1>
-			</header>
-
-			<RankingTabs active="rising" />
-
-			{loading ? <Loading type={2} /> : (
-				<>
-					<p className="ranking__list-note">최근 7일 vs 이전 7일 증가율 기준</p>
-					<div className="ranking__list">
-						<div className="ranking__list-header ranking__list-header--aligned">
-							<span>순위</span>
-							<span>변동</span>
-							<span>채널</span>
-							<span>증가율</span>
-							<span>최근 후원</span>
-							<span>후원자</span>
-						</div>
-
-						{(data?.entries ?? []).length === 0 ? (
-							<p className="ranking__empty">급상승 데이터가 아직 없습니다.</p>
-						) : (
-							data!.entries.map((entry, idx) => (
-								<div key={entry.memberID} className="ranking__row">
-									<div className="ranking__row-rank">#{idx + 1}</div>
-									<div className="ranking__row-change">
-										<TrendingUp size={16} className="text-green-600" />
-									</div>
-									<div className="ranking__row-member">
-										<Link href={`/channel/${entry.memberID}`} className="ranking__row-member-link">
-											<Avatar className="h-8 w-8">
-												{entry.memberThumb ? <AvatarImage src={entry.memberThumb} alt={entry.memberName} /> : null}
-												<AvatarFallback>{entry.memberName?.[0] ?? '?'}</AvatarFallback>
-											</Avatar>
-											<span className="ranking__row-name">
-												<span className="ranking__row-name-main">{entry.memberName}</span>
-												{entry.channelName && entry.channelName !== entry.memberName ? (
-													<small className="ranking__row-channel">{entry.channelName}</small>
-												) : null}
-											</span>
-										</Link>
-									</div>
-									<div className="ranking__row-progress">
-										<strong className="text-green-600">
-											+{entry.growthRate.toFixed(0)}%
-										</strong>
-									</div>
-									<div className="ranking__row-amount">{formatAmount(entry.recentAmount)}원</div>
-									<div className="ranking__row-broadcast">
-										<span>{entry.donorCount}명</span>
-									</div>
-								</div>
-							))
-						)}
-					</div>
-				</>
-			)}
-		</div>
-	);
-}

+ 0 - 880
app/(main)/ranking/style.scss

@@ -1,880 +0,0 @@
-$bp-mobile-max: 767px;
-$bp-tablet-min: 768px;
-$bp-tablet-max: 1023px;
-$bp-pc: 1024px;
-
-#rankingPage {
-	max-width: 1200px;
-	margin: 0 auto;
-	padding: 1.5rem 1rem 4rem;
-
-	@media (max-width: $bp-mobile-max) {
-		padding: 1rem 0.75rem 6rem;
-	}
-}
-
-.ranking {
-	&__header {
-		display: flex;
-		align-items: baseline;
-		justify-content: space-between;
-		gap: 0.75rem;
-		flex-wrap: wrap;
-		padding: 0.25rem 0 1rem;
-
-		h1 {
-			font-size: 1.75rem;
-			font-weight: 700;
-			margin: 0;
-
-			@media (max-width: $bp-mobile-max) {
-				font-size: 1.4rem;
-			}
-		}
-	}
-
-	&__tabs-wrap {
-		display: flex;
-		align-items: stretch;
-		gap: 0.25rem;
-		margin-bottom: 1rem;
-		border-bottom: 1px solid hsl(var(--border));
-	}
-
-	&__tabs-arrow {
-		// 기본 숨김 (데스크톱: 탭 5개가 모두 보여 불필요)
-		display: none;
-		align-items: center;
-		justify-content: center;
-		width: 32px;
-		flex-shrink: 0;
-		background: transparent;
-		border: none;
-		color: hsl(var(--muted-foreground));
-		cursor: pointer;
-		transition: color 0.15s ease;
-
-		&:hover {
-			color: hsl(var(--foreground));
-		}
-
-		&:focus-visible {
-			outline: 2px solid hsl(var(--primary));
-			outline-offset: -2px;
-			border-radius: 4px;
-		}
-
-		@media (max-width: $bp-mobile-max) {
-			// 모바일: overflow 스크롤 유도용 화살표 표시
-			display: inline-flex;
-		}
-	}
-
-	&__tabs {
-		display: flex;
-		gap: 0.5rem;
-		overflow-x: auto;
-		overflow-y: hidden;
-		padding: 0.25rem 0.25rem 0;
-		flex: 1 1 auto;
-		min-width: 0;
-		cursor: grab;
-		user-select: none;
-		-webkit-user-select: none;
-		touch-action: pan-y;
-
-		&:active {
-			cursor: grabbing;
-		}
-
-		&::-webkit-scrollbar {
-			height: 4px;
-		}
-
-		@media (max-width: $bp-mobile-max) {
-			// 모바일: 스크롤바 숨김 + 좌우 fade mask (더 있다는 시각 힌트)
-			scrollbar-width: none;
-			-ms-overflow-style: none;
-			mask-image: linear-gradient(
-				to right,
-				transparent 0,
-				#000 16px,
-				#000 calc(100% - 16px),
-				transparent 100%
-			);
-			-webkit-mask-image: linear-gradient(
-				to right,
-				transparent 0,
-				#000 16px,
-				#000 calc(100% - 16px),
-				transparent 100%
-			);
-
-			&::-webkit-scrollbar {
-				display: none;
-			}
-		}
-	}
-
-	&__tabs-item {
-		display: inline-flex;
-		align-items: center;
-		gap: 0.4rem;
-		padding: 0.5rem 1rem;
-		margin-bottom: -1px;
-		font-size: 0.95rem;
-		font-weight: 600;
-		color: hsl(var(--muted-foreground));
-		border-bottom: 2px solid transparent;
-		white-space: nowrap;
-		flex-shrink: 0;
-		-webkit-user-drag: none;
-		-webkit-tap-highlight-color: transparent;
-		transition: color 0.15s ease, border-color 0.15s ease;
-
-		&:hover {
-			color: hsl(var(--foreground));
-		}
-
-		&:focus-visible {
-			outline: 2px solid hsl(var(--primary));
-			outline-offset: 2px;
-			border-radius: 4px;
-		}
-
-		&--active {
-			color: hsl(var(--primary));
-			border-bottom-color: hsl(var(--primary));
-		}
-	}
-
-	&__filters {
-		display: flex;
-		flex-direction: row;
-		flex-wrap: wrap;
-		gap: 0.5rem;
-		margin-bottom: 1.5rem;
-
-		@media (max-width: $bp-mobile-max) {
-			gap: 0.4rem;
-		}
-	}
-
-	&__filter-select {
-		padding: 0.4rem 0.7rem;
-		font-size: 0.88rem;
-		color: hsl(var(--foreground));
-		background: hsl(var(--background));
-		border: 1px solid hsl(var(--border));
-		border-radius: 6px;
-		cursor: pointer;
-
-		&:focus {
-			outline: none;
-			border-color: hsl(var(--primary));
-		}
-	}
-
-	&__list-note {
-		margin: 1rem 0 0.5rem;
-		font-size: 0.8rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__list {
-		display: flex;
-		flex-direction: column;
-		gap: 0.25rem;
-		margin-top: 1.5rem;
-		border-top: 1px solid hsl(var(--border));
-
-		&-header {
-			display: grid;
-			grid-template-columns: 60px 60px 1fr 180px 120px 120px;
-			gap: 0.75rem;
-			padding: 0.75rem 1rem;
-			font-size: 0.8rem;
-			font-weight: 600;
-			color: hsl(var(--muted-foreground));
-			background: hsl(var(--muted) / 0.3);
-
-			@media (max-width: $bp-tablet-max) {
-				grid-template-columns: 48px 48px 1fr 100px;
-			}
-
-			@media (max-width: $bp-mobile-max) {
-				display: none;
-			}
-
-			// 행 콘텐츠 정렬에 맞춰 헤더 텍스트 정렬 (rank/change=center, amount/broadcast=right)
-			&--aligned {
-				span:nth-child(1),
-				span:nth-child(2) {
-					text-align: center;
-				}
-
-				span:nth-child(5),
-				span:nth-child(6) {
-					text-align: right;
-				}
-			}
-		}
-	}
-
-	&__row {
-		display: grid;
-		grid-template-columns: 60px 60px 1fr 180px 120px 120px;
-		gap: 0.75rem;
-		align-items: center;
-		padding: 0.9rem 1rem;
-		border-bottom: 1px solid hsl(var(--border));
-		transition: background 0.15s ease;
-
-		&:hover {
-			background: hsl(var(--muted) / 0.25);
-		}
-
-		@media (max-width: $bp-tablet-max) {
-			grid-template-columns: 48px 48px 1fr 100px;
-
-			.ranking__row-progress,
-			.ranking__row-broadcast {
-				display: none;
-			}
-		}
-
-		@media (max-width: $bp-mobile-max) {
-			grid-template-columns: 40px 1fr auto;
-			gap: 0.5rem;
-			padding: 0.75rem 0.5rem;
-
-			.ranking__row-change {
-				display: none;
-			}
-		}
-
-		&--mine {
-			background: hsl(var(--primary) / 0.08);
-			border-left: 3px solid hsl(var(--primary));
-		}
-
-		&--rank-1 {
-			background: linear-gradient(90deg, rgba(255, 215, 0, 0.18), rgba(255, 215, 0, 0.04) 60%, transparent);
-			border-bottom-color: rgba(255, 215, 0, 0.4);
-		}
-
-		&--rank-2 {
-			background: linear-gradient(90deg, rgba(192, 192, 192, 0.18), rgba(192, 192, 192, 0.04) 60%, transparent);
-			border-bottom-color: rgba(192, 192, 192, 0.4);
-		}
-
-		&--rank-3 {
-			background: linear-gradient(90deg, rgba(205, 127, 50, 0.2), rgba(205, 127, 50, 0.05) 60%, transparent);
-			border-bottom-color: rgba(205, 127, 50, 0.4);
-		}
-
-		&-rank {
-			font-weight: 700;
-			font-size: 1rem;
-			text-align: center;
-		}
-
-		&-change {
-			display: flex;
-			justify-content: center;
-		}
-
-		&-member {
-			min-width: 0;
-		}
-
-		&-member-link {
-			display: inline-flex;
-			align-items: center;
-			gap: 0.6rem;
-			min-width: 0;
-			color: inherit;
-
-			&:hover .ranking__row-name {
-				color: hsl(var(--primary));
-			}
-		}
-
-		&-name {
-			display: flex;
-			flex-direction: column;
-			font-weight: 600;
-			font-size: 0.95rem;
-			min-width: 0;
-			overflow: hidden;
-			text-overflow: ellipsis;
-		}
-
-		&-channel {
-			font-size: 0.75rem;
-			font-weight: 400;
-			color: hsl(var(--muted-foreground));
-		}
-
-		&-name-main {
-			display: inline-flex;
-			align-items: center;
-			gap: 0.4rem;
-		}
-
-		&-progress {
-			width: 100%;
-		}
-
-		&-progress-bar {
-			position: relative;
-			height: 6px;
-			background: hsl(var(--muted));
-			border-radius: 9999px;
-			overflow: hidden;
-
-			span {
-				display: block;
-				height: 100%;
-				width: var(--progress, 0%);
-				background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0.6));
-				transition: width 0.3s ease;
-			}
-
-			&--tier-1 span {
-				background: linear-gradient(90deg, #FFD700 0%, #FFA500 100%);
-			}
-
-			&--tier-2 span {
-				background: linear-gradient(90deg, hsl(var(--primary)), hsl(var(--primary) / 0.55));
-			}
-
-			&--tier-3 span {
-				background: linear-gradient(90deg, hsl(var(--muted-foreground) / 0.85), hsl(var(--muted-foreground) / 0.4));
-			}
-		}
-
-		&-amount {
-			font-weight: 700;
-			font-size: 0.95rem;
-			text-align: right;
-
-			&--accent {
-				color: #e11d48;
-				font-weight: 800;
-			}
-		}
-
-		&-broadcast {
-			display: flex;
-			flex-direction: column;
-			align-items: flex-end;
-			font-size: 0.88rem;
-
-			small {
-				font-size: 0.72rem;
-				color: hsl(var(--muted-foreground));
-			}
-
-			&--accent {
-				color: #7c3aed;
-				font-weight: 700;
-			}
-		}
-	}
-
-	&__change {
-		display: inline-flex;
-		align-items: center;
-		gap: 0.15rem;
-		padding: 0.1rem 0.4rem;
-		font-size: 0.72rem;
-		font-weight: 600;
-		border-radius: 9999px;
-
-		&--up {
-			color: #16a34a;
-			background: rgba(22, 163, 74, 0.1);
-		}
-
-		&--down {
-			color: #dc2626;
-			background: rgba(220, 38, 38, 0.1);
-		}
-
-		&--same {
-			color: hsl(var(--muted-foreground));
-			background: hsl(var(--muted));
-		}
-
-		&--new {
-			color: #7c3aed;
-			background: rgba(124, 58, 237, 0.12);
-		}
-	}
-
-	&__grade-badge {
-		display: inline-flex;
-		align-items: center;
-		gap: 0.2rem;
-		padding: 0.1rem 0.45rem;
-		font-size: 0.72rem;
-		font-weight: 600;
-		border-radius: 9999px;
-		background: hsl(var(--muted) / 0.7);
-		color: var(--grade-color, inherit);
-	}
-
-	&__grade-icon {
-		width: 14px;
-		height: 14px;
-		object-fit: contain;
-	}
-
-	&__grade-name {
-		font-weight: 600;
-	}
-
-	&__empty {
-		padding: 3rem 1rem;
-		text-align: center;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__hall-controls {
-		display: flex;
-		flex-wrap: wrap;
-		align-items: center;
-		gap: 1rem;
-		margin-bottom: 1.5rem;
-
-		@media (max-width: $bp-mobile-max) {
-			gap: 0.75rem;
-		}
-	}
-
-	&__header-sub {
-		margin-top: 0.4rem;
-		font-size: 0.88rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__hall-period {
-		display: inline-flex;
-		flex-wrap: wrap;
-		gap: 0.3rem;
-		margin-left: auto; // flex 부모 안에서 우측으로 밀어냄 (탭은 좌측, 기간은 우측)
-
-		@media (max-width: $bp-mobile-max) {
-			margin-left: 0; // 모바일: wrap 된 상태에서 좌측 정렬
-		}
-	}
-
-	&__hall-period-btn {
-		padding: 0.4rem 0.85rem;
-		font-size: 0.85rem;
-		font-weight: 500;
-		color: hsl(var(--muted-foreground));
-		background: hsl(var(--background));
-		border: 1px solid hsl(var(--border));
-		border-radius: 6px;
-		cursor: pointer;
-		transition: color 0.15s ease, background 0.15s ease, border-color 0.15s ease;
-
-		&:hover {
-			color: hsl(var(--foreground));
-			border-color: hsl(var(--foreground) / 0.3);
-		}
-
-		&:focus-visible {
-			outline: 2px solid hsl(var(--primary));
-			outline-offset: 2px;
-		}
-
-		&--active {
-			color: hsl(var(--primary-foreground));
-			background: hsl(var(--primary));
-			border-color: hsl(var(--primary));
-		}
-	}
-
-	&__hall-top100 {
-		margin-top: 2rem;
-
-		h2 {
-			display: inline-flex;
-			align-items: center;
-			gap: 0.4rem;
-			font-size: 1.15rem;
-			font-weight: 700;
-			margin-bottom: 0.75rem;
-
-			svg {
-				color: hsl(var(--primary));
-			}
-		}
-	}
-
-	&__medal-badge {
-		display: inline-flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 0.1rem;
-		line-height: 1;
-
-		strong {
-			font-size: 0.82rem;
-			font-weight: 800;
-		}
-
-		&--1 {
-			color: #b8860b;
-
-			svg {
-				color: #FFD700;
-				filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.18));
-			}
-		}
-
-		&--2 {
-			color: #4b5563;
-
-			svg {
-				color: #C0C0C0;
-			}
-		}
-
-		&--3 {
-			color: #92400e;
-
-			svg {
-				color: #CD7F32;
-			}
-		}
-	}
-
-	&__my-bar {
-		position: fixed;
-		bottom: 16px;
-		left: 50%;
-		transform: translateX(-50%);
-		display: flex;
-		align-items: center;
-		gap: 0.75rem;
-		padding: 0.75rem 1.25rem;
-		background: hsl(var(--background));
-		border: 1px solid hsl(var(--primary));
-		border-radius: 9999px;
-		box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
-		z-index: 40;
-
-		@media (max-width: $bp-mobile-max) {
-			left: 0;
-			right: 0;
-			bottom: 0;
-			transform: none;
-			border-radius: 0;
-			border-left: 0;
-			border-right: 0;
-			border-bottom: 0;
-			justify-content: space-between;
-			width: 100%;
-		}
-
-		&-icon {
-			display: inline-flex;
-			align-items: center;
-			justify-content: center;
-			width: 32px;
-			height: 32px;
-			background: hsl(var(--primary));
-			color: hsl(var(--primary-foreground));
-			border-radius: 9999px;
-		}
-
-		&-text {
-			display: flex;
-			align-items: center;
-			gap: 0.4rem;
-			font-size: 0.95rem;
-			font-weight: 600;
-
-			strong {
-				font-size: 0.85rem;
-				color: hsl(var(--muted-foreground));
-			}
-		}
-
-		&-up {
-			color: #16a34a;
-			font-style: normal;
-		}
-
-		&-down {
-			color: #dc2626;
-			font-style: normal;
-		}
-
-		&-amount {
-			font-weight: 700;
-			color: hsl(var(--primary));
-		}
-	}
-}
-
-/* ─────────────── 시상대 (Podium) ─────────────── */
-
-.ranking-podium {
-	position: relative;
-	margin: 1.5rem 0 2rem;
-	padding: 1.5rem 1rem;
-	border-radius: 16px;
-	background: radial-gradient(ellipse at center, hsl(var(--muted) / 0.4), transparent 70%);
-	overflow: hidden;
-
-	&__stage {
-		display: grid;
-		grid-template-columns: 1fr 1.3fr 1fr;
-		align-items: end;
-		gap: 1.5rem;
-		max-width: 820px;
-		margin: 0 auto;
-
-		@media (max-width: $bp-tablet-max) {
-			gap: 1rem;
-			max-width: 620px;
-		}
-
-		@media (max-width: $bp-mobile-max) {
-			grid-template-columns: 1fr;
-			gap: 0.75rem;
-		}
-	}
-
-	&__winner {
-		position: relative;
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 0.5rem;
-		padding: 2.5rem 1rem 1.25rem;
-		border-radius: 16px;
-		text-align: center;
-		box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
-		transition: transform 0.25s ease;
-
-		&:hover {
-			transform: translateY(-4px);
-		}
-
-		&--first {
-			order: 2;
-			min-height: 280px;
-			background: linear-gradient(135deg, #FFD700 0%, #FFA500 100%);
-			color: #3a2600;
-
-			.ranking-podium__avatar {
-				width: 120px;
-				height: 120px;
-				border: 4px solid rgba(255, 255, 255, 0.8);
-				box-shadow: 0 0 24px rgba(255, 215, 0, 0.6);
-			}
-
-			@media (max-width: $bp-tablet-max) {
-				min-height: 240px;
-
-				.ranking-podium__avatar {
-					width: 96px;
-					height: 96px;
-				}
-			}
-
-			@media (max-width: $bp-mobile-max) {
-				order: 1;
-				flex-direction: row;
-				align-items: center;
-				min-height: 140px;
-				padding: 1rem 1.25rem;
-				text-align: left;
-
-				.ranking-podium__avatar {
-					width: 80px;
-					height: 80px;
-				}
-			}
-		}
-
-		&--second {
-			order: 1;
-			min-height: 230px;
-			background: linear-gradient(135deg, #C0C0C0 0%, #A8A8A8 100%);
-			color: #1f2937;
-
-			.ranking-podium__avatar {
-				width: 96px;
-				height: 96px;
-				border: 3px solid rgba(255, 255, 255, 0.8);
-			}
-
-			@media (max-width: $bp-tablet-max) {
-				min-height: 200px;
-
-				.ranking-podium__avatar {
-					width: 80px;
-					height: 80px;
-				}
-			}
-
-			@media (max-width: $bp-mobile-max) {
-				order: 2;
-			}
-		}
-
-		&--third {
-			order: 3;
-			min-height: 210px;
-			background: linear-gradient(135deg, #CD7F32 0%, #A0522D 100%);
-			color: #fff8f0;
-
-			.ranking-podium__avatar {
-				width: 96px;
-				height: 96px;
-				border: 3px solid rgba(255, 255, 255, 0.7);
-			}
-
-			@media (max-width: $bp-tablet-max) {
-				min-height: 190px;
-
-				.ranking-podium__avatar {
-					width: 80px;
-					height: 80px;
-				}
-			}
-		}
-
-		&--empty {
-			opacity: 0.5;
-			box-shadow: none;
-		}
-
-		@media (max-width: $bp-mobile-max) {
-			min-height: unset !important;
-			padding: 1rem;
-		}
-	}
-
-	&__link {
-		display: inline-flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 0.4rem;
-		color: inherit;
-
-		@media (max-width: $bp-mobile-max) {
-			flex-direction: row;
-			gap: 1rem;
-			align-items: center;
-		}
-	}
-
-	&__crown {
-		position: absolute;
-		top: 0.25rem;
-		left: 50%;
-		transform: translate(-50%, -60%);
-		color: #FFF59D;
-		filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3));
-	}
-
-	&__medal {
-		position: absolute;
-		top: 0.5rem;
-		right: 0.75rem;
-		color: rgba(255, 255, 255, 0.9);
-	}
-
-	&__rank-number {
-		position: absolute;
-		bottom: -0.5rem;
-		left: 0.75rem;
-		font-size: 5rem;
-		font-weight: 900;
-		line-height: 0.8;
-		color: rgba(255, 255, 255, 0.25);
-		-webkit-user-select: none;
-		-moz-user-select: none;
-		-ms-user-select: none;
-		user-select: none;
-		pointer-events: none;
-
-		@media (max-width: $bp-mobile-max) {
-			font-size: 3.5rem;
-			bottom: 0;
-		}
-	}
-
-	&__avatar {
-		border-radius: 9999px;
-		background: #fff;
-	}
-
-	&__name {
-		font-size: 1.1rem;
-		font-weight: 700;
-		line-height: 1.2;
-	}
-
-	&__channel {
-		font-size: 0.82rem;
-		opacity: 0.85;
-	}
-
-	&__amount {
-		margin-top: 0.25rem;
-		font-size: 1rem;
-		font-weight: 700;
-	}
-
-	&__meta {
-		font-size: 0.78rem;
-		opacity: 0.85;
-	}
-
-	&__shimmer {
-		position: absolute;
-		top: 0;
-		left: 0;
-		right: 0;
-		bottom: 0;
-		pointer-events: none;
-		background: linear-gradient(120deg, transparent 0%, rgba(255, 255, 255, 0.4) 50%, transparent 100%);
-		background-size: 200% 100%;
-		animation: ranking-podium-shimmer 3s ease-in-out infinite;
-		border-radius: inherit;
-	}
-}
-
-@keyframes ranking-podium-shimmer {
-	0% {
-		background-position: 200% 0;
-	}
-
-	100% {
-		background-position: -200% 0;
-	}
-}
-
-@media (prefers-reduced-motion: reduce) {
-	.ranking-podium__shimmer {
-		animation: none;
-		opacity: 0.2;
-	}
-
-	.ranking-podium__winner {
-		transition: none;
-
-		&:hover {
-			transform: none;
-		}
-	}
-}

+ 1 - 13
app/(main)/user/[sid]/_component/UserProfileStats.tsx

@@ -1,4 +1,4 @@
-import { Coins, CalendarCheck, Flame } from 'lucide-react';
+import { CalendarCheck, Flame } from 'lucide-react';
 import { UserProfileDto } from '@/types/account/profile';
 
 type Props = {
@@ -6,20 +6,8 @@ type Props = {
 };
 
 export default function UserProfileStats({ profile }: Props) {
-	const showDonation = profile.totalDonatedAmount !== null && profile.totalDonatedAmount !== undefined;
-
 	return (
 		<section className="user-profile__stats" aria-label="회원 활동 통계">
-			{showDonation && (
-				<div className="user-profile__stat">
-					<Coins size={20} className="user-profile__stat-icon user-profile__stat-icon--coins" strokeWidth={1.75} />
-					<div className="user-profile__stat-body">
-						<span className="user-profile__stat-label">후원 총액</span>
-						<span className="user-profile__stat-value">{(profile.totalDonatedAmount ?? 0).toLocaleString()} P</span>
-					</div>
-				</div>
-			)}
-
 			<div className="user-profile__stat">
 				<CalendarCheck size={20} className="user-profile__stat-icon user-profile__stat-icon--attendance" strokeWidth={1.75} />
 				<div className="user-profile__stat-body">

+ 3 - 65
app/(main)/watch/[identifier]/ChatSidebar.tsx

@@ -8,18 +8,15 @@
  *
  * 재활성화 가이드: ~/.claude/projects/E--workspace-dpot/memory/plan_dpot_chat_reactivate_after_quota.md
  *
- * 이 파일을 import 하면 useChat / LeaderboardPanel 도 함께 동작합니다.
+ * 이 파일을 import 하면 useChat 도 함께 동작합니다.
  * 일반적으로는 import 하지 마세요.
  */
 
 import { useState, useRef, useEffect, useCallback, type CSSProperties, type KeyboardEvent } from 'react';
 import useAuth from '@/hooks/useAuth';
 import useChat from '@/hooks/useChat';
-import { fetchApi } from '@/lib/utils/client';
 import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faUsers, faEye, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock, faCoins, faCrown, faRankingStar } from '@fortawesome/free-solid-svg-icons';
-import type { LiveLeaderboardResponse } from '@/types/response/channel/leaderboard';
-import LeaderboardPanel from './LeaderboardPanel';
+import { faUsers, faEye, faEllipsisVertical, faPaperPlane, faTrashCan, faMagnifyingGlassPlus, faMagnifyingGlassMinus, faRotateRight, faClock } from '@fortawesome/free-solid-svg-icons';
 import './chat-sidebar.scss';
 
 const MIN_FONT_SIZE = 12;
@@ -28,10 +25,9 @@ const DEFAULT_FONT_SIZE = 16;
 
 type ChatSidebarProps = {
 	channelSID: string;
-	onDonate?: () => void;
 };
 
-export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps) {
+export default function ChatSidebar({ channelSID }: ChatSidebarProps) {
 	const { isAuthenticated } = useAuth();
 	const { messages, systemMessages, participantCount, participants, sendMessage, clearMessages, refreshChat, requestParticipants, chatConnected } = useChat(channelSID);
 	const [inputValue, setInputValue] = useState('');
@@ -41,37 +37,8 @@ export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps)
 	const messagesRef = useRef<HTMLDivElement>(null);
 	const isAutoScrollRef = useRef(true);
 	const [showParticipants, setShowParticipants] = useState(false);
-	const [showLeaderboard, setShowLeaderboard] = useState(false);
-	const [myXp, setMyXp] = useState<number|null>(null);
 	const menuRef = useRef<HTMLDivElement>(null);
 
-	// 내 XP 주기 갱신 (30초마다)
-	useEffect(() => {
-		if (!isAuthenticated) {
-			return;
-		}
-		let active = true;
-		const fetchMyXp = async () => {
-			try {
-				const res = await fetchApi<LiveLeaderboardResponse>(
-					`/api/channel/${channelSID}/live-leaderboard?maxRank=1`,
-					{ silent: true }
-				);
-				if (active && res.data) {
-					setMyXp(res.data.myRank?.xp ?? 0);
-				}
-			} catch {
-				// ignore
-			}
-		};
-		fetchMyXp();
-		const interval = setInterval(fetchMyXp, 30000);
-		return () => {
-			active = false;
-			clearInterval(interval);
-		};
-	}, [channelSID, isAuthenticated]);
-
 	// 메시지 + 시스템 메시지를 시간순 병합
 	const mergedMessages = (() => {
 		const items: Array<
@@ -187,17 +154,6 @@ export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps)
 					</button>
 				</div>
 				<div className='chat-header-actions'>
-					{isAuthenticated && myXp !== null && (
-						<button
-							type='button'
-							className='chat-my-xp'
-							title='내 XP · 리더보드 열기'
-							onClick={() => setShowLeaderboard(true)}
-						>
-							<FontAwesomeIcon icon={faRankingStar} />
-							<span>{myXp.toLocaleString()}XP</span>
-						</button>
-					)}
 					<div className='chat-menu-wrapper' ref={menuRef}>
 						<button type='button' title='메뉴' onClick={() => setShowMenu((prev) => !prev)}>
 							<FontAwesomeIcon icon={faEllipsisVertical} />
@@ -247,15 +203,9 @@ export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps)
 					const msg = item.data;
 					const badgeUrl = msg.titleIconUrl || msg.gradeImageUrl || msg.memberIcon;
 					const badgeAlt = msg.titleName ?? '';
-					const isTopRank = typeof msg.leaderboardRank === 'number' && msg.leaderboardRank >= 1 && msg.leaderboardRank <= 3;
 					return (
 						<div key={`msg-${index}`} className='chat-message'>
 							{showTime && <span className='chat-message-time'>{formatTime(msg.sentAt)}</span>}
-							{isTopRank && (
-								<span className={`chat-message-crown chat-message-crown--rank${msg.leaderboardRank}`} title={`리더보드 ${msg.leaderboardRank}위`}>
-									<FontAwesomeIcon icon={faCrown} />
-								</span>
-							)}
 							{badgeUrl && (
 								<img src={badgeUrl} alt={badgeAlt} className='chat-message-badge' aria-hidden={!badgeAlt} />
 							)}
@@ -281,11 +231,6 @@ export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps)
 							maxLength={500}
 							disabled={!chatConnected}
 						/>
-						{onDonate && (
-							<button type='button' title='후원하기' onClick={onDonate} className='chat-donate-btn'>
-								<FontAwesomeIcon icon={faCoins} />
-							</button>
-						)}
 						<button type='button' title='전송' onClick={handleSend} disabled={!chatConnected || !inputValue.trim()}>
 							<FontAwesomeIcon icon={faPaperPlane} />
 						</button>
@@ -297,13 +242,6 @@ export default function ChatSidebar({ channelSID, onDonate }: ChatSidebarProps)
 				)}
 			</div>
 
-			{/* 리더보드 패널 */}
-			<LeaderboardPanel
-				channelSID={channelSID}
-				open={showLeaderboard}
-				onClose={() => setShowLeaderboard(false)}
-			/>
-
 			{/* 참여자 목록 패널 */}
 			{showParticipants && (
 				<>

+ 0 - 365
app/(main)/watch/[identifier]/DonationModal.tsx

@@ -1,365 +0,0 @@
-'use client';
-
-import { useState, useEffect, useRef, useCallback } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import useAuth from '@/hooks/useAuth';
-import { DropdownData } from '@/types/response/mypage/dropdown';
-import './donation-modal.scss';
-
-type CrewMemberInfo = {
-	crewMemberID: number;
-	nickname: string;
-	thumb: string|null;
-	channelName: string|null;
-};
-
-type ActiveCrew = {
-	crewSessionID: number;
-	title: string;
-	crewName: string;
-	members: CrewMemberInfo[];
-}|null;
-
-type SignatureItem = {
-	id: number;
-	title: string;
-	amount: number;
-	matchType: number;
-	imageUrl: string;
-};
-
-type SignatureListResponse = {
-	list: SignatureItem[];
-	total: number;
-	hasMore: boolean;
-};
-
-type Props = {
-	channelSID: string;
-	onClose: () => void;
-};
-
-const PER_PAGE = 6;
-
-export default function DonationModal({ channelSID, onClose }: Props)
-{
-	const { member } = useAuth();
-
-	const [amount, setAmount] = useState(1000);
-	const [message, setMessage] = useState('');
-	const [sendName, setSendName] = useState(member?.name || member?.sid || '');
-	const [isAnonymous, setIsAnonymous] = useState(false);
-	const [pointBalance, setPointBalance] = useState<number|null>(null);
-	const [activeCrew, setActiveCrew] = useState<ActiveCrew>(null);
-	const [selectedMember, setSelectedMember] = useState<number|null>(null);
-	const [sending, setSending] = useState(false);
-	const [done, setDone] = useState(false);
-
-	// 시그니처 이미지 페이징
-	const [signatures, setSignatures] = useState<SignatureItem[]>([]);
-	const [sigPage, setSigPage] = useState(1);
-	const [sigHasMore, setSigHasMore] = useState(true);
-	const [sigLoading, setSigLoading] = useState(false);
-	const [selectedSigId, setSelectedSigId] = useState<number|null>(null);
-	const sigSentinelRef = useRef<HTMLDivElement|null>(null);
-
-	const presetAmounts = [1000, 3000, 5000, 10000, 30000, 50000];
-
-	useEffect(() => {
-		fetchApi<ActiveCrew>(`/api/donation/crew/active/${channelSID}`)
-			.then(res => {
-				if (res.data) {
-					setActiveCrew(res.data);
-				}
-			})
-			.catch(() => {});
-	}, [channelSID]);
-
-	useEffect(() => {
-		fetchApi<DropdownData>('/api/mypage/dropdown', { silent: true })
-			.then(res => {
-				if (res.data) {
-					setPointBalance(res.data.spendableBalance);
-				}
-			})
-			.catch(() => {});
-	}, []);
-
-	// 시그니처 이미지 초기 로드 및 페이징
-	const loadSignatures = useCallback(async (page: number) => {
-		if (sigLoading) {
-			return;
-		}
-		setSigLoading(true);
-		try {
-			const res = await fetchApi<SignatureListResponse>(
-				`/api/donation/signatures/${channelSID}?page=${page}&perPage=${PER_PAGE}`,
-				{ silent: true }
-			);
-			if (res.data) {
-				setSignatures(prev => page === 1 ? res.data!.list : [...prev, ...res.data!.list]);
-				setSigHasMore(res.data.hasMore);
-			}
-		} catch {
-			setSigHasMore(false);
-		} finally {
-			setSigLoading(false);
-		}
-	}, [channelSID, sigLoading]);
-
-	useEffect(() => {
-		loadSignatures(1);
-		// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [channelSID]);
-
-	// IntersectionObserver 무한 스크롤
-	useEffect(() => {
-		const sentinel = sigSentinelRef.current;
-		if (!sentinel || !sigHasMore || sigLoading) {
-			return;
-		}
-
-		const observer = new IntersectionObserver((entries) => {
-			if (entries[0].isIntersecting) {
-				const next = sigPage + 1;
-				setSigPage(next);
-				loadSignatures(next);
-			}
-		}, { threshold: 0.5 });
-
-		observer.observe(sentinel);
-		return () => observer.disconnect();
-	}, [sigPage, sigHasMore, sigLoading, loadSignatures]);
-
-	const handleSignatureClick = (sig: SignatureItem) => {
-		setSelectedSigId(sig.id);
-		setAmount(sig.amount);
-	};
-
-	const handleSend = async () => {
-		if (amount < 1000) {
-			alert('최소 후원 금액은 1,000원입니다.');
-			return;
-		}
-		const finalSendName = isAnonymous ? '익명' : sendName.trim();
-		if (!finalSendName) {
-			alert('보내는 사람 이름을 입력해 주세요.');
-			return;
-		}
-
-		setSending(true);
-		try {
-			const body: Record<string, unknown> = {
-				channelSID,
-				amount,
-				message: message || null,
-				sendName: finalSendName
-			};
-
-			if (activeCrew && selectedMember) {
-				body.crewSessionID = activeCrew.crewSessionID;
-				body.crewMemberID = selectedMember;
-			}
-
-			const res = await fetchApi('/api/donation/send', {
-				method: 'POST',
-				body,
-				silent: true
-			});
-
-			if (!res.success) {
-				const msg = res.message ?? '';
-				if (/\uC794\uC561/.test(msg) || msg.includes('부족')) {
-					alert('POINT가 부족합니다.');
-				} else {
-					alert(msg || '후원에 실패했습니다.');
-				}
-				return;
-			}
-
-			setDone(true);
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '후원에 실패했습니다.');
-		} finally {
-			setSending(false);
-		}
-	};
-
-	if (done) {
-		return (
-			<div className="donation-modal" role="dialog" aria-modal="true" aria-labelledby="donation-modal-title">
-				<div className="donation-modal__overlay" onClick={onClose} />
-				<div className="donation-modal__box">
-					<div className="donation-modal__done">
-						<div className="donation-modal__done-icon" aria-hidden="true">🎉</div>
-						<p className="donation-modal__done-text">{amount.toLocaleString()}원 후원 완료!</p>
-						<button type="button" className="donation-modal__btn donation-modal__btn--primary" onClick={onClose}>닫기</button>
-					</div>
-				</div>
-			</div>
-		);
-	}
-
-	return (
-		<div className="donation-modal" role="dialog" aria-modal="true" aria-labelledby="donation-modal-title">
-			<div className="donation-modal__overlay" onClick={onClose} />
-			<div className="donation-modal__box">
-				<div className="donation-modal__header">
-					<h2 id="donation-modal-title" className="donation-modal__title">후원하기</h2>
-					<button type="button" className="donation-modal__close" onClick={onClose} aria-label="닫기">&times;</button>
-				</div>
-
-				<div className="donation-modal__body">
-					{/* 시그니처 이미지 그리드 (상단, 이미지 있는 것만) */}
-					{signatures.length > 0 && (
-						<div className="donation-modal__signatures">
-							<label className="donation-modal__label">시그니처 선택 (선택 시 금액 자동 입력)</label>
-							<div className="donation-modal__signature-grid" role="listbox" aria-label="시그니처 이미지">
-								{signatures.map(sig => {
-									const isActive = selectedSigId === sig.id;
-									return (
-										<button
-											type="button"
-											key={sig.id}
-											className={`donation-modal__signature${isActive ? ' donation-modal__signature--active' : ''}`}
-											onClick={() => handleSignatureClick(sig)}
-											aria-selected={isActive}
-											role="option"
-										>
-											<img src={sig.imageUrl} alt={sig.title} className="donation-modal__signature-img" />
-											<span className="donation-modal__signature-amount">{sig.amount.toLocaleString()}원</span>
-										</button>
-									);
-								})}
-								{sigHasMore && <div ref={sigSentinelRef} className="donation-modal__signature-sentinel" aria-hidden="true" />}
-								{sigLoading && <div className="donation-modal__signature-loading">불러오는 중...</div>}
-							</div>
-						</div>
-					)}
-
-					{/* 크루원 선택 (시그니처 바로 아래) */}
-					{activeCrew && activeCrew.members.length > 0 && (
-						<div className="donation-modal__crew">
-							<label className="donation-modal__crew-label">
-								크루원에게 후원 <span className="donation-modal__crew-tag">{activeCrew.crewName}</span>
-							</label>
-							<div className="donation-modal__crew-list">
-								<button
-									type="button"
-									className={`donation-modal__crew-item${selectedMember === null ? ' donation-modal__crew-item--active' : ''}`}
-									onClick={() => setSelectedMember(null)}
-								>
-									<div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">채널</div>
-									<span>채널 주인</span>
-								</button>
-								{activeCrew.members.map(m => (
-									<button
-										type="button"
-										key={m.crewMemberID}
-										className={`donation-modal__crew-item${selectedMember === m.crewMemberID ? ' donation-modal__crew-item--active' : ''}`}
-										onClick={() => setSelectedMember(m.crewMemberID)}
-									>
-										{m.thumb ? (
-											<img src={m.thumb} alt="" className="donation-modal__crew-thumb" />
-										) : (
-											<div className="donation-modal__crew-thumb donation-modal__crew-thumb--default">{m.nickname.charAt(0)}</div>
-										)}
-										<span>{m.nickname}</span>
-									</button>
-								))}
-							</div>
-						</div>
-					)}
-
-					{/* 별명 */}
-					<div className="donation-modal__field">
-						<div className="donation-modal__label-row">
-							<label htmlFor="donation-sendname">별명</label>
-							<label className="donation-modal__anon-toggle">
-								<input
-									type="checkbox"
-									checked={isAnonymous}
-									onChange={e => setIsAnonymous(e.target.checked)}
-								/>
-								<span>익명</span>
-							</label>
-						</div>
-						<input
-							id="donation-sendname"
-							type="text"
-							value={isAnonymous ? '익명' : sendName}
-							onChange={e => setSendName(e.target.value)}
-							placeholder="보내는 사람"
-							maxLength={20}
-							disabled={isAnonymous}
-						/>
-					</div>
-
-					{/* 금액 */}
-					<div className="donation-modal__field">
-						<div className="donation-modal__label-row">
-							<label htmlFor="donation-amount">후원 금액</label>
-							{pointBalance !== null && (
-								<span className="donation-modal__balance" aria-live="polite">
-									잔액 {pointBalance.toLocaleString()}P
-								</span>
-							)}
-						</div>
-						<input
-							id="donation-amount"
-							type="number"
-							min={1000}
-							max={10000000}
-							step={1000}
-							value={amount}
-							onChange={e => { setAmount(Number(e.target.value)); setSelectedSigId(null); }}
-						/>
-						<div className="donation-modal__presets" role="group" aria-label="금액 프리셋">
-							{presetAmounts.map(a => (
-								<button
-									type="button"
-									key={a}
-									className={`donation-modal__preset${amount === a ? ' donation-modal__preset--active' : ''}`}
-									onClick={() => { setAmount(a); setSelectedSigId(null); }}
-								>
-									{a.toLocaleString()}원
-								</button>
-							))}
-						</div>
-					</div>
-
-					{/* 메시지 */}
-					<div className="donation-modal__field">
-						<div className="donation-modal__label-row">
-							<label htmlFor="donation-message">메시지 (선택)</label>
-							<span className={`donation-modal__msg-counter${message.length >= 100 ? ' donation-modal__msg-counter--max' : ''}`} aria-live="polite">
-								{message.length}/100
-							</span>
-						</div>
-						<textarea
-							id="donation-message"
-							value={message}
-							onChange={e => setMessage(e.target.value)}
-							placeholder="응원 메시지를 남겨주세요"
-							maxLength={100}
-							rows={2}
-						/>
-					</div>
-				</div>
-
-				{/* 푸터: 취소 / 보내기 */}
-				<div className="donation-modal__footer">
-					<button type="button" className="donation-modal__btn" onClick={onClose}>취소</button>
-					<button
-						type="button"
-						className="donation-modal__btn donation-modal__btn--primary"
-						onClick={handleSend}
-						disabled={sending}
-					>
-						{sending ? '전송 중...' : '보내기'}
-					</button>
-				</div>
-			</div>
-		</div>
-	);
-}

+ 0 - 125
app/(main)/watch/[identifier]/DonationToast.tsx

@@ -1,125 +0,0 @@
-'use client';
-
-import { useEffect, useRef, useState, useCallback } from 'react';
-import { useSignalRContext } from '@/contexts/signalrProvider';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faGift, faXmark } from '@fortawesome/free-solid-svg-icons';
-import './donation-toast.scss';
-
-type ToastItem = {
-	id: number;
-	content: string;
-};
-
-type Props = {
-	channelSID: string;
-};
-
-const AUTO_DISMISS_MS = 6000;
-const MAX_VISIBLE = 3;
-
-let nextId = 0;
-
-/**
- * 후원/시스템 알림 토스트 (우상단).
- *
- * - SignalR `ReceiveSystemMessage` 를 수신하여 토스트로 노출
- * - 기존 dpot SignalR 채팅에 표시되던 후원 알림 메시지를 대체
- * - YouTube iframe 사용 환경에서 dpot 시스템 메시지 노출 채널 역할
- *
- * 자동 dismiss: AUTO_DISMISS_MS 후 사라짐. 수동 닫기 버튼 제공.
- */
-export default function DonationToast({ channelSID }: Props)
-{
-	const { chatConnection, chatConnected } = useSignalRContext();
-	const [toasts, setToasts] = useState<ToastItem[]>([]);
-	const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
-
-	const removeToast = useCallback((id: number) => {
-		setToasts(prev => prev.filter(t => t.id !== id));
-		const timer = timersRef.current.get(id);
-		if (timer) {
-			clearTimeout(timer);
-			timersRef.current.delete(id);
-		}
-	}, []);
-
-	useEffect(() => {
-		if (!chatConnection || !chatConnected) {
-			return;
-		}
-
-		// 채널 참가 (시스템 메시지 수신을 위해 필수)
-		chatConnection.invoke('JoinChannel', channelSID).catch((err) => {
-			console.error('[DonationToast] 채널 참가 실패:', err);
-		});
-
-		const handler = (content: string) => {
-			const id = ++nextId;
-			setToasts(prev => {
-				const next = [...prev, { id, content }];
-				// 동시 노출 제한: 가장 오래된 것부터 제거
-				if (next.length > MAX_VISIBLE) {
-					const removed = next.shift();
-					if (removed) {
-						const timer = timersRef.current.get(removed.id);
-						if (timer) {
-							clearTimeout(timer);
-							timersRef.current.delete(removed.id);
-						}
-					}
-				}
-				return next;
-			});
-
-			const timer = setTimeout(() => {
-				setToasts(prev => prev.filter(t => t.id !== id));
-				timersRef.current.delete(id);
-			}, AUTO_DISMISS_MS);
-			timersRef.current.set(id, timer);
-		};
-
-		chatConnection.on('ReceiveSystemMessage', handler);
-
-		return () => {
-			chatConnection.off('ReceiveSystemMessage', handler);
-			if (chatConnection.state === 'Connected') {
-				chatConnection.invoke('LeaveChannel').catch(() => {});
-			}
-		};
-	}, [chatConnection, chatConnected, channelSID]);
-
-	// 컴포넌트 언마운트 시 모든 타이머 정리
-	useEffect(() => {
-		const timers = timersRef.current;
-		return () => {
-			timers.forEach(timer => clearTimeout(timer));
-			timers.clear();
-		};
-	}, []);
-
-	if (toasts.length === 0) {
-		return null;
-	}
-
-	return (
-		<div className="donation-toast" role="region" aria-live="polite" aria-label="후원 알림">
-			{toasts.map(toast => (
-				<div key={toast.id} className="donation-toast__item">
-					<span className="donation-toast__icon" aria-hidden="true">
-						<FontAwesomeIcon icon={faGift} />
-					</span>
-					<span className="donation-toast__content">{toast.content}</span>
-					<button
-						type="button"
-						className="donation-toast__close"
-						onClick={() => removeToast(toast.id)}
-						aria-label="알림 닫기"
-					>
-						<FontAwesomeIcon icon={faXmark} />
-					</button>
-				</div>
-			))}
-		</div>
-	);
-}

+ 0 - 130
app/(main)/watch/[identifier]/LeaderboardPanel.tsx

@@ -1,130 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faXmark, faCrown, faCircleUser } from '@fortawesome/free-solid-svg-icons';
-import type { LiveLeaderboardResponse, LeaderboardItem } from '@/types/response/channel/leaderboard';
-import './leaderboard-panel.scss';
-
-type Props = {
-	channelSID: string;
-	open: boolean;
-	onClose: () => void;
-};
-
-export default function LeaderboardPanel({ channelSID, open, onClose }: Props) {
-	const [data, setData] = useState<LiveLeaderboardResponse|null>(null);
-	const [loading, setLoading] = useState(false);
-	const [error, setError] = useState<string|null>(null);
-
-	const load = useCallback(async () => {
-		setLoading(true);
-		setError(null);
-		try {
-			const res = await fetchApi<LiveLeaderboardResponse>(
-				`/api/channel/${channelSID}/live-leaderboard`,
-				{ silent: true }
-			);
-			if (res.data) {
-				setData(res.data);
-			} else {
-				setError('리더보드를 불러오지 못했습니다.');
-			}
-		} catch {
-			setError('리더보드를 불러오지 못했습니다.');
-		} finally {
-			setLoading(false);
-		}
-	}, [channelSID]);
-
-	useEffect(() => {
-		if (!open) {
-			return;
-		}
-		load();
-		const interval = setInterval(load, 15000); // 15초마다 새로고침
-		return () => clearInterval(interval);
-	}, [open, load]);
-
-	if (!open) {
-		return null;
-	}
-
-	const renderItem = (item: LeaderboardItem) => {
-		const isCrown = item.rank >= 1 && item.rank <= 3;
-		const badgeUrl = item.titleIconUrl || item.gradeImageUrl || item.memberIcon;
-		const rankClass = isCrown ? `leaderboard-panel__rank--top${item.rank}` : '';
-		return (
-			<li key={item.memberID} className='leaderboard-panel__item'>
-				<div className={`leaderboard-panel__rank ${rankClass}`}>
-					{isCrown ? <FontAwesomeIcon icon={faCrown} /> : item.rank}
-				</div>
-				<div className='leaderboard-panel__avatar'>
-					{badgeUrl ? (
-						<img src={badgeUrl} alt='' />
-					) : (
-						<FontAwesomeIcon icon={faCircleUser} className='leaderboard-panel__avatar-fallback' />
-					)}
-				</div>
-				<div className='leaderboard-panel__name'>
-					<span className='leaderboard-panel__name-text'>{item.memberName}</span>
-					<span className='leaderboard-panel__sid'>@{item.memberSID}</span>
-				</div>
-				<div className='leaderboard-panel__xp'>{item.xp.toLocaleString()}XP</div>
-			</li>
-		);
-	};
-
-	return (
-		<div className='leaderboard-panel' role='dialog' aria-modal='true' aria-labelledby='leaderboard-title'>
-			<div className='leaderboard-panel__overlay' onClick={onClose} />
-			<div className='leaderboard-panel__box'>
-				<div className='leaderboard-panel__header'>
-					<button type='button' className='leaderboard-panel__back' onClick={onClose} aria-label='닫기'>
-						<FontAwesomeIcon icon={faXmark} />
-					</button>
-					<h3 id='leaderboard-title' className='leaderboard-panel__title'>열성 팬</h3>
-					<span className='leaderboard-panel__count'>{data?.total ?? 0}명</span>
-				</div>
-
-				<div className='leaderboard-panel__body'>
-					{loading && data === null && <div className='leaderboard-panel__loading'>불러오는 중...</div>}
-					{error && <div className='leaderboard-panel__error'>{error}</div>}
-
-					{data && data.broadcastSessionID === null && (
-						<div className='leaderboard-panel__empty'>
-							현재 진행 중인 방송 세션이 없습니다.
-						</div>
-					)}
-
-					{data && data.broadcastSessionID !== null && data.list.length === 0 && (
-						<div className='leaderboard-panel__empty'>
-							아직 리더보드 참여자가 없습니다.
-						</div>
-					)}
-
-					{data && data.list.length > 0 && (
-						<ul className='leaderboard-panel__list'>
-							{data.list.map(renderItem)}
-						</ul>
-					)}
-				</div>
-
-				{data?.myRank && (
-					<div className='leaderboard-panel__footer'>
-						{data.myRank.rank === 0 ? (
-							<p className='leaderboard-panel__my-empty'>
-								채팅이나 후원으로 참여해 보세요.
-							</p>
-						) : (
-							<p className='leaderboard-panel__my'>
-								내 순위: <strong>{data.myRank.rank}위</strong> / {data.myRank.xp.toLocaleString()}XP
-							</p>
-						)}
-					</div>
-				)}
-			</div>
-		</div>
-	);
-}

+ 2 - 26
app/(main)/watch/[identifier]/WatchView.tsx

@@ -5,11 +5,8 @@ import { ChannelDetail, ChannelStatusUpdate } from '@/types/channel';
 import { useSignalRContext } from '@/contexts/signalrProvider';
 import { buildChannelUrl, formatHandle } from '@/lib/utils/channel';
 import YouTubeChatIframe from './YouTubeChatIframe';
-import DonationToast from './DonationToast';
-import DonationModal from './DonationModal';
 import ShareMenu from './ShareMenu';
 import FollowButton from '@/app/component/FollowButton';
-import useAuth from '@/hooks/useAuth';
 import Link from 'next/link';
 import './style.scss';
 
@@ -31,7 +28,6 @@ function formatCount(n: number): string {
 
 export default function WatchView({ channel }: Props)
 {
-	const [showDonation, setShowDonation] = useState(false);
 	const [descOpen, setDescOpen] = useState(false);
 	const [isLive, setIsLive] = useState(channel.isLive);
 	const [videoId, setVideoId] = useState<string|null>(channel.videoId);
@@ -40,14 +36,6 @@ export default function WatchView({ channel }: Props)
 	// ChannelStatusBroadcaster 는 AppHub 의 channel:{sid} 그룹으로 송출하므로 appConnection 사용 필수.
 	// (chatConnection 으로 받으면 그룹 키 미스매치 + ChatHub.JoinChannel 의 입장 메시지 도배 부작용)
 	const { appConnection } = useSignalRContext();
-	const { loginCheck } = useAuth();
-
-	const handleDonate = () => {
-		if (!loginCheck()) {
-			return;
-		}
-		setShowDonation(true);
-	};
 
 	// SSR 시점엔 window 가 없으므로 마운트 후 origin 해석
 	useEffect(() => {
@@ -200,29 +188,17 @@ export default function WatchView({ channel }: Props)
 				</div>
 			</div>
 
-			{/* 우측 채팅 — YouTube Live Chat iframe (라이브 시) + 후원 버튼 */}
+			{/* 우측 채팅 — YouTube Live Chat iframe (라이브 시) */}
 			<div className="watch-page__chat">
 				<YouTubeChatIframe
 					videoId={isLive ? videoId : null}
-					onDonate={handleDonate}
 				/>
 				{/*
 				 * [DEPRECATED] dpot SignalR 채팅 — YouTube iframe 으로 대체.
 				 * quota 승인 후 재활성화 예정.
-				 * <ChatSidebar channelSID={channel.channelSID} onDonate={() => setShowDonation(true)} />
+				 * <ChatSidebar channelSID={channel.channelSID} />
 				 */}
 			</div>
-
-			{/* 후원 알림 토스트 (우상단) — SignalR ReceiveSystemMessage 수신 */}
-			<DonationToast channelSID={channel.channelSID} />
-
-			{/* 후원 모달 */}
-			{showDonation && (
-				<DonationModal
-					channelSID={channel.channelSID}
-					onClose={() => setShowDonation(false)}
-				/>
-			)}
 		</div>
 	);
 }

+ 1 - 18
app/(main)/watch/[identifier]/YouTubeChatIframe.tsx

@@ -1,13 +1,10 @@
 'use client';
 
 import { useEffect, useState } from 'react';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faCoins } from '@fortawesome/free-solid-svg-icons';
 import './youtube-chat-iframe.scss';
 
 type Props = {
 	videoId: string|null;
-	onDonate?: () => void;
 };
 
 /**
@@ -20,7 +17,7 @@ type Props = {
  *
  * Quota 비용: 0 — YouTube 자체 인프라 사용, dpot 의 quota 소비 없음.
  */
-export default function YouTubeChatIframe({ videoId, onDonate }: Props)
+export default function YouTubeChatIframe({ videoId }: Props)
 {
 	const [embedDomain, setEmbedDomain] = useState<string|null>(null);
 
@@ -58,20 +55,6 @@ export default function YouTubeChatIframe({ videoId, onDonate }: Props)
 					<p className="yt-chat__offline-desc">라이브 방송이 시작되면 채팅에 참여할 수 있어요.</p>
 				</div>
 			)}
-
-			{onDonate && (
-				<div className="yt-chat__footer">
-					<button
-						type="button"
-						className="yt-chat__donate-btn"
-						onClick={onDonate}
-						aria-label="후원하기"
-					>
-						<FontAwesomeIcon icon={faCoins} />
-						<span>후원하기</span>
-					</button>
-				</div>
-			)}
 		</div>
 	);
 }

+ 4 - 59
app/(main)/watch/[identifier]/chat-sidebar.scss

@@ -52,29 +52,6 @@
 			align-items: center;
 			gap: 4px;
 
-			.chat-my-xp {
-				display: flex;
-				align-items: center;
-				gap: 4px;
-				padding: 4px 10px;
-				border: 1px solid var(--border-default);
-				border-radius: 12px;
-				background: linear-gradient(135deg, #a855f7 0%, #7e22ce 100%);
-				color: #fff;
-				font-size: 0.75rem;
-				font-weight: 700;
-				cursor: pointer;
-				transition: opacity 0.15s;
-
-				svg {
-					font-size: 0.8125rem;
-				}
-
-				&:hover {
-					opacity: 0.85;
-				}
-			}
-
 			.chat-menu-wrapper > button {
 				display: flex;
 				align-items: center;
@@ -188,30 +165,6 @@
 				flex-shrink: 0;
 			}
 
-			.chat-message-crown {
-				display: inline-flex;
-				align-items: center;
-				justify-content: center;
-				width: 16px;
-				height: 16px;
-				flex-shrink: 0;
-
-				svg {
-					font-size: 0.75rem;
-				}
-
-				&--rank1 svg {
-					color: #f59e0b; // gold
-				}
-
-				&--rank2 svg {
-					color: #9ca3af; // silver
-				}
-
-				&--rank3 svg {
-					color: #b45309; // bronze
-				}
-			}
 
 			.chat-message-badge {
 				width: 16px;
@@ -275,7 +228,7 @@
 				width: 36px;
 				height: 36px;
 				border: none;
-				background: var(--color-donate);
+				background: var(--color-accent);
 				color: #fff;
 				border-radius: 4px;
 				cursor: pointer;
@@ -283,7 +236,7 @@
 				transition: background 0.15s;
 
 				&:hover {
-					background: var(--color-donate-hover);
+					background: var(--color-accent-hover);
 				}
 
 				&:disabled {
@@ -292,14 +245,6 @@
 				}
 			}
 
-			.chat-donate-btn {
-				background: #FFD700;
-				color: #333;
-
-				&:hover {
-					background: #FFC400;
-				}
-			}
 		}
 
 		.chat-login-notice {
@@ -309,12 +254,12 @@
 			padding: 2px 0;
 
 			a {
-				color: var(--color-donate);
+				color: var(--color-accent);
 				font-weight: 600;
 				text-decoration: underline;
 
 				&:hover {
-					color: var(--color-donate-hover);
+					color: var(--color-accent-hover);
 				}
 			}
 		}

+ 0 - 454
app/(main)/watch/[identifier]/donation-modal.scss

@@ -1,454 +0,0 @@
-.donation-modal {
-	position: fixed;
-	inset: 0;
-	z-index: 100;
-	display: flex;
-	align-items: center;
-	justify-content: center;
-
-	&__overlay {
-		position: absolute;
-		inset: 0;
-		background: var(--overlay-color);
-	}
-
-	&__box {
-		position: relative;
-		width: 100%;
-		max-width: 640px;
-		max-height: 90vh;
-		display: flex;
-		flex-direction: column;
-		background: var(--bg-page);
-		color: var(--text-primary);
-		border: 1px solid var(--border-strong);
-		border-radius: 12px;
-		padding: 0;
-		overflow: hidden;
-		box-shadow: 0 12px 32px var(--shadow-color);
-
-		// Tablet — letterbox 줄임
-		@media (max-width: 768px) and (min-width: 641px) {
-			max-width: 600px;
-		}
-
-		// Mobile / sm — full screen modal
-		@media (max-width: 640px) {
-			max-width: 100%;
-			max-height: 100%;
-			height: 100dvh;
-			border-radius: 0;
-			border: none;
-		}
-	}
-
-	&__body {
-		flex: 1;
-		overflow-y: auto;
-		padding: 20px 24px;
-
-		&::-webkit-scrollbar {
-			width: 6px;
-		}
-
-		&::-webkit-scrollbar-thumb {
-			background: var(--border-strong);
-			border-radius: 3px;
-		}
-
-		@media (max-width: 640px) {
-			padding: 16px;
-		}
-	}
-
-	&__label {
-		display: block;
-		font-size: 0.9375rem;
-		font-weight: 500;
-		margin-bottom: 6px;
-		color: var(--text-primary);
-	}
-
-	// 시그니처 이미지 그리드
-	&__signatures {
-		margin-bottom: 16px;
-	}
-
-	&__signature-grid {
-		display: grid;
-		grid-template-columns: repeat(4, 1fr);
-		gap: 8px;
-		max-height: 280px;
-		overflow-y: auto;
-		padding: 4px;
-
-		@media (max-width: 768px) {
-			grid-template-columns: repeat(3, 1fr);
-		}
-
-		@media (max-width: 640px) {
-			grid-template-columns: repeat(2, 1fr);
-			max-height: 220px;
-		}
-
-		&::-webkit-scrollbar {
-			width: 6px;
-		}
-
-		&::-webkit-scrollbar-thumb {
-			background: var(--border-strong);
-			border-radius: 3px;
-		}
-	}
-
-	&__signature {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 4px;
-		padding: 6px;
-		border: 2px solid var(--border-default);
-		border-radius: 8px;
-		background: var(--bg-subtle);
-		color: var(--text-primary);
-		cursor: pointer;
-		transition: all 0.15s;
-
-		&:hover {
-			border-color: var(--color-donate);
-		}
-
-		&--active {
-			border-color: var(--color-donate);
-			background: var(--sidebar-active-bg);
-		}
-	}
-
-	&__signature-img {
-		width: 100%;
-		aspect-ratio: 1 / 1;
-		object-fit: contain;
-		background: var(--bg-page);
-		border-radius: 6px;
-	}
-
-	&__signature-amount {
-		font-size: 0.75rem;
-		font-weight: 600;
-		color: var(--text-primary);
-	}
-
-	&__signature-sentinel {
-		grid-column: 1 / -1;
-		height: 1px;
-	}
-
-	&__signature-loading {
-		grid-column: 1 / -1;
-		text-align: center;
-		padding: 8px;
-		font-size: 0.75rem;
-		color: var(--text-muted);
-	}
-
-	&__header {
-		flex-shrink: 0;
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		padding: 10px 16px;
-		background: var(--bg-head);
-		color: var(--text-normal);
-	}
-
-	&__title {
-		font-size: 1.125rem;
-		font-weight: 700;
-		margin: 0;
-		color: var(--text-normal);
-	}
-
-	&__close {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		width: 32px;
-		height: 32px;
-		background: transparent;
-		border: none;
-		border-radius: 4px;
-		font-size: 1.25rem;
-		line-height: 1;
-		cursor: pointer;
-		color: var(--text-normal);
-		transition: background 0.15s;
-
-		&:hover {
-			background: rgba(255, 255, 255, 0.15);
-			color: var(--text-normal);
-		}
-	}
-
-	&__field {
-		margin-bottom: 16px;
-
-		> label {
-			display: block;
-			font-size: 0.9375rem;
-			font-weight: 500;
-			margin-bottom: 6px;
-			color: var(--text-primary);
-		}
-
-		> input, > textarea {
-			width: 100%;
-			padding: 8px 12px;
-			border: 1px solid var(--border-default);
-			border-radius: 6px;
-			font-size: 0.875rem;
-			background: var(--bg-input);
-			color: var(--text-primary);
-			resize: none;
-
-			&::placeholder {
-				color: var(--text-muted);
-			}
-
-			&:disabled {
-				background: var(--bg-subtle);
-				color: var(--text-muted);
-				cursor: not-allowed;
-			}
-		}
-	}
-
-	&__label-row {
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		margin-bottom: 6px;
-		gap: 8px;
-
-		> label {
-			margin: 0;
-			font-size: 0.9375rem;
-			font-weight: 500;
-			color: var(--text-primary);
-		}
-	}
-
-	&__anon-toggle {
-		display: inline-flex;
-		align-items: center;
-		gap: 6px;
-		font-size: 0.875rem;
-		color: var(--text-secondary);
-		cursor: pointer;
-		user-select: none;
-
-		> input[type="checkbox"] {
-			margin: 0;
-			width: 18px;
-			height: 18px;
-			cursor: pointer;
-			accent-color: var(--color-donate);
-		}
-	}
-
-	&__balance {
-		font-size: 0.8125rem;
-		font-weight: 600;
-		color: var(--color-blue);
-		font-variant-numeric: tabular-nums;
-	}
-
-	&__msg-counter {
-		font-size: 0.75rem;
-		color: var(--text-muted);
-		font-variant-numeric: tabular-nums;
-
-		&--max {
-			color: var(--color-danger);
-			font-weight: 600;
-		}
-	}
-
-	&__presets {
-		display: flex;
-		flex-wrap: wrap;
-		gap: 6px;
-		margin-top: 8px;
-	}
-
-	&__preset {
-		padding: 6px 12px;
-		border: 1px solid var(--border-default);
-		border-radius: 20px;
-		background: var(--bg-subtle);
-		color: var(--text-primary);
-		font-size: 0.8125rem;
-		cursor: pointer;
-		transition: all 0.15s;
-
-		&:hover {
-			border-color: var(--color-donate);
-		}
-
-		&--active {
-			background: var(--color-donate);
-			color: #fff;
-			border-color: var(--color-donate);
-		}
-	}
-
-	// 크루원 선택
-	&__crew {
-		margin-bottom: 16px;
-		padding: 12px;
-		background: var(--bg-subtle);
-		border: 1px solid var(--border-default);
-		border-radius: 8px;
-	}
-
-	&__crew-label {
-		display: block;
-		font-size: 0.9375rem;
-		font-weight: 500;
-		margin-bottom: 8px;
-		color: var(--text-primary);
-	}
-
-	&__crew-tag {
-		display: inline-block;
-		padding: 2px 8px;
-		background: var(--sidebar-active-bg);
-		color: var(--color-donate);
-		border-radius: 4px;
-		font-size: 0.75rem;
-		font-weight: 600;
-		margin-left: 4px;
-	}
-
-	&__crew-list {
-		display: flex;
-		flex-wrap: wrap;
-		gap: 8px;
-	}
-
-	&__crew-item {
-		display: flex;
-		flex-direction: column;
-		align-items: center;
-		gap: 4px;
-		padding: 8px 12px;
-		border: 2px solid var(--border-default);
-		border-radius: 8px;
-		background: var(--bg-page);
-		color: var(--text-primary);
-		cursor: pointer;
-		font-size: 0.75rem;
-		min-width: 72px;
-		transition: all 0.15s;
-
-		&:hover {
-			border-color: var(--color-donate);
-		}
-
-		&--active {
-			border-color: var(--color-donate);
-			background: var(--sidebar-active-bg);
-		}
-
-		> span {
-			font-weight: 500;
-			max-width: 64px;
-			overflow: hidden;
-			text-overflow: ellipsis;
-			white-space: nowrap;
-		}
-	}
-
-	&__crew-thumb {
-		width: 40px;
-		height: 40px;
-		border-radius: 50%;
-		object-fit: cover;
-
-		&--default {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			background: var(--bg-elevated);
-			color: var(--text-secondary);
-			font-size: 0.875rem;
-			font-weight: 600;
-		}
-	}
-
-	// 푸터
-	&__footer {
-		flex-shrink: 0;
-		display: flex;
-		gap: 12px;
-		justify-content: center;
-		padding: 12px 24px;
-		border-top: 1px solid var(--border-default);
-		background: var(--bg-page);
-
-		@media (max-width: 640px) {
-			padding: 12px 16px;
-		}
-	}
-
-	&__btn {
-		padding: 8px 20px;
-		border-radius: 6px;
-		font-size: 0.875rem;
-		font-weight: 500;
-		cursor: pointer;
-		border: 1px solid var(--border-default);
-		background: var(--bg-subtle);
-		color: var(--text-primary);
-		transition: all 0.15s;
-
-		&:hover {
-			background: var(--btn-default-hover);
-		}
-
-		&--primary {
-			background: var(--color-donate);
-			color: #fff;
-			border-color: var(--color-donate);
-
-			&:hover {
-				background: var(--color-donate-hover);
-				border-color: var(--color-donate-hover);
-				opacity: 1;
-			}
-
-			&:disabled {
-				opacity: 0.5;
-				cursor: not-allowed;
-			}
-		}
-	}
-
-	// 완료 화면
-	&__done {
-		text-align: center;
-		padding: 24px 0;
-		color: var(--text-primary);
-	}
-
-	&__done-icon {
-		font-size: 3rem;
-		margin-bottom: 12px;
-	}
-
-	&__done-text {
-		font-size: 1.1rem;
-		font-weight: 600;
-		margin-bottom: 20px;
-		color: var(--text-primary);
-	}
-}

+ 0 - 89
app/(main)/watch/[identifier]/donation-toast.scss

@@ -1,89 +0,0 @@
-.donation-toast {
-	position: fixed;
-	top: 80px;
-	right: 24px;
-	z-index: 60;
-	display: flex;
-	flex-direction: column;
-	gap: 8px;
-	max-width: 360px;
-	pointer-events: none;
-
-	@media (max-width: 768px) {
-		top: 12px;
-		right: 12px;
-		left: 12px;
-		max-width: none;
-	}
-
-	&__item {
-		display: flex;
-		align-items: flex-start;
-		gap: 10px;
-		padding: 12px 14px;
-		background: linear-gradient(135deg, #FFD700 0%, #FFC400 100%);
-		color: #2a1a00;
-		border-radius: 10px;
-		box-shadow: 0 8px 24px rgba(0, 0, 0, 0.18), 0 2px 6px rgba(0, 0, 0, 0.08);
-		font-size: 0.875rem;
-		line-height: 1.4;
-		animation: donation-toast-slide-in 0.25s ease-out;
-		pointer-events: auto;
-	}
-
-	&__icon {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 24px;
-		height: 24px;
-		flex-shrink: 0;
-		color: #b45309;
-
-		svg {
-			font-size: 1rem;
-		}
-	}
-
-	&__content {
-		flex: 1;
-		min-width: 0;
-		font-weight: 600;
-		word-break: break-word;
-	}
-
-	&__close {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 22px;
-		height: 22px;
-		flex-shrink: 0;
-		border: none;
-		background: transparent;
-		color: #6b4500;
-		cursor: pointer;
-		border-radius: 4px;
-		transition: background 0.15s, color 0.15s;
-
-		svg {
-			font-size: 0.8125rem;
-		}
-
-		&:hover {
-			background: rgba(0, 0, 0, 0.08);
-			color: #2a1a00;
-		}
-	}
-}
-
-@keyframes donation-toast-slide-in {
-	from {
-		opacity: 0;
-		transform: translateX(20px);
-	}
-	to {
-		opacity: 1;
-		transform: translateX(0);
-	}
-}

+ 0 - 207
app/(main)/watch/[identifier]/leaderboard-panel.scss

@@ -1,207 +0,0 @@
-.leaderboard-panel {
-	position: absolute;
-	inset: 0;
-	z-index: 20;
-	display: flex;
-	flex-direction: column;
-
-	&__overlay {
-		position: absolute;
-		inset: 0;
-		background: var(--overlay-color);
-	}
-
-	&__box {
-		position: relative;
-		z-index: 1;
-		display: flex;
-		flex-direction: column;
-		height: 100%;
-		background: var(--bg-page);
-	}
-
-	&__header {
-		display: flex;
-		align-items: center;
-		gap: 8px;
-		padding: 8px 12px;
-		border-bottom: 1px solid var(--border-default);
-		flex-shrink: 0;
-	}
-
-	&__back {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 32px;
-		height: 32px;
-		border: none;
-		background: transparent;
-		color: var(--text-secondary);
-		border-radius: 4px;
-		cursor: pointer;
-
-		&:hover {
-			background: var(--border-default);
-			color: var(--text-primary);
-		}
-	}
-
-	&__title {
-		flex: 1;
-		margin: 0;
-		font-size: 0.9375rem;
-		font-weight: 700;
-		color: var(--text-primary);
-	}
-
-	&__count {
-		font-size: 0.75rem;
-		color: var(--text-muted);
-	}
-
-	&__body {
-		flex: 1;
-		overflow-y: auto;
-		padding: 4px 0;
-
-		&::-webkit-scrollbar {
-			width: 4px;
-		}
-
-		&::-webkit-scrollbar-thumb {
-			background: var(--border-strong);
-			border-radius: 2px;
-		}
-	}
-
-	&__loading, &__empty, &__error {
-		text-align: center;
-		padding: 32px 16px;
-		font-size: 0.8125rem;
-		color: var(--text-muted);
-	}
-
-	&__error {
-		color: #dc2626;
-	}
-
-	&__list {
-		list-style: none;
-		margin: 0;
-		padding: 0;
-	}
-
-	&__item {
-		display: flex;
-		align-items: center;
-		gap: 10px;
-		padding: 8px 12px;
-		border-bottom: 1px solid var(--bg-subtle);
-
-		&:last-child {
-			border-bottom: none;
-		}
-	}
-
-	&__rank {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 28px;
-		height: 28px;
-		font-size: 0.875rem;
-		font-weight: 700;
-		color: var(--text-secondary);
-		flex-shrink: 0;
-
-		&--top1 {
-			color: #f59e0b;
-		}
-
-		&--top2 {
-			color: #9ca3af;
-		}
-
-		&--top3 {
-			color: #b45309;
-		}
-	}
-
-	&__avatar {
-		width: 36px;
-		height: 36px;
-		border-radius: 50%;
-		overflow: hidden;
-		flex-shrink: 0;
-		background: var(--bg-subtle);
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-
-		img {
-			width: 100%;
-			height: 100%;
-			object-fit: cover;
-		}
-	}
-
-	&__avatar-fallback {
-		font-size: 1.5rem;
-		color: var(--text-muted);
-	}
-
-	&__name {
-		flex: 1;
-		min-width: 0;
-		display: flex;
-		flex-direction: column;
-		gap: 2px;
-	}
-
-	&__name-text {
-		font-size: 0.8125rem;
-		font-weight: 600;
-		color: var(--text-primary);
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-	}
-
-	&__sid {
-		font-size: 0.6875rem;
-		color: var(--text-muted);
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-	}
-
-	&__xp {
-		font-size: 0.8125rem;
-		font-weight: 700;
-		color: var(--color-donate);
-		flex-shrink: 0;
-	}
-
-	&__footer {
-		flex-shrink: 0;
-		padding: 10px 16px;
-		border-top: 1px solid var(--border-default);
-		background: var(--bg-subtle);
-		text-align: center;
-	}
-
-	&__my, &__my-empty {
-		margin: 0;
-		font-size: 0.8125rem;
-		color: var(--text-primary);
-
-		strong {
-			color: var(--color-donate);
-		}
-	}
-
-	&__my-empty {
-		color: var(--text-muted);
-	}
-}

+ 2 - 2
app/(main)/watch/[identifier]/style.scss

@@ -64,7 +64,7 @@
 		color: #999;
 
 		a {
-			color: var(--color-donate);
+			color: var(--color-accent);
 			text-decoration: underline;
 		}
 	}
@@ -165,7 +165,7 @@
 		width: 14px;
 		height: 14px;
 		border-radius: 50%;
-		background: var(--color-donate);
+		background: var(--color-accent);
 		color: #fff;
 		font-size: 0.5625rem;
 		flex-shrink: 0;

+ 0 - 35
app/(main)/watch/[identifier]/youtube-chat-iframe.scss

@@ -38,39 +38,4 @@
 		line-height: 1.5;
 	}
 
-	&__footer {
-		flex-shrink: 0;
-		padding: 12px;
-		border-top: 1px solid var(--border-default);
-		background: var(--bg-elevated);
-	}
-
-	&__donate-btn {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		gap: 8px;
-		width: 100%;
-		height: 44px;
-		border: none;
-		border-radius: 8px;
-		background: #FFD700;
-		color: #333;
-		font-size: 0.9375rem;
-		font-weight: 700;
-		cursor: pointer;
-		transition: background 0.15s, transform 0.05s;
-
-		svg {
-			font-size: 1rem;
-		}
-
-		&:hover {
-			background: #FFC400;
-		}
-
-		&:active {
-			transform: scale(0.98);
-		}
-	}
 }

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

@@ -1,70 +0,0 @@
-import { NextRequest, NextResponse } from 'next/server';
-import { ResultDto } from '@/types/response/common';
-import { fetchJson } from '@/lib/utils/server';
-
-function buildEndpoint(path?: string[]) {
-	const subPath = path ? `/${path.join('/')}` : '';
-	return `/api/channel-titles${subPath}`;
-}
-
-export async function GET(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
-	const { path } = await params;
-	const endpoint = buildEndpoint(path);
-	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 = buildEndpoint(path);
-	const contentType = request.headers.get('content-type') || '';
-
-	// 멀티파트 업로드는 boundary 보존을 위해 arrayBuffer로 그대로 전달
-	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: (await request.text()) || '{}'
-	});
-
-	return NextResponse.json(res);
-}
-
-export async function PUT(request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
-	const { path } = await params;
-	const endpoint = buildEndpoint(path);
-	const contentType = request.headers.get('content-type') || '';
-
-	if (contentType.includes('multipart/form-data')) {
-		const res: ResultDto = await fetchJson(endpoint, {
-			method: 'PUT',
-			body: await request.arrayBuffer(),
-			headers: { 'Content-Type': contentType }
-		});
-
-		return NextResponse.json(res);
-	}
-
-	const res: ResultDto = await fetchJson(endpoint, {
-		method: 'PUT',
-		body: (await request.text()) || '{}'
-	});
-
-	return NextResponse.json(res);
-}
-
-export async function DELETE(_request: NextRequest, { params }: { params: Promise<{ path?: string[] }> }) {
-	const { path } = await params;
-	const endpoint = buildEndpoint(path);
-	const res: ResultDto = await fetchJson(endpoint, { method: 'DELETE' });
-	return NextResponse.json(res);
-}

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

@@ -1,18 +0,0 @@
-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: (await request.text()) || '{}' });
-	return NextResponse.json(res);
-}

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

@@ -1,46 +0,0 @@
-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 text = await request.text();
-	const body = text || '{}';
-	const res: ResultDto = await fetchJson(endpoint, { method: 'POST', body });
-
-	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);
-}

+ 0 - 12
app/api/ranking/[[...path]]/route.ts

@@ -1,12 +0,0 @@
-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 subPath = path ? `/${path.join('/')}` : '';
-	const endpoint = `/api/ranking${subPath}`;
-	const url = new URL(request.url);
-	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
-	return NextResponse.json(res);
-}

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

@@ -1,11 +0,0 @@
-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);
-}

+ 1 - 1
app/auth/sns/google/callback/style.scss

@@ -23,7 +23,7 @@
 		width: 36px;
 		height: 36px;
 		border: 3px solid var(--border-default);
-		border-top-color: var(--color-donate);
+		border-top-color: var(--color-accent);
 		border-radius: 50%;
 		animation: sns-callback-spin 0.8s linear infinite;
 		margin-bottom: 4px;

+ 15 - 6
app/component/Header.tsx

@@ -13,18 +13,24 @@ import Profile from '@/app/component/Profile';
 import Logo from '@/app/component/Logo';
 import PointChargeIcon from '@/public/icons/layout/point.svg';
 import useCart from '@/hooks/useCart';
+import { FEATURE_CHANNEL } from '@/constants/features';
 
-const navItems = [
+// 채널/방송 메뉴(생방송·크리에이터)는 채널 기능 ON 일 때만 노출
+const channelNavItems = [
 	{ href: '/live', label: '생방송' },
-	{ href: '/creators', label: '크리에이터' },
+	{ href: '/creators', label: '크리에이터' }
+];
+
+const baseNavItems = [
 	{ href: '/games', label: '게임' },
 	{ href: '/feed/all', label: '피드' },
-	{ href: '/ranking/hall-of-fame', label: '순위' },
 	{ href: '/store', label: '상점' },
 	{ href: '/attendance', label: '출석부' },
 	{ href: '/board/notice', label: '고객지원' }
 ];
 
+const navItems = FEATURE_CHANNEL ? [...channelNavItems, ...baseNavItems] : baseNavItems;
+
 const SCROLL_AMOUNT = 120;
 
 type Props = {
@@ -60,9 +66,12 @@ export default function Header({ sidebarOpen, onToggle }: Props)
 
 			{/* 1줄: 로고 + 우측 아이콘 (PC에서는 전체 내비) */}
 			<div className={Styles.headerRow1}>
-				<button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴'>
-					<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
-				</button>
+				{/* 햄버거는 채널 사이드바 토글 전용 — 채널 기능 OFF 시 미노출 */}
+				{FEATURE_CHANNEL && (
+					<button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴'>
+						<FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
+					</button>
+				)}
 
 				<Link href='/' className={Styles.logo} aria-label="DPOT 홈">
 					<Logo size="sm" />

+ 8 - 5
app/component/Layout.tsx

@@ -6,6 +6,7 @@ import Header from './Header';
 import Footer from './Footer'
 import ChannelSidebar from '@/app/component/channel/ChannelSidebar';
 import { ChannelListItem } from '@/types/channel';
+import { FEATURE_CHANNEL } from '@/constants/features';
 
 type Props = {
 	children: React.ReactNode;
@@ -25,17 +26,19 @@ export default function Layout({ children, initialChannels }: Props) {
 
 	return (
 		<>
-			<div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}`}>
+			<div id='container' className={`${Styles.container}${sidebarOpen ? ` ${Styles.sidebarOpen}` : ''}${FEATURE_CHANNEL ? '' : ` ${Styles.noAside}`}`}>
 
 				<Header sidebarOpen={sidebarOpen} onToggle={toggleSidebar} />
 
 				{/* 모바일 오버레이 */}
 				<div className={Styles.overlay} onClick={closeOverlay} />
 
-				{/* 좌측 채널 사이드바 */}
-				<aside id='aside' className={Styles.aside}>
-					<ChannelSidebar initialChannels={initialChannels} />
-				</aside>
+				{/* 좌측 채널 사이드바 — 채널 기능 OFF 시 미노출 */}
+				{FEATURE_CHANNEL && (
+					<aside id='aside' className={Styles.aside}>
+						<ChannelSidebar initialChannels={initialChannels} />
+					</aside>
+				)}
 
 				{/* 메인 내용 */}
 				<main id='main' className={`${Styles.main} relative`}>

+ 2 - 7
app/component/Profile.tsx

@@ -11,6 +11,7 @@ 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 { FEATURE_CHANNEL } from '@/constants/features';
 
 import {
 	DropdownMenu,
@@ -167,12 +168,6 @@ function ProfileInner() {
 									<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>
@@ -180,7 +175,7 @@ function ProfileInner() {
 
 				<DropdownMenuSeparator />
 
-				{member.isCreator && (
+				{FEATURE_CHANNEL && member.isCreator && (
 					<DropdownMenuItem asChild>
 						<Link href="/studio">
 							<span className="profile-dropdown__menu-icon">

+ 1 - 1
app/component/payButton.scss

@@ -1,4 +1,4 @@
-// 결제 버튼 — watch 페이지 "후원하기" 버튼과 동일한 톤 (노랑)
+// 결제 버튼 (노랑)
 // 다크 모드: 보라
 .pay-button {
 	position: relative;

+ 4 - 4
app/globals.scss

@@ -72,8 +72,8 @@ body {
 		--btn-primary: #007bc3;
 		--btn-primary-hover: #0066a3;
 		--btn-primary-shadow: #00558a;
-		--color-donate: #F7931A;
-		--color-donate-hover: #e5851a;
+		--color-accent: #F7931A;
+		--color-accent-hover: #e5851a;
 		--color-danger: #d51b28;
 		--color-success: #4caf50;
 		--color-warning-bg: #fefce8;
@@ -159,8 +159,8 @@ body {
 		--btn-primary: #8b5cf6;
 		--btn-primary-hover: #7c3aed;
 		--btn-primary-shadow: #6d28d9;
-		--color-donate: #F7931A;
-		--color-donate-hover: #f59e0b;
+		--color-accent: #F7931A;
+		--color-accent-hover: #f59e0b;
 		--color-danger: #ef4444;
 		--color-success: #22c55e;
 		--color-warning-bg: #422006;

+ 0 - 693
app/remote/[widgetToken]/page.tsx

@@ -1,693 +0,0 @@
-'use client';
-
-import { use, useEffect, useMemo, useState, useRef, useCallback } from 'react';
-import { createPortal } from 'react-dom';
-import * as signalR from '@microsoft/signalr';
-import { Pause, Play, SkipForward, Bell, BellOff, Volume2, Image as ImageIcon, MoreVertical, X, AlertTriangle, Wifi, WifiOff, ChevronUp, ChevronDown } from 'lucide-react';
-import { DonationAlertData, DonationRemoteState, AlertQueueItem, AlertStatusKey, RemoteCrewItem, RemoteCrewMembersResponse, RemoteCrewMemberItem } from '@/types/donation';
-import { fetchApi } from '@/lib/utils/client';
-import './style.scss';
-
-type Props = {
-	params: Promise<{ widgetToken: string }>;
-};
-
-const STATUS_LABEL: Record<AlertStatusKey, string> = {
-	queued: '대기',
-	playing: '재생 중',
-	delivered: '완료',
-	skipped: '건너뜀',
-	ignored: '무시',
-	failed: '실패'
-};
-
-type PlayingMeta = { startedAt: number; durationSec: number };
-
-/**
- * 둥근 시계형 카운트다운 — 보라색 호가 시계방향으로 차오르며 남은 초 표시.
- * 매 250ms 다시 렌더링 (성능 negligible).
- */
-function TimerRing({ meta }: { meta: PlayingMeta }) {
-	const [now, setNow] = useState<number>(() => Date.now());
-	useEffect(() => {
-		const id = setInterval(() => setNow(Date.now()), 250);
-		return () => clearInterval(id);
-	}, []);
-
-	const elapsedSec = (now - meta.startedAt) / 1000;
-	const remainingSec = Math.max(0, meta.durationSec - elapsedSec);
-	const progress = Math.min(1, elapsedSec / meta.durationSec);
-
-	const r = 12;
-	const c = 2 * Math.PI * r;
-	const offset = c * (1 - progress);
-
-	return (
-		<span className="dpot-remote__timer-ring" aria-label={`남은 시간 ${Math.ceil(remainingSec)}초`}>
-			<svg width="30" height="30" viewBox="0 0 30 30">
-				<circle cx="15" cy="15" r={r} fill="none" stroke="rgba(255,255,255,0.15)" strokeWidth="2.5" />
-				<circle
-					cx="15" cy="15" r={r}
-					fill="none"
-					stroke="#7c3aed"
-					strokeWidth="2.5"
-					strokeLinecap="round"
-					strokeDasharray={c}
-					strokeDashoffset={offset}
-					transform="rotate(-90 15 15)"
-					style={{ transition: 'stroke-dashoffset 0.25s linear' }}
-				/>
-			</svg>
-			<span className="dpot-remote__timer-num">{Math.ceil(remainingSec)}</span>
-		</span>
-	);
-}
-
-type TabKey = 'all'|'queued'|'ignored'|'failed'|'skipped'|'delivered';
-
-const TAB_DEFS: { key: TabKey; label: string }[] = [
-	{ key: 'all', label: '전체' },
-	{ key: 'queued', label: '대기' },
-	{ key: 'ignored', label: '무시' },
-	{ key: 'failed', label: '실패' },
-	{ key: 'skipped', label: '건너뜀' },
-	{ key: 'delivered', label: '완료' }
-];
-
-export default function RemotePage({ params }: Props)
-{
-	const { widgetToken } = 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 [menuPos, setMenuPos] = useState<{ top: number; right: number }|null>(null);
-	const [activeTab, setActiveTab] = useState<TabKey>('all');
-	const [isPopup, setIsPopup] = useState(false);
-	const [playingMeta, setPlayingMeta] = useState<Record<number, PlayingMeta>>({});
-
-	// 통합 테스트 후원 form (selectedCrewID null 이면 일반, 값 있으면 크루 후원)
-	const [testName, setTestName] = useState('');
-	const [testAmount, setTestAmount] = useState<number>(1000);
-	const [testMessage, setTestMessage] = useState('');
-	const [isTestMode, setIsTestMode] = useState(true);
-	const [testSubmitting, setTestSubmitting] = useState(false);
-	const [crews, setCrews] = useState<RemoteCrewItem[]>([]);
-	const [crewMembers, setCrewMembers] = useState<RemoteCrewMemberItem[]>([]);
-	const [selectedCrewID, setSelectedCrewID] = useState<number|null>(null);
-	const [selectedMemberID, setSelectedMemberID] = useState<number|null>(null);
-
-	const connectionRef = useRef<signalR.HubConnection|null>(null);
-
-	useEffect(() => {
-		setIsPopup(typeof window !== 'undefined' && !!window.opener);
-	}, []);
-
-	// 메뉴 외부 클릭 시 닫기
-	useEffect(() => {
-		if (openMenuID === null) {
-			return;
-		}
-		const handler = (e: MouseEvent) => {
-			const target = e.target as HTMLElement;
-			if (!target.closest('.dpot-remote__menu-dropdown') && !target.closest('.dpot-remote__menu-btn')) {
-				setOpenMenuID(null);
-				setMenuPos(null);
-			}
-		};
-		document.addEventListener('click', handler);
-		return () => document.removeEventListener('click', handler);
-	}, [openMenuID]);
-
-	const toggleMenu = (alertID: number, e: React.MouseEvent<HTMLButtonElement>) => {
-		if (openMenuID === alertID) {
-			setOpenMenuID(null);
-			setMenuPos(null);
-			return;
-		}
-		const rect = e.currentTarget.getBoundingClientRect();
-		setMenuPos({
-			top: rect.bottom + 4,
-			right: window.innerWidth - rect.right
-		});
-		setOpenMenuID(alertID);
-	};
-
-	const closeMenu = () => {
-		setOpenMenuID(null);
-		setMenuPos(null);
-	};
-
-	const loadState = useCallback(async () => {
-		try {
-			const res = await fetchApi<DonationRemoteState & { queue: AlertQueueItem[] }>(`${apiBase}/state/${widgetToken}`, { 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 {}
-	}, [apiBase, widgetToken]);
-
-	const loadCrews = useCallback(async () => {
-		try {
-			const res = await fetchApi<{ crews: RemoteCrewItem[] }>(`${apiBase}/crews/${widgetToken}`, { silent: true });
-			if (res.data) {
-				setCrews(res.data.crews);
-			}
-		} catch {}
-	}, [apiBase, widgetToken]);
-
-	const loadCrewMembers = useCallback(async (crewID: number) => {
-		try {
-			const res = await fetchApi<RemoteCrewMembersResponse>(`${apiBase}/crews/${widgetToken}/${crewID}/members`, { silent: true });
-			if (res.data) {
-				setCrewMembers(res.data.members);
-				setSelectedMemberID(null);
-			}
-		} catch {}
-	}, [apiBase, widgetToken]);
-
-	const connectHub = useCallback(() => {
-		const conn = new signalR.HubConnectionBuilder().withUrl(hubUrl).withAutomaticReconnect().build();
-
-		conn.on('ReceiveAlert', (data: DonationAlertData) => {
-			setQueue(prev => {
-				const existing = prev.find(q => q.alertID === data.alertID);
-				if (existing) {
-					return prev.map(q => q.alertID === data.alertID ? { ...q, status: 'queued' as AlertStatusKey } : q);
-				}
-				const item: AlertQueueItem = {
-					alertID: data.alertID,
-					donationID: data.donationID,
-					status: 'queued',
-					sponsorMemberID: data.sponsorMemberID,
-					sendName: data.sendName,
-					amount: data.amount,
-					message: data.message,
-					isTest: data.isTest,
-					createdAt: data.createdAt
-				};
-				return [item, ...prev];
-			});
-		});
-
-		conn.on('ReceiveState', (s: DonationRemoteState) => setState(s));
-		conn.on('ReceiveSkip', () => {
-			setQueue(prev => prev.map(q => q.status === 'playing' ? { ...q, status: 'skipped' } : q));
-		});
-
-		conn.on('ReceiveQueueReorder', (data: { swap?: number[] }) => {
-			if (!data?.swap || data.swap.length !== 2) {
-				return;
-			}
-			const [a, b] = data.swap;
-			setQueue(prev => {
-				const idxA = prev.findIndex(q => q.alertID === a);
-				const idxB = prev.findIndex(q => q.alertID === b);
-				if (idxA < 0 || idxB < 0) {
-					return prev;
-				}
-				const next = [...prev];
-				[next[idxA], next[idxB]] = [next[idxB], next[idxA]];
-				return next;
-			});
-		});
-
-		conn.on('ReceiveAlertStatusUpdate', (update: { alertID: number; status: AlertStatusKey; displayDurationSec?: number|null; startedAt?: string|null }) => {
-			setQueue(prev => prev.map(q => q.alertID === update.alertID ? { ...q, status: update.status } : q));
-
-			// playing 진입 시 타이머 ring 시작용 메타 저장
-			if (update.status === 'playing' && update.displayDurationSec && update.displayDurationSec > 0) {
-				const started = update.startedAt ? new Date(update.startedAt).getTime() : Date.now();
-				setPlayingMeta(prev => ({ ...prev, [update.alertID]: { startedAt: started, durationSec: update.displayDurationSec! } }));
-			} else if (update.status !== 'playing') {
-				// playing 종료 → 메타 제거 (memory leak 방지)
-				setPlayingMeta(prev => {
-					if (!(update.alertID in prev)) {
-						return prev;
-					}
-					const next = { ...prev };
-					delete next[update.alertID];
-					return next;
-				});
-			}
-		});
-
-		conn.start().then(() => {
-			conn.invoke('JoinChannel', widgetToken);
-			setConnected(true);
-		}).catch(() => {});
-
-		conn.onclose(() => setConnected(false));
-		conn.onreconnected(() => { conn.invoke('JoinChannel', widgetToken); setConnected(true); });
-		connectionRef.current = conn;
-	}, [hubUrl, widgetToken]);
-
-	useEffect(() => {
-		loadState();
-		loadCrews();
-		connectHub();
-		return () => { connectionRef.current?.stop(); };
-	}, [loadState, loadCrews, connectHub]);
-
-	useEffect(() => {
-		if (selectedCrewID) {
-			loadCrewMembers(selectedCrewID);
-		} else {
-			setCrewMembers([]);
-			setSelectedMemberID(null);
-		}
-	}, [selectedCrewID, loadCrewMembers]);
-
-	const togglePause = async () => {
-		const next = { ...state, isPaused: !state.isPaused };
-		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
-	};
-
-	const toggleAccepting = async () => {
-		const next = { ...state, isAccepting: !state.isAccepting };
-		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
-	};
-
-	const toggleAudioOnly = async () => {
-		const next = { ...state, isAudioOnly: !state.isAudioOnly, isVideoOnly: false };
-		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
-	};
-
-	const toggleVideoOnly = async () => {
-		const next = { ...state, isVideoOnly: !state.isVideoOnly, isAudioOnly: false };
-		setState(next);
-		await fetchApi(`${apiBase}/state`, { method: 'POST', body: { ...next, widgetToken }, silent: true });
-	};
-
-	const skipCurrent = async () => {
-		await fetchApi(`${apiBase}/skip/${widgetToken}`, { 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));
-		closeMenu();
-	};
-
-	// 일시정지 중일 때만, queued 상태 인접 알림과 swap
-	const swapAlert = async (alertID: number, direction: 'up'|'down') => {
-		const queuedItems = filtered.filter(q => q.status === 'queued');
-		const idx = queuedItems.findIndex(q => q.alertID === alertID);
-		if (idx < 0) {
-			return;
-		}
-		const targetIdx = direction === 'up' ? idx - 1 : idx + 1;
-		if (targetIdx < 0 || targetIdx >= queuedItems.length) {
-			return;
-		}
-		const partner = queuedItems[targetIdx];
-		await fetchApi(`${apiBase}/reorder`, {
-			method: 'POST',
-			body: { widgetToken, alertIDA: alertID, alertIDB: partner.alertID },
-			silent: true
-		});
-		// 백엔드가 broadcast 한 ReceiveQueueReorder 가 도착하면 화면 갱신됨
-	};
-
-	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));
-		closeMenu();
-	};
-
-	const playNow = async (alertID: number) => {
-		closeMenu();
-		// status 변경은 SignalR ReceiveAlertStatusUpdate에 맡김 (위젯이 재생 시작 → 'playing' broadcast)
-		// 직접 setQueue 하면 race condition으로 'playing'을 덮어씀
-		await fetchApi(`${apiBase}/play-now/${alertID}`, { method: 'POST', silent: true });
-	};
-
-	const markDelivered = async (alertID: number) => {
-		closeMenu();
-		// Playing 상태에서 stuck된 알림 강제 완료 처리 (위젯 종료/네트워크 단절 등)
-		// status 변경은 SignalR broadcast로 동기화
-		await fetchApi(`${apiBase}/mark-delivered/${alertID}`, { method: 'POST', silent: true });
-	};
-
-	const submitTestDonate = async () => {
-		if (!testName.trim()) {
-			alert('별명을 입력하세요.');
-			return;
-		}
-		if (testAmount < 1000) {
-			alert('최소 후원 금액은 1,000원입니다.');
-			return;
-		}
-		if (selectedCrewID && (selectedMemberID === null || selectedMemberID <= 0)) {
-			alert('크루원을 선택하세요.');
-			return;
-		}
-		if (!isTestMode) {
-			const isCrew = !!selectedCrewID;
-			const msg = isCrew
-				? '영구 후원으로 기록됩니다. 활성 크루 세션이 있으면 리더보드에 합산됩니다. 계속하시겠습니까?'
-				: '영구 후원으로 기록됩니다. 본인 채널 통계에 반영됩니다. 계속하시겠습니까?';
-			const ok = window.confirm(msg);
-			if (!ok) {
-				return;
-			}
-		}
-		setTestSubmitting(true);
-		try {
-			await fetchApi(`${apiBase}/test-donate`, {
-				method: 'POST',
-				body: {
-					widgetToken,
-					sendName: testName.trim(),
-					amount: testAmount,
-					message: testMessage.trim() || null,
-					crewMemberID: selectedCrewID ? selectedMemberID : null,
-					isTest: isTestMode
-				},
-				silent: false
-			});
-			setTestMessage('');
-		} catch (e) {
-			console.error('test-donate failed:', e);
-		} finally {
-			setTestSubmitting(false);
-		}
-	};
-
-	const counts = useMemo(() => ({
-		all: queue.length,
-		queued: queue.filter(q => q.status === 'queued' || q.status === 'playing').length,
-		ignored: queue.filter(q => q.status === 'ignored').length,
-		failed: queue.filter(q => q.status === 'failed').length,
-		skipped: queue.filter(q => q.status === 'skipped').length,
-		delivered: queue.filter(q => q.status === 'delivered').length
-	}), [queue]);
-
-	const filtered = useMemo(() => {
-		if (activeTab === 'all') {
-			return queue;
-		}
-		if (activeTab === 'queued') {
-			return queue.filter(q => q.status === 'queued' || q.status === 'playing');
-		}
-		return queue.filter(q => q.status === activeTab);
-	}, [activeTab, queue]);
-
-	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="dpot-remote">
-			{/* 상단 상태 배너 */}
-			<div className={`dpot-remote__banner${state.isPaused ? ' dpot-remote__banner--paused' : ' dpot-remote__banner--playing'}`} role="status">
-				{state.isPaused ? (
-					<><Pause size={14} aria-hidden="true" /><span>현재 알림 일시정지 중입니다</span></>
-				) : (
-					<><Play size={14} aria-hidden="true" /><span>현재 알림 재생 중입니다</span></>
-				)}
-			</div>
-
-			{/* 헤더 */}
-			<header className="dpot-remote__header">
-				<div className="dpot-remote__brand">
-					<span className="dpot-remote__brand-logo">DPOT</span>
-					<span className="dpot-remote__brand-title">리모콘</span>
-				</div>
-				<div className="dpot-remote__header-actions">
-					<span className={`dpot-remote__connection${connected ? ' dpot-remote__connection--on' : ' dpot-remote__connection--off'}`}>
-						{connected ? <Wifi size={12} aria-hidden="true" /> : <WifiOff size={12} aria-hidden="true" />}
-						<span>{connected ? '연결됨' : '연결 끊김'}</span>
-					</span>
-					{isPopup && (
-						<button type="button" className="dpot-remote__close-btn" onClick={() => window.close()} aria-label="닫기">
-							<X size={16} aria-hidden="true" />
-						</button>
-					)}
-				</div>
-			</header>
-
-			{/* 운영 토글 (음성만/영상만/후원수신) */}
-			<div className="dpot-remote__sub-controls">
-				<button type="button" className={`dpot-remote__sub-btn${state.isAccepting ? '' : ' dpot-remote__sub-btn--off'}`} onClick={toggleAccepting}>
-					{state.isAccepting ? <Bell size={14} aria-hidden="true" /> : <BellOff size={14} aria-hidden="true" />}
-					<span>{state.isAccepting ? '후원 받는 중' : '후원 안 받음'}</span>
-				</button>
-				<button type="button" className={`dpot-remote__sub-btn${state.isAudioOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleAudioOnly}>
-					<Volume2 size={14} aria-hidden="true" />
-					<span>음성만</span>
-				</button>
-				<button type="button" className={`dpot-remote__sub-btn${state.isVideoOnly ? ' dpot-remote__sub-btn--active' : ''}`} onClick={toggleVideoOnly}>
-					<ImageIcon size={14} aria-hidden="true" />
-					<span>영상만</span>
-				</button>
-			</div>
-
-			{/* 통합 테스트 후원 form (크루 미선택=일반, 선택=크루) */}
-			<fieldset className="dpot-remote__test-form">
-				<legend className="dpot-remote__test-form-legend">테스트 후원</legend>
-				<div className="dpot-remote__test-row">
-					<select
-						className="dpot-remote__select"
-						value={selectedCrewID ?? ''}
-						onChange={e => setSelectedCrewID(e.target.value ? parseInt(e.target.value, 10) : null)}
-					>
-						<option value="">크루 없음 (일반 후원)</option>
-						{crews.map(c => (
-							<option key={c.id} value={c.id}>크루 · {c.name} ({c.memberCount}명)</option>
-						))}
-					</select>
-					<select
-						className="dpot-remote__select"
-						value={selectedMemberID ?? ''}
-						onChange={e => setSelectedMemberID(e.target.value ? parseInt(e.target.value, 10) : null)}
-						disabled={!selectedCrewID || crewMembers.length === 0}
-					>
-						<option value="">{selectedCrewID ? '크루원 선택' : '— 크루원 —'}</option>
-						{crewMembers.map(m => (
-							<option key={m.id} value={m.id}>{m.nickname}{m.role ? ` (${m.role})` : ''}</option>
-						))}
-					</select>
-				</div>
-				<div className="dpot-remote__test-row">
-					<input
-						type="text"
-						className="dpot-remote__input"
-						placeholder="별명"
-						value={testName}
-						onChange={e => setTestName(e.target.value)}
-						maxLength={30}
-					/>
-					<input
-						type="number"
-						className="dpot-remote__input dpot-remote__input--amount"
-						placeholder="금액"
-						value={testAmount > 0 ? testAmount : ''}
-						onChange={e => setTestAmount(parseInt(e.target.value || '0', 10))}
-						min={1000}
-						step={1000}
-					/>
-				</div>
-				<textarea
-					className="dpot-remote__textarea"
-					placeholder="메시지 (선택)"
-					value={testMessage}
-					onChange={e => setTestMessage(e.target.value)}
-					rows={2}
-					maxLength={200}
-				/>
-				<div className="dpot-remote__test-form-footer">
-					<label className="dpot-remote__checkbox">
-						<input
-							type="checkbox"
-							checked={isTestMode}
-							onChange={e => setIsTestMode(e.target.checked)}
-						/>
-						<span>테스트 모드 (위젯에만 표시, DB 통계 미반영)</span>
-					</label>
-					<button
-						type="button"
-						className="dpot-remote__test-submit"
-						onClick={submitTestDonate}
-						disabled={testSubmitting}
-					>
-						{testSubmitting ? '전송 중...' : '후원하기'}
-					</button>
-				</div>
-				{!isTestMode && (
-					<p className="dpot-remote__warn" role="alert">
-						<AlertTriangle size={14} aria-hidden="true" />
-						<span>
-							{selectedCrewID
-								? '영구 후원으로 기록됩니다. 활성 크루 세션이 있을 경우 리더보드에 합산됩니다.'
-								: '영구 후원으로 기록됩니다. 통계에 반영됩니다.'}
-						</span>
-					</p>
-				)}
-			</fieldset>
-
-			{/* 6개 탭 */}
-			<div className="dpot-remote__tabs" role="tablist">
-				{TAB_DEFS.map(t => (
-					<button
-						key={t.key}
-						type="button"
-						role="tab"
-						aria-selected={activeTab === t.key}
-						className={`dpot-remote__tab${activeTab === t.key ? ' dpot-remote__tab--active' : ''}`}
-						onClick={() => setActiveTab(t.key)}
-					>
-						<span className="dpot-remote__tab-label">{t.label}</span>
-						<span className="dpot-remote__tab-count">{counts[t.key]}</span>
-					</button>
-				))}
-			</div>
-
-			{/* 후원 알림 listing */}
-			<div className="dpot-remote__list-wrap">
-				<div className="dpot-remote__list-header" role="row">
-					<span className="dpot-remote__col dpot-remote__col--reorder">순서</span>
-					<span className="dpot-remote__col dpot-remote__col--name">별명</span>
-					<span className="dpot-remote__col dpot-remote__col--amount">금액</span>
-					<span className="dpot-remote__col dpot-remote__col--msg">메시지</span>
-					<span className="dpot-remote__col dpot-remote__col--time">시간</span>
-					<span className="dpot-remote__col dpot-remote__col--status">상태</span>
-					<span className="dpot-remote__col dpot-remote__col--menu">기능</span>
-				</div>
-				<div className="dpot-remote__list">
-					{filtered.length === 0 && <div className="dpot-remote__empty">후원 알림이 없습니다</div>}
-					{filtered.map(item => {
-						// queued 항목 중 reorder 가능한 위치 판정
-						const queuedItems = filtered.filter(q => q.status === 'queued');
-						const qIdx = queuedItems.findIndex(q => q.alertID === item.alertID);
-						const canMoveUp = item.status === 'queued' && qIdx > 0;
-						const canMoveDown = item.status === 'queued' && qIdx >= 0 && qIdx < queuedItems.length - 1;
-						return (
-						<div key={item.alertID} className={`dpot-remote__row dpot-remote__row--${item.status}`} role="row">
-							<span className="dpot-remote__col dpot-remote__col--reorder">
-								{item.status === 'queued' ? (
-									<>
-										<button
-											type="button"
-											className="dpot-remote__reorder-btn"
-											onClick={() => swapAlert(item.alertID, 'up')}
-											disabled={!canMoveUp}
-											aria-label="위로"
-											title="위로"
-										>
-											<ChevronUp size={14} aria-hidden="true" />
-										</button>
-										<button
-											type="button"
-											className="dpot-remote__reorder-btn"
-											onClick={() => swapAlert(item.alertID, 'down')}
-											disabled={!canMoveDown}
-											aria-label="아래로"
-											title="아래로"
-										>
-											<ChevronDown size={14} aria-hidden="true" />
-										</button>
-									</>
-								) : null}
-							</span>
-							<span className="dpot-remote__col dpot-remote__col--name" title={item.sendName}>
-								{item.isTest && <span className="dpot-remote__test-badge">TEST</span>}
-								{item.sendName}
-							</span>
-							<span className="dpot-remote__col dpot-remote__col--amount">{item.amount.toLocaleString()}원</span>
-							<span className="dpot-remote__col dpot-remote__col--msg" title={item.message ?? ''}>{item.message ?? '—'}</span>
-							<span className="dpot-remote__col dpot-remote__col--time">{formatTime(item.createdAt)}</span>
-							<span className={`dpot-remote__col dpot-remote__col--status dpot-remote__status-badge dpot-remote__status-badge--${item.status}`}>
-								{item.status === 'playing' && playingMeta[item.alertID]
-									? <TimerRing meta={playingMeta[item.alertID]} />
-									: (STATUS_LABEL[item.status] ?? item.status)}
-							</span>
-							<span className="dpot-remote__col dpot-remote__col--menu">
-								<button
-									type="button"
-									className="dpot-remote__menu-btn"
-									onClick={(e) => toggleMenu(item.alertID, e)}
-									aria-label="메뉴 열기"
-									aria-expanded={openMenuID === item.alertID}
-								>
-									<MoreVertical size={16} aria-hidden="true" />
-								</button>
-							</span>
-						</div>
-						);
-					})}
-				</div>
-			</div>
-
-			{/* 하단 메인 컨트롤 */}
-			<div className="dpot-remote__main-controls">
-				<button
-					type="button"
-					className={`dpot-remote__main-btn dpot-remote__main-btn--pause${state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
-					onClick={() => { if (!state.isPaused) togglePause(); }}
-					disabled={state.isPaused}
-				>
-					<Pause size={18} aria-hidden="true" />
-					<span>일시정지</span>
-				</button>
-				<button
-					type="button"
-					className="dpot-remote__main-btn dpot-remote__main-btn--skip"
-					onClick={skipCurrent}
-				>
-					<SkipForward size={18} aria-hidden="true" />
-					<span>건너뛰기</span>
-				</button>
-				<button
-					type="button"
-					className={`dpot-remote__main-btn dpot-remote__main-btn--play${!state.isPaused ? ' dpot-remote__main-btn--active' : ''}`}
-					onClick={() => { if (state.isPaused) togglePause(); }}
-					disabled={!state.isPaused}
-				>
-					<Play size={18} aria-hidden="true" />
-					<span>재생</span>
-				</button>
-			</div>
-
-			{/* 메뉴 dropdown — Portal로 body에 렌더해서 list overflow 클립 회피 */}
-			{openMenuID !== null && menuPos && typeof document !== 'undefined' && (() => {
-				const activeItem = queue.find(q => q.alertID === openMenuID);
-				if (!activeItem) {
-					return null;
-				}
-				return createPortal(
-					<div
-						className="dpot-remote__menu-dropdown"
-						role="menu"
-						style={{ position: 'fixed', top: menuPos.top, right: menuPos.right }}
-					>
-						{activeItem.status === 'queued' && (
-							<>
-								<button type="button" className="dpot-remote__menu-item" onClick={() => playNow(activeItem.alertID)}>즉시 재생</button>
-								<button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
-							</>
-						)}
-						{activeItem.status === 'playing' && (
-							<button type="button" className="dpot-remote__menu-item" onClick={() => markDelivered(activeItem.alertID)}>완료 처리</button>
-						)}
-						{(activeItem.status === 'failed' || activeItem.status === 'skipped' || activeItem.status === 'ignored' || activeItem.status === 'delivered') && (
-							<button type="button" className="dpot-remote__menu-item" onClick={() => resendAlert(activeItem.alertID)}>재전송</button>
-						)}
-						{activeItem.status === 'skipped' && (
-							<button type="button" className="dpot-remote__menu-item dpot-remote__menu-item--danger" onClick={() => ignoreAlert(activeItem.alertID)}>무시</button>
-						)}
-					</div>,
-					document.body
-				);
-			})()}
-		</div>
-	);
-}

+ 0 - 744
app/remote/[widgetToken]/style.scss

@@ -1,744 +0,0 @@
-.dpot-remote {
-	display: flex;
-	flex-direction: column;
-	width: 100%;
-	max-width: 880px;
-	min-height: 100vh;
-	margin: 0 auto;
-	padding: 0;
-	background: var(--bg-page);
-	color: var(--text-primary);
-	font-size: 0.875rem;
-
-	&__banner {
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		gap: 8px;
-		padding: 8px 16px;
-		text-align: center;
-		font-size: 0.875rem;
-		font-weight: 700;
-		color: #ffffff;
-		letter-spacing: 0.02em;
-
-		&--paused {
-			background: #dc2626;
-			border-bottom: 1px solid #b91c1c;
-		}
-
-		&--playing {
-			background: #16a34a;
-			border-bottom: 1px solid #15803d;
-		}
-	}
-
-	&__header {
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		padding: 12px 20px;
-		border-bottom: 1px solid var(--border-default);
-		background: var(--bg-head);
-		color: var(--text-normal);
-	}
-
-	&__brand {
-		display: flex;
-		align-items: baseline;
-		gap: 6px;
-	}
-
-	&__brand-logo {
-		font-size: 1.25rem;
-		font-weight: 800;
-		color: #ffffff;
-		letter-spacing: -0.02em;
-	}
-
-	&__brand-title {
-		font-size: 1rem;
-		font-weight: 600;
-		color: var(--text-normal);
-	}
-
-	&__header-actions {
-		display: flex;
-		align-items: center;
-		gap: 12px;
-	}
-
-	&__connection {
-		display: inline-flex;
-		align-items: center;
-		gap: 5px;
-		font-size: 0.75rem;
-		font-weight: 600;
-		padding: 4px 10px;
-		border-radius: 999px;
-
-		&--on {
-			background: rgba(255, 255, 255, 0.18);
-			color: #d4f5dd;
-		}
-
-		&--off {
-			background: rgba(255, 255, 255, 0.18);
-			color: #fecaca;
-		}
-	}
-
-	&__close-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 32px;
-		height: 32px;
-		border: none;
-		background: transparent;
-		color: var(--text-normal);
-		font-size: 1.25rem;
-		line-height: 1;
-		cursor: pointer;
-		transition: background 0.15s;
-
-		&:hover {
-			background: var(--bg-subtle);
-			color: var(--text-primary);
-		}
-	}
-
-	&__sub-controls {
-		display: flex;
-		gap: 6px;
-		padding: 10px 16px;
-		border-bottom: 1px solid var(--border-default);
-	}
-
-	&__sub-btn {
-		flex: 1;
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		gap: 6px;
-		padding: 8px 6px;
-		border: 1px solid var(--border-default);
-		border-radius: 8px;
-		background: var(--bg-page);
-		color: var(--text-primary);
-		font-size: 0.75rem;
-		font-weight: 500;
-		cursor: pointer;
-		transition: all 0.15s;
-
-		&:hover {
-			background: var(--bg-subtle);
-		}
-
-		&--active {
-			border-color: #3b82f6;
-			background: rgba(59, 130, 246, 0.1);
-			color: #3b82f6;
-			font-weight: 600;
-		}
-
-		&--off {
-			border-color: #dc2626;
-			background: rgba(220, 38, 38, 0.1);
-			color: #dc2626;
-			font-weight: 600;
-		}
-	}
-
-	&__test-form {
-		margin: 12px 16px 0;
-		padding: 12px 14px 14px;
-		border: 1px solid var(--border-default);
-		border-radius: 10px;
-		background: var(--bg-subtle);
-	}
-
-	&__test-form-legend {
-		display: inline-flex;
-		align-items: center;
-		gap: 6px;
-		padding: 0 8px;
-		font-size: 1rem;
-		font-weight: 700;
-		color: var(--text-primary);
-	}
-
-	&__test-row {
-		display: flex;
-		gap: 8px;
-		margin-bottom: 8px;
-	}
-
-	&__input,
-	&__select,
-	&__textarea {
-		flex: 1;
-		padding: 8px 10px;
-		border: 1px solid var(--border-default);
-		border-radius: 6px;
-		background: var(--bg-page);
-		color: var(--text-primary);
-		font-size: 0.8125rem;
-		outline: none;
-		transition: border-color 0.15s;
-
-		&:focus {
-			border-color: #3b82f6;
-			box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
-		}
-
-		&:disabled {
-			background: var(--bg-subtle);
-			color: var(--text-muted);
-			cursor: not-allowed;
-		}
-	}
-
-	&__input {
-		&--amount {
-			max-width: 140px;
-			text-align: right;
-		}
-	}
-
-	&__textarea {
-		width: 100%;
-		min-height: 56px;
-		resize: vertical;
-		font-family: inherit;
-		margin-bottom: 8px;
-	}
-
-	&__test-form-footer {
-		display: flex;
-		align-items: center;
-		justify-content: space-between;
-		gap: 12px;
-		flex-wrap: wrap;
-	}
-
-	&__checkbox {
-		display: flex;
-		align-items: center;
-		gap: 6px;
-		font-size: 0.75rem;
-		color: var(--text-secondary);
-		cursor: pointer;
-		user-select: none;
-
-		input[type='checkbox'] {
-			width: 16px;
-			height: 16px;
-			cursor: pointer;
-			accent-color: #2a8db3;
-		}
-	}
-
-	&__test-submit {
-		padding: 8px 18px;
-		border: none;
-		border-radius: 6px;
-		background: #2a8db3;
-		color: #ffffff;
-		font-size: 0.8125rem;
-		font-weight: 600;
-		cursor: pointer;
-		transition: background 0.15s;
-
-		&:hover:not(:disabled) {
-			background: #1f6f8f;
-		}
-
-		&:disabled {
-			background: var(--text-muted);
-			cursor: not-allowed;
-		}
-	}
-
-	&__warn {
-		display: flex;
-		align-items: center;
-		gap: 6px;
-		margin: 8px 0 0;
-		padding: 6px 10px;
-		border-left: 3px solid #dc2626;
-		background: rgba(220, 38, 38, 0.08);
-		color: #dc2626;
-		font-size: 0.75rem;
-		font-weight: 500;
-	}
-
-	&__tabs {
-		display: flex;
-		gap: 4px;
-		margin: 14px 16px 0;
-		padding: 4px;
-		border-radius: 10px;
-		background: var(--bg-subtle);
-		overflow-x: auto;
-	}
-
-	&__tab {
-		flex: 1;
-		min-width: 64px;
-		display: flex;
-		align-items: center;
-		justify-content: center;
-		gap: 6px;
-		padding: 8px 6px;
-		border: none;
-		border-radius: 6px;
-		background: transparent;
-		color: var(--text-secondary);
-		font-size: 0.75rem;
-		font-weight: 500;
-		cursor: pointer;
-		transition: all 0.15s;
-		white-space: nowrap;
-
-		&:hover {
-			background: rgba(0, 0, 0, 0.04);
-			color: var(--text-primary);
-		}
-
-		&--active {
-			background: var(--bg-page);
-			color: var(--text-primary);
-			font-weight: 700;
-			box-shadow: 0 1px 3px rgba(0, 0, 0, 0.08);
-		}
-	}
-
-	&__tab-label {
-		font-size: 0.75rem;
-	}
-
-	&__tab-count {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		min-width: 20px;
-		height: 18px;
-		padding: 0 6px;
-		border-radius: 999px;
-		background: var(--text-muted);
-		color: var(--bg-page);
-		font-size: 0.6875rem;
-		font-weight: 700;
-	}
-
-	&__tab--active &__tab-count {
-		background: #2a8db3;
-		color: #ffffff;
-	}
-
-	&__list-wrap {
-		flex: 1;
-		display: flex;
-		flex-direction: column;
-		margin: 10px 16px 0;
-		border: 1px solid var(--border-default);
-		border-radius: 10px;
-		overflow: hidden;
-		background: var(--bg-page);
-		min-height: 280px;
-	}
-
-	&__list-header,
-	&__row {
-		display: grid;
-		grid-template-columns: 1.4fr 1fr 2.2fr 0.7fr 1fr 0.5fr;
-		gap: 8px;
-		padding: 8px 12px;
-		align-items: center;
-	}
-
-	&__list-header {
-		background: var(--bg-subtle);
-		border-bottom: 1px solid var(--border-default);
-		font-size: 0.75rem;
-		font-weight: 700;
-		color: var(--text-secondary);
-		text-transform: uppercase;
-		letter-spacing: 0.04em;
-	}
-
-	&__list {
-		max-height: 360px;
-		overflow-y: auto;
-	}
-
-	&__col {
-		font-size: 0.8125rem;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-
-		&--name {
-			display: flex;
-			align-items: center;
-			gap: 6px;
-			font-weight: 600;
-		}
-
-		&--amount {
-			font-weight: 700;
-			color: var(--color-danger);
-			text-align: right;
-		}
-
-		&--msg {
-			color: var(--text-secondary);
-		}
-
-		&--time {
-			color: var(--text-muted);
-			font-variant-numeric: tabular-nums;
-		}
-
-		&--status {
-			text-align: center;
-		}
-
-		&--menu {
-			display: flex;
-			align-items: center;
-			justify-content: center;
-		}
-	}
-
-	&__test-badge {
-		display: inline-flex;
-		align-items: center;
-		padding: 1px 6px;
-		border-radius: 4px;
-		background: #eab308;
-		color: #ffffff;
-		font-size: 0.625rem;
-		font-weight: 800;
-		letter-spacing: 0.04em;
-	}
-
-	&__row {
-		border-bottom: 1px solid var(--border-default);
-		border-left: 4px solid transparent;
-		transition: background 0.1s;
-
-		&:last-child {
-			border-bottom: none;
-		}
-
-		&:hover {
-			background: var(--bg-subtle);
-		}
-
-		&--queued {
-			border-left-color: #94a3b8;
-		}
-
-		&--playing {
-			border-left-color: #3b82f6;
-			background: rgba(59, 130, 246, 0.06);
-		}
-
-		&--delivered {
-			border-left-color: #16a34a;
-		}
-
-		&--skipped {
-			border-left-color: #eab308;
-		}
-
-		&--ignored {
-			border-left-color: #64748b;
-			opacity: 0.65;
-		}
-
-		&--failed {
-			border-left-color: #dc2626;
-			background: rgba(220, 38, 38, 0.04);
-		}
-	}
-
-	&__status-badge {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		padding: 2px 8px;
-		border-radius: 999px;
-		font-size: 0.6875rem;
-		font-weight: 700;
-
-		&--queued {
-			background: rgba(148, 163, 184, 0.18);
-			color: #475569;
-		}
-
-		&--playing {
-			background: rgba(124, 58, 237, 0.18);
-			color: #7c3aed;
-			padding: 0;
-		}
-
-		&--delivered {
-			background: rgba(22, 163, 74, 0.18);
-			color: #16a34a;
-		}
-
-		&--skipped {
-			background: rgba(234, 179, 8, 0.18);
-			color: #ca8a04;
-		}
-
-		&--ignored {
-			background: rgba(100, 116, 139, 0.18);
-			color: #64748b;
-		}
-
-		&--failed {
-			background: rgba(220, 38, 38, 0.18);
-			color: #dc2626;
-		}
-	}
-
-	&__empty {
-		padding: 40px 20px;
-		text-align: center;
-		color: var(--text-muted);
-		font-size: 0.875rem;
-	}
-
-	&__menu-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 26px;
-		height: 26px;
-		border: 1px solid var(--border-default);
-		border-radius: 6px;
-		background: transparent;
-		color: var(--text-primary);
-		font-size: 1rem;
-		line-height: 1;
-		cursor: pointer;
-		transition: background 0.15s;
-
-		&:hover {
-			background: var(--bg-subtle);
-		}
-	}
-
-	// reorder ↑/↓ 버튼 (일시정지 시에만 노출)
-	&__col--reorder {
-		display: inline-flex;
-		flex-direction: column;
-		align-items: center;
-		justify-content: center;
-		gap: 2px;
-		width: 32px;
-		flex-shrink: 0;
-	}
-
-	&__reorder-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 22px;
-		height: 16px;
-		padding: 0;
-		border: 1px solid var(--border-default);
-		border-radius: 4px;
-		background: transparent;
-		color: var(--text-primary);
-		cursor: pointer;
-		transition: background 0.15s, opacity 0.15s;
-
-		&:hover:not(:disabled) {
-			background: var(--bg-subtle);
-		}
-
-		&:disabled {
-			opacity: 0.3;
-			cursor: not-allowed;
-		}
-	}
-
-	&__menu-dropdown {
-		z-index: 9999;
-		min-width: 130px;
-		padding: 4px;
-		border: 1px solid var(--border-default);
-		border-radius: 8px;
-		background: var(--bg-page);
-		box-shadow: 0 6px 16px rgba(0, 0, 0, 0.18);
-	}
-
-	&__menu-item {
-		display: block;
-		width: 100%;
-		padding: 7px 10px;
-		border: none;
-		border-radius: 4px;
-		background: transparent;
-		color: var(--text-primary);
-		font-size: 0.8125rem;
-		font-weight: 500;
-		text-align: left;
-		cursor: pointer;
-		transition: background 0.1s;
-
-		&:hover {
-			background: var(--bg-subtle);
-		}
-
-		&--danger {
-			color: #dc2626;
-
-			&:hover {
-				background: rgba(220, 38, 38, 0.08);
-			}
-		}
-	}
-
-	&__main-controls {
-		display: grid;
-		grid-template-columns: 1fr 1fr 1fr;
-		gap: 8px;
-		padding: 12px 16px 16px;
-		margin-top: auto;
-		border-top: 1px solid var(--border-default);
-	}
-
-	&__main-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		gap: 8px;
-		padding: 12px 8px;
-		border: 1px solid var(--border-default);
-		border-radius: 10px;
-		background: var(--bg-page);
-		color: var(--text-primary);
-		font-size: 0.875rem;
-		font-weight: 700;
-		cursor: pointer;
-		transition: all 0.15s;
-
-		&:hover:not(:disabled) {
-			background: var(--bg-subtle);
-		}
-
-		&:disabled {
-			cursor: default;
-			opacity: 0.6;
-		}
-
-		&--pause {
-			&.dpot-remote__main-btn--active {
-				background: #dc2626;
-				border-color: #b91c1c;
-				color: #ffffff;
-				opacity: 1;
-			}
-		}
-
-		&--skip {
-			&:hover:not(:disabled) {
-				background: var(--bg-subtle);
-				border-color: #2a8db3;
-				color: #2a8db3;
-			}
-		}
-
-		&--play {
-			&.dpot-remote__main-btn--active {
-				background: #16a34a;
-				border-color: #15803d;
-				color: #ffffff;
-				opacity: 1;
-
-				// 재생 중 인디케이터 — Play 아이콘이 우측 슬라이드+페이드 반복
-				svg {
-					animation: dpot-remote-play-pulse 1.2s ease-in-out infinite;
-				}
-			}
-		}
-	}
-}
-
-@keyframes dpot-remote-play-pulse {
-	0%, 100% {
-		transform: translateX(0);
-		opacity: 1;
-	}
-	50% {
-		transform: translateX(4px);
-		opacity: 0.55;
-	}
-}
-
-// ── 타이머 ring (재생 중 카운트다운) ─────────────────
-.dpot-remote__timer-ring {
-	position: relative;
-	display: inline-flex;
-	align-items: center;
-	justify-content: center;
-	width: 30px;
-	height: 30px;
-
-	svg {
-		position: absolute;
-		inset: 0;
-	}
-}
-
-.dpot-remote__timer-num {
-	position: relative;
-	font-size: 0.6875rem;
-	font-weight: 700;
-	color: #7c3aed;
-	font-variant-numeric: tabular-nums;
-}
-
-@media (max-width: 767px) {
-	.dpot-remote {
-		max-width: 100%;
-		font-size: 0.8125rem;
-
-		&__list-header,
-		&__row {
-			grid-template-columns: 1.2fr 0.9fr 1.6fr 0.6fr 0.9fr 0.5fr;
-			gap: 4px;
-			padding: 6px 8px;
-		}
-
-		&__col {
-			font-size: 0.75rem;
-		}
-
-		&__test-row {
-			flex-direction: column;
-		}
-
-		&__input--amount {
-			max-width: 100%;
-		}
-
-		&__tabs {
-			margin: 10px 12px 0;
-		}
-
-		&__list-wrap {
-			margin: 8px 12px 0;
-		}
-
-		&__main-controls {
-			padding: 10px 12px 14px;
-		}
-	}
-}

+ 0 - 7
app/remote/layout.tsx

@@ -1,7 +0,0 @@
-export default function RemoteLayout({ children }: { children: React.ReactNode }) {
-	return (
-		<div className="m-0 p-0 bg-[#1A1A2E] min-h-screen">
-			{children}
-		</div>
-	);
-}

+ 7 - 198
app/studio/Sidebar.tsx

@@ -1,28 +1,15 @@
 'use client';
 
-import { useState, useEffect, useCallback } from 'react';
+import { useState, useEffect } from 'react';
 import Link from 'next/link';
 import Image from 'next/image';
 import { usePathname } from 'next/navigation';
 import {
 	ArrowLeft,
-	Bell,
-	Calculator,
-	ChartLine,
-	ChevronDown,
 	ChevronsUpDown,
 	CircleAlert,
-	Coins,
-	Gauge,
-	Heart,
-	Landmark,
-	LogOut,
-	FileText,
-	Target,
-	Trophy,
+	Settings,
 	User,
-	Users,
-	Wallet,
 } from 'lucide-react';
 import {
 	Sidebar as SidebarRoot,
@@ -31,14 +18,10 @@ import {
 	SidebarMenu,
 	SidebarMenuButton,
 	SidebarMenuItem,
-	SidebarMenuSub,
-	SidebarMenuSubButton,
-	SidebarMenuSubItem,
 	SidebarGroup,
 	SidebarGroupContent,
 	useSidebar,
 } from '@/components/ui/sidebar';
-import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
 import {
 	DropdownMenu,
 	DropdownMenuContent,
@@ -56,40 +39,6 @@ export default function Sidebar()
 	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(() => {
@@ -184,9 +133,6 @@ export default function Sidebar()
 										<DropdownMenuItem asChild>
 											<Link href="/studio/settings">채널 정보 보기</Link>
 										</DropdownMenuItem>
-										<DropdownMenuItem asChild>
-											<Link href="/studio/donation-code">후원 코드</Link>
-										</DropdownMenuItem>
 									</>
 								) : (
 									<>
@@ -210,153 +156,16 @@ export default function Sidebar()
 					<SidebarGroupContent>
 						<SidebarMenu>
 
-							{/* 대시보드 */}
+							{/* 채널 설정 */}
 							<SidebarMenuItem>
-								<SidebarMenuButton asChild isActive={isActive('/studio/dashboard')} tooltip="대시보드">
-									<Link href="/studio/dashboard">
-										<Gauge />
-										<span>대시보드</span>
+								<SidebarMenuButton asChild isActive={isActive('/studio/settings')} tooltip="채널 설정">
+									<Link href="/studio/settings">
+										<Settings />
+										<span>채널 설정</span>
 									</Link>
 								</SidebarMenuButton>
 							</SidebarMenuItem>
 
-							{/* 후원 설정 */}
-							<Collapsible open={donationOpen} onOpenChange={handleDonationToggle}>
-								<SidebarMenuItem>
-									<CollapsibleTrigger asChild>
-										<SidebarMenuButton isActive={isDonationPath} tooltip="후원 설정">
-											<Heart />
-											<span>후원 설정</span>
-											<ChevronDown
-												className={`ml-auto size-4 transition-transform duration-200${donationOpen ? ' rotate-180' : ''}`}
-											/>
-										</SidebarMenuButton>
-									</CollapsibleTrigger>
-									<CollapsibleContent>
-										<SidebarMenuSub>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/alert')}>
-													<Link href="/studio/donation/alert">
-														<Bell />
-														<span>알림</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/goal')}>
-													<Link href="/studio/donation/goal">
-														<Target />
-														<span>목표</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/rank')}>
-													<Link href="/studio/donation/rank">
-														<Trophy />
-														<span>순위</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/crew')}>
-													<Link href="/studio/donation/crew">
-														<Users />
-														<span>크루</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/donation/titles')}>
-													<Link href="/studio/donation/titles">
-														<User />
-														<span>칭호</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-										</SidebarMenuSub>
-									</CollapsibleContent>
-								</SidebarMenuItem>
-							</Collapsible>
-
-							{/* 지갑 */}
-							<Collapsible open={walletOpen} onOpenChange={handleWalletToggle}>
-								<SidebarMenuItem>
-									<CollapsibleTrigger asChild>
-										<SidebarMenuButton isActive={isWalletPath} tooltip="지갑">
-											<Wallet />
-											<span>지갑</span>
-											<ChevronDown
-												className={`ml-auto size-4 transition-transform duration-200${walletOpen ? ' rotate-180' : ''}`}
-											/>
-										</SidebarMenuButton>
-									</CollapsibleTrigger>
-									<CollapsibleContent>
-										<SidebarMenuSub>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/balance')}>
-													<Link href="/studio/wallet/balance">
-														<Coins />
-														<span>잔액</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/revenue')}>
-													<Link href="/studio/wallet/revenue">
-														<ChartLine />
-														<span>수익</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/withdraw')}>
-													<Link href="/studio/wallet/withdraw">
-														<LogOut />
-														<span>출금</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-										</SidebarMenuSub>
-									</CollapsibleContent>
-								</SidebarMenuItem>
-							</Collapsible>
-
-							{/* 정산 */}
-							<Collapsible open={settlementOpen} onOpenChange={handleSettlementToggle}>
-								<SidebarMenuItem>
-									<CollapsibleTrigger asChild>
-										<SidebarMenuButton isActive={isSettlementPath} tooltip="정산">
-											<Calculator />
-											<span>정산</span>
-											<ChevronDown
-												className={`ml-auto size-4 transition-transform duration-200${settlementOpen ? ' rotate-180' : ''}`}
-											/>
-										</SidebarMenuButton>
-									</CollapsibleTrigger>
-									<CollapsibleContent>
-										<SidebarMenuSub>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/account')}>
-													<Link href="/studio/settlement/account">
-														<Landmark />
-														<span>계좌 관리</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-											<SidebarMenuSubItem>
-												<SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/tax')}>
-													<Link href="/studio/settlement/tax">
-														<FileText />
-														<span>원천징수 내역</span>
-													</Link>
-												</SidebarMenuSubButton>
-											</SidebarMenuSubItem>
-										</SidebarMenuSub>
-									</CollapsibleContent>
-								</SidebarMenuItem>
-							</Collapsible>
-
 						</SidebarMenu>
 					</SidebarGroupContent>
 				</SidebarGroup>

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

@@ -1,294 +0,0 @@
-'use client';
-
-import { useEffect, useMemo, useState } from 'react';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faCopy, faArrowUpRightFromSquare, faWallet, faCalendarDay, faCartShopping, faClock } from '@fortawesome/free-solid-svg-icons';
-import { fetchApi } from '@/lib/utils/client';
-import type { DashboardResponse, DashboardWidgetConfig } from '@/types/response/studio/dashboard';
-import './style.scss';
-
-type SimpleWidget = {
-	key: string;
-	label: string;
-	description: string;
-	path: string;
-};
-
-export default function DashboardPage()
-{
-	const [data, setData] = useState<DashboardResponse|null>(null);
-	const [loading, setLoading] = useState(true);
-	const [copiedKey, setCopiedKey] = useState<string|null>(null);
-	const [selectedGoalID, setSelectedGoalID] = useState<number|null>(null);
-	const [selectedRankID, setSelectedRankID] = useState<number|null>(null);
-	const [selectedCrewID, setSelectedCrewID] = useState<number|null>(null);
-
-	useEffect(() => {
-		loadDashboard();
-	}, []);
-
-	const loadDashboard = async () => {
-		try {
-			const res = await fetchApi<DashboardResponse>('/api/studio/dashboard', { silent: true });
-			if (res.data) {
-				setData(res.data);
-				if (res.data.goalConfigs.length > 0) {
-					setSelectedGoalID(res.data.goalConfigs[0].id);
-				}
-				if (res.data.rankConfigs.length > 0) {
-					setSelectedRankID(res.data.rankConfigs[0].id);
-				}
-				if (res.data.crewConfigs.length > 0) {
-					setSelectedCrewID(res.data.crewConfigs[0].id);
-				}
-			}
-		} catch {} finally {
-			setLoading(false);
-		}
-	};
-
-	const getWidgetFullUrl = (path: string) => {
-		if (typeof window === 'undefined') {
-			return path;
-		}
-		return `${window.location.origin}${path}`;
-	};
-
-	const handleCopy = async (key: string, path: string) => {
-		try {
-			await navigator.clipboard.writeText(getWidgetFullUrl(path));
-			setCopiedKey(key);
-			setTimeout(() => setCopiedKey(null), 2000);
-		} catch {}
-	};
-
-	const handleOpen = (key: string, path: string) => {
-		const url = getWidgetFullUrl(path);
-		if (key === 'remote' && typeof window !== 'undefined' && window.innerWidth >= 768) {
-			const w = 880, h = 900;
-			const left = window.screenX + (window.outerWidth - w) / 2;
-			const top = window.screenY + (window.outerHeight - h) / 2;
-			window.open(url, 'dpot-remote', `width=${w},height=${h},left=${left},top=${top},scrollbars=yes,resizable=yes`);
-			return;
-		}
-		window.open(url, '_blank');
-	};
-
-	const widgets = data?.widgets;
-	const goalConfigs = data?.goalConfigs ?? [];
-	const rankConfigs = data?.rankConfigs ?? [];
-	const crewConfigs = data?.crewConfigs ?? [];
-
-	const goalPath = useMemo(
-		() => widgets && selectedGoalID ? `${widgets.goal}?configID=${selectedGoalID}` : '',
-		[widgets, selectedGoalID]
-	);
-	const rankPath = useMemo(
-		() => widgets && selectedRankID ? `${widgets.rank}?configID=${selectedRankID}` : '',
-		[widgets, selectedRankID]
-	);
-	const crewPath = useMemo(
-		() => widgets && selectedCrewID ? `${widgets.crew}?configID=${selectedCrewID}` : '',
-		[widgets, selectedCrewID]
-	);
-
-	if (loading) {
-		return (
-			<div className="studio-page">
-				<div className="dashboard">
-					<h1 className="studio-page__title">대시보드</h1>
-					<p className="studio-page__empty">준비 중...</p>
-				</div>
-			</div>
-		);
-	}
-
-	const financial = data?.financial;
-	const recentDonations = data?.recentDonations ?? [];
-
-	const renderConfigSelector = (
-		key: 'goal'|'rank'|'crew',
-		label: string,
-		description: string,
-		configs: DashboardWidgetConfig[],
-		selectedID: number|null,
-		setSelectedID: (id: number|null) => void,
-		path: string,
-		basePath: string
-	) => {
-		const isCopied = copiedKey === key;
-		return (
-			<div key={key} className="dashboard__widget-item">
-				<div className="dashboard__widget-info">
-					<span className="dashboard__widget-label">{label}</span>
-					<span className="dashboard__widget-desc">{description}</span>
-				</div>
-				<div className="dashboard__widget-url">
-					{configs.length === 0 ? (
-						<span className="dashboard__widget-empty-inline">활성 설정이 없습니다.</span>
-					) : (
-						<select
-							className="dashboard__widget-select"
-							value={selectedID ?? ''}
-							onChange={e => setSelectedID(e.target.value ? parseInt(e.target.value, 10) : null)}
-							aria-label={`${label} 설정 선택`}
-						>
-							{configs.map(c => (
-								<option key={c.id} value={c.id}>
-									{c.title || `#${c.id}`} - {getWidgetFullUrl(`${basePath}?configID=${c.id}`)}
-								</option>
-							))}
-						</select>
-					)}
-				</div>
-				<div className="dashboard__widget-actions">
-					<button
-						type="button"
-						className={`dashboard__widget-btn${isCopied ? ' dashboard__widget-btn--copied' : ''}`}
-						onClick={() => handleCopy(key, path)}
-						title="URL 복사"
-						disabled={!path}
-					>
-						<FontAwesomeIcon icon={faCopy} />
-						{isCopied ? '복사됨' : '복사'}
-					</button>
-					<button
-						type="button"
-						className="dashboard__widget-btn dashboard__widget-btn--open"
-						onClick={() => handleOpen(key, path)}
-						title="새 창에서 열기"
-						disabled={!path}
-					>
-						<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
-						열기
-					</button>
-				</div>
-			</div>
-		);
-	};
-
-	const renderSimpleWidget = ({ key, label, description, path }: SimpleWidget) => {
-		const isCopied = copiedKey === key;
-		return (
-			<div key={key} className="dashboard__widget-item">
-				<div className="dashboard__widget-info">
-					<span className="dashboard__widget-label">{label}</span>
-					<span className="dashboard__widget-desc">{description}</span>
-				</div>
-				<div className="dashboard__widget-url">
-					<code className="dashboard__widget-url-text">{getWidgetFullUrl(path)}</code>
-				</div>
-				<div className="dashboard__widget-actions">
-					<button
-						type="button"
-						className={`dashboard__widget-btn${isCopied ? ' dashboard__widget-btn--copied' : ''}`}
-						onClick={() => handleCopy(key, path)}
-						title="URL 복사"
-					>
-						<FontAwesomeIcon icon={faCopy} />
-						{isCopied ? '복사됨' : '복사'}
-					</button>
-					<button
-						type="button"
-						className="dashboard__widget-btn dashboard__widget-btn--open"
-						onClick={() => handleOpen(key, path)}
-						title={key === 'remote' ? '리모콘 팝업 열기' : '새 창에서 열기'}
-					>
-						<FontAwesomeIcon icon={faArrowUpRightFromSquare} />
-						열기
-					</button>
-				</div>
-			</div>
-		);
-	};
-
-	return (
-		<div className="studio-page">
-			<div className="dashboard">
-				<h1 className="studio-page__title mb-4">대시보드</h1>
-
-				{/* 재무 요약 카드 */}
-				<div className="dashboard__cards">
-					<div className="dashboard__card">
-						<span className="dashboard__card-icon">
-							<FontAwesomeIcon icon={faWallet} />
-						</span>
-						<span className="dashboard__card-label">출금 가능 잔액</span>
-						<span className="dashboard__card-value dashboard__card-value--primary">
-							{(financial?.availableBalance ?? 0).toLocaleString()}원
-						</span>
-					</div>
-					<div className="dashboard__card">
-						<span className="dashboard__card-icon">
-							<FontAwesomeIcon icon={faCalendarDay} />
-						</span>
-						<span className="dashboard__card-label">오늘 후원</span>
-						<span className="dashboard__card-value">
-							{(financial?.todayDonations ?? 0).toLocaleString()}원
-						</span>
-					</div>
-					<div className="dashboard__card">
-						<span className="dashboard__card-icon">
-							<FontAwesomeIcon icon={faCartShopping} />
-						</span>
-						<span className="dashboard__card-label">이번 달 상점 판매</span>
-						<span className="dashboard__card-value">
-							{(financial?.monthStoreSales ?? 0).toLocaleString()}원
-						</span>
-					</div>
-					<div className="dashboard__card">
-						<span className="dashboard__card-icon">
-							<FontAwesomeIcon icon={faClock} />
-						</span>
-						<span className="dashboard__card-label">출금 대기</span>
-						<span className="dashboard__card-value dashboard__card-value--danger">
-							{(financial?.pendingWithdrawal ?? 0).toLocaleString()}원
-						</span>
-					</div>
-				</div>
-
-				{/* 위젯 URL */}
-				{widgets && (
-					<section className="dashboard__section">
-						<h2 className="dashboard__section-title">위젯 URL</h2>
-						<p className="dashboard__section-desc">
-							OBS 브라우저 소스에 아래 URL을 등록하세요. 후원 목표/순위는 등록한 활성 설정 중 원하는 항목을 선택해 주세요.
-						</p>
-						<div className="dashboard__widget-list">
-							{renderSimpleWidget({ key: 'alert', label: '후원 알림', description: 'OBS에 후원 알림 표시', path: widgets.alert })}
-							{renderConfigSelector('goal', '후원 목표', '목표 금액 진행률 표시', goalConfigs, selectedGoalID, setSelectedGoalID, goalPath, widgets.goal)}
-							{renderConfigSelector('rank', '후원 순위', '후원자 순위 표시', rankConfigs, selectedRankID, setSelectedRankID, rankPath, widgets.rank)}
-							{renderConfigSelector('crew', '크루 리더보드', '크루 순위 표시', crewConfigs, selectedCrewID, setSelectedCrewID, crewPath, widgets.crew)}
-							{renderSimpleWidget({ key: 'remote', label: '리모콘', description: '후원 알림 제어', path: widgets.remote })}
-						</div>
-					</section>
-				)}
-
-				{/* 최근 후원 */}
-				<section className="dashboard__section">
-					<h2 className="dashboard__section-title">최근 후원</h2>
-					{recentDonations.length === 0 ? (
-						<div className="dashboard__empty">아직 후원 내역이 없습니다.</div>
-					) : (
-						<div className="dashboard__donation-list">
-							{recentDonations.map(d => (
-								<div key={d.id} className="dashboard__donation-item">
-									<div className="dashboard__donation-info">
-										<span className="dashboard__donation-name">{d.sendName}</span>
-										<span className="dashboard__donation-amount">{d.amount.toLocaleString()}원</span>
-									</div>
-									{d.message && (
-										<div className="dashboard__donation-msg">{d.message}</div>
-									)}
-									<div className="dashboard__donation-time">
-										{new Date(d.createdAt).toLocaleString('ko-KR')}
-									</div>
-								</div>
-							))}
-						</div>
-					)}
-				</section>
-			</div>
-		</div>
-	);
-}

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

@@ -1,360 +0,0 @@
-// ── 대시보드 ──────────────────────────────────────
-.dashboard {
-	width: 100%;
-	max-width: 960px;
-
-	// ── 재무 요약 카드 ──
-	&__cards {
-		display: grid;
-		grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
-		gap: 16px;
-		margin-bottom: 32px;
-	}
-
-	&__card {
-		border: 1px solid hsl(var(--border));
-		border-radius: 8px;
-		padding: 20px;
-		background: hsl(var(--background));
-		display: flex;
-		flex-direction: column;
-		gap: 6px;
-	}
-
-	&__card-icon {
-		font-size: 1.25rem;
-		color: hsl(var(--muted-foreground));
-		margin-bottom: 4px;
-	}
-
-	&__card-label {
-		font-size: 0.8125rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__card-value {
-		font-size: 1.5rem;
-		font-weight: 700;
-		color: hsl(var(--foreground));
-
-		&--primary {
-			color: hsl(var(--primary));
-		}
-
-		&--danger {
-			color: var(--color-danger);
-		}
-	}
-
-	// ── 섹션 ──
-	&__section {
-		margin-bottom: 32px;
-	}
-
-	&__section-title {
-		font-size: 1.125rem;
-		font-weight: 600;
-		color: hsl(var(--foreground));
-		margin-bottom: 8px;
-	}
-
-	&__section-desc {
-		font-size: 0.8125rem;
-		color: hsl(var(--muted-foreground));
-		margin-bottom: 16px;
-	}
-
-	// ── 위젯 URL ──
-	&__widget-list {
-		display: flex;
-		flex-direction: column;
-		gap: 12px;
-	}
-
-	&__widget-item {
-		border: 1px solid hsl(var(--border));
-		border-radius: 8px;
-		padding: 16px 20px;
-		background: hsl(var(--background));
-		display: flex;
-		align-items: center;
-		gap: 16px;
-	}
-
-	&__widget-info {
-		display: flex;
-		flex-direction: column;
-		gap: 2px;
-		min-width: 120px;
-		flex-shrink: 0;
-	}
-
-	&__widget-label {
-		font-size: 0.9375rem;
-		font-weight: 600;
-		color: hsl(var(--foreground));
-	}
-
-	&__widget-desc {
-		font-size: 0.75rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__widget-url {
-		flex: 1;
-		width: 0;
-		min-width: 0;
-		overflow: hidden;
-	}
-
-	&__widget-url-text {
-		display: block;
-		font-size: 0.8125rem;
-		color: hsl(var(--muted-foreground));
-		background: hsl(var(--muted));
-		padding: 6px 10px;
-		border-radius: 4px;
-		white-space: nowrap;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		font-family: 'Fira Code', 'Consolas', monospace;
-	}
-
-	&__widget-select {
-		width: 100%;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		font-size: 0.8125rem;
-		padding: 6px 10px;
-		border: 1px solid hsl(var(--border));
-		border-radius: 4px;
-		background: hsl(var(--background));
-		color: hsl(var(--foreground));
-		cursor: pointer;
-	}
-
-	&__widget-empty-inline {
-		display: block;
-		font-size: 0.8125rem;
-		color: hsl(var(--muted-foreground));
-		font-style: italic;
-		padding: 6px 10px;
-	}
-
-	&__widget-actions {
-		display: flex;
-		gap: 6px;
-		flex-shrink: 0;
-	}
-
-	&__widget-btn {
-		display: inline-flex;
-		align-items: center;
-		gap: 4px;
-		padding: 6px 12px;
-		border: 1px solid hsl(var(--border));
-		border-radius: calc(var(--radius) - 2px);
-		font-size: 0.8125rem;
-		font-weight: 500;
-		cursor: pointer;
-		background: hsl(var(--background));
-		color: hsl(var(--foreground));
-		transition: background-color 0.15s;
-		white-space: nowrap;
-
-		&:hover {
-			background: hsl(var(--accent));
-		}
-
-		&--copied {
-			background: hsl(var(--primary));
-			color: hsl(var(--primary-foreground));
-			border-color: hsl(var(--primary));
-		}
-
-		&--open {
-			color: hsl(var(--primary));
-			border-color: hsl(var(--primary));
-
-			&:hover {
-				background: hsl(var(--primary) / 0.1);
-			}
-		}
-	}
-
-	// ── 최근 후원 ──
-	&__donation-list {
-		display: flex;
-		flex-direction: column;
-		gap: 8px;
-	}
-
-	&__donation-item {
-		border: 1px solid hsl(var(--border));
-		border-radius: 8px;
-		padding: 14px 20px;
-		background: hsl(var(--background));
-	}
-
-	&__donation-info {
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		margin-bottom: 4px;
-	}
-
-	&__donation-name {
-		font-size: 0.9375rem;
-		font-weight: 600;
-		color: hsl(var(--foreground));
-	}
-
-	&__donation-amount {
-		font-size: 0.9375rem;
-		font-weight: 700;
-		color: hsl(var(--primary));
-	}
-
-	&__donation-msg {
-		font-size: 0.8125rem;
-		color: hsl(var(--foreground));
-		margin-bottom: 4px;
-		line-height: 1.4;
-	}
-
-	&__donation-time {
-		font-size: 0.75rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__empty {
-		border: 1px solid hsl(var(--border));
-		border-radius: 8px;
-		padding: 40px 24px;
-		text-align: center;
-		color: hsl(var(--muted-foreground));
-		font-size: 0.875rem;
-	}
-
-	// ── 반응형 ──
-	@media (max-width: 768px) {
-		&__cards {
-			grid-template-columns: repeat(2, 1fr);
-			gap: 10px;
-			margin-bottom: 24px;
-		}
-
-		&__card {
-			padding: 14px;
-		}
-
-		&__card-value {
-			font-size: 1.25rem;
-		}
-
-		&__section {
-			margin-bottom: 24px;
-		}
-
-		&__section-title {
-			font-size: 1rem;
-		}
-
-		&__widget-item {
-			flex-direction: column;
-			align-items: flex-start;
-			gap: 10px;
-			padding: 14px 16px;
-		}
-
-		&__widget-info {
-			min-width: 0;
-			width: 100%;
-		}
-
-		&__widget-url {
-			width: 100%;
-		}
-
-		&__widget-url-text {
-			font-size: 0.75rem;
-			padding: 8px 10px;
-		}
-
-		&__widget-actions {
-			width: 100%;
-
-			.dashboard__widget-btn {
-				flex: 1;
-				justify-content: center;
-				padding: 10px 12px;
-			}
-		}
-
-		&__donation-item {
-			padding: 12px 14px;
-		}
-
-		&__donation-name {
-			font-size: 0.875rem;
-		}
-
-		&__donation-amount {
-			font-size: 0.875rem;
-		}
-
-		&__donation-msg {
-			font-size: 0.75rem;
-		}
-
-		&__empty {
-			padding: 28px 16px;
-		}
-	}
-
-	@media (max-width: 480px) {
-		&__cards {
-			grid-template-columns: 1fr;
-		}
-
-		&__card-label {
-			font-size: 0.75rem;
-		}
-
-		&__card-value {
-			font-size: 1.125rem;
-		}
-
-		&__widget-list {
-			gap: 10px;
-		}
-
-		&__widget-item {
-			padding: 12px 14px;
-			gap: 8px;
-		}
-
-		&__widget-label {
-			font-size: 0.875rem;
-		}
-
-		&__widget-desc {
-			font-size: 0.6875rem;
-		}
-
-		&__donation-list {
-			gap: 6px;
-		}
-
-		&__donation-info {
-			flex-direction: column;
-			align-items: flex-start;
-			gap: 2px;
-			margin-bottom: 6px;
-		}
-
-		&__donation-time {
-			font-size: 0.6875rem;
-		}
-	}
-}

+ 0 - 150
app/studio/donation-code/page.tsx

@@ -1,150 +0,0 @@
-'use client';
-
-import { useEffect, useState } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Button } from '@/components/ui/button';
-import type { StudioSettingsResponse } from '@/types/response/studio/settings';
-
-const CODE_PATTERN = /^[A-Za-z0-9]{4,7}$/;
-
-type IssueResponse = {
-	donationCode: string;
-};
-
-export default function StudioDonationCodePage()
-{
-	const [loading, setLoading] = useState(true);
-	const [existingCode, setExistingCode] = useState<string|null>(null);
-	const [input, setInput] = useState('');
-	const [submitting, setSubmitting] = useState(false);
-	const [error, setError] = useState<string|null>(null);
-
-	useEffect(() => {
-		fetchApi<StudioSettingsResponse>('/api/studio/settings')
-			.then(res => {
-				if (res.data) {
-					setExistingCode(res.data.donationCode);
-				}
-			})
-			.catch(() => {})
-			.finally(() => {
-				setLoading(false);
-			});
-	}, []);
-
-	const handleSubmit = async (e: React.FormEvent) => {
-		e.preventDefault();
-
-		const trimmed = input.trim();
-
-		if (!CODE_PATTERN.test(trimmed)) {
-			setError('후원 코드는 4~7자 영문/숫자만 가능합니다.');
-			return;
-		}
-
-		const normalized = trimmed.toUpperCase();
-
-		const ok = window.confirm(`후원 코드 "${normalized}" 로 등록합니다.\n한 번 등록하면 변경할 수 없습니다. 진행하시겠습니까?`);
-
-		if (!ok) {
-			return;
-		}
-
-		setSubmitting(true);
-		setError(null);
-
-		const res = await fetchApi<IssueResponse>('/api/studio/donation-code', {
-			method: 'POST',
-			body: { code: normalized },
-			silent: true
-		});
-
-		if (res.success && res.data) {
-			setExistingCode(res.data.donationCode);
-			setInput('');
-			window.alert(`후원 코드 "${res.data.donationCode}" 가 정상적으로 등록되었습니다.`);
-		}
-		else {
-			setError(res.message || '후원 코드 발급에 실패했습니다.');
-		}
-
-		setSubmitting(false);
-	};
-
-	if (loading) {
-		return (
-			<div className="studio-page">
-				<p className="text-sm text-muted-foreground">불러오는 중...</p>
-			</div>
-		);
-	}
-
-	return (
-		<div className="studio-page">
-			<div className="mb-6">
-				<h1 className="text-2xl font-bold mb-3">후원 코드</h1>
-				<p className="mt-1 text-sm text-muted-foreground leading-relaxed">
-					후원 코드는 시청자가 후원 채널을 빠르게 찾도록 도와줍니다. 4~7자 영문/숫자만 가능하며, 대소문자 구분 없이 대문자로 저장됩니다.
-					<br />
-					한 번 등록하면 <span className="font-semibold text-foreground">변경할 수 없습니다.</span>
-				</p>
-			</div>
-
-			{existingCode !== null ? (
-				<div className="max-w-md rounded-md border border-input bg-background p-5">
-					<Label htmlFor="code-readonly" className="text-sm">
-						등록된 후원 코드
-					</Label>
-					<Input
-						id="code-readonly"
-						value={existingCode}
-						readOnly
-						className="mt-2 font-mono text-base tracking-widest"
-					/>
-					<p className="mt-2 text-xs text-muted-foreground">
-						* 이미 등록되어 변경할 수 없습니다.
-					</p>
-				</div>
-			) : (
-				<form onSubmit={handleSubmit} className="max-w-md space-y-4 rounded-md border border-input bg-background p-5">
-					<div>
-						<Label htmlFor="code" className="text-sm">
-							후원 코드
-						</Label>
-						<Input
-							id="code"
-							value={input}
-							onChange={(e) => {
-								setInput(e.target.value);
-								setError(null);
-							}}
-							placeholder="예: ABC123"
-							maxLength={7}
-							autoComplete="off"
-							spellCheck={false}
-							className="mt-2 font-mono text-base tracking-widest uppercase"
-							disabled={submitting}
-						/>
-						<p className="mt-1 text-xs text-muted-foreground">
-							* 4~7자 영문/숫자만 사용 가능. 특수문자·한글 불가.
-						</p>
-					</div>
-
-					{error && (
-						<p className="text-xs text-destructive">{error}</p>
-					)}
-
-					<Button
-						type="submit"
-						disabled={submitting || !CODE_PATTERN.test(input.trim())}
-						className="w-full"
-					>
-						{submitting ? '등록 중...' : '등록하기'}
-					</Button>
-				</form>
-			)}
-		</div>
-	);
-}

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

@@ -1,389 +0,0 @@
-'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 pb-5">
-				<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>
-	);
-}

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

@@ -1,390 +0,0 @@
-'use client';
-
-import { useEffect, useRef, useState } from 'react';
-import Image from 'next/image';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faPlus, faImage, faPlay, faStop } from '@fortawesome/free-solid-svg-icons';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Dialog, DialogContent, DialogTitle } from '@/components/ui/dialog';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import { useAlertConfigContext } from '../context';
-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 { channelID } = useStudioContext();
-	const { setItems } = useAlertConfigContext();
-	const [playingAudio, setPlayingAudio] = useState<number|null>(null);
-	const [previewImageUrl, setPreviewImageUrl] = useState<string|null>(null);
-	const [thumbErrors, setThumbErrors] = useState<Set<number>>(new Set());
-	const [togglingIDs, setTogglingIDs] = useState<Set<number>>(new Set());
-	const audioRef = useRef<HTMLAudioElement|null>(null);
-
-	// ── 활성/비활성 토글 ─────────────────────────────
-	const handleToggleActive = async (item: AlertConfigItem) => {
-		if (!channelID || togglingIDs.has(item.id)) {
-			return;
-		}
-
-		const next = !item.isActive;
-
-		// 1) in-flight guard (race 방지)
-		setTogglingIDs(prev => {
-			const s = new Set(prev);
-			s.add(item.id);
-			return s;
-		});
-
-		// 2) optimistic update
-		setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: next } : x));
-
-		try {
-			const res = await fetchApi<{ id: number; isActive: boolean }>(
-				`/api/studio/donation/alert/config/${item.id}/active`,
-				{
-					method: 'PATCH',
-					body: { channelID, isActive: next },
-					silent: true
-				}
-			);
-
-			if (!res.success) {
-				// 롤백
-				setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
-				alert(res.message || '활성 상태 변경에 실패했습니다.');
-			} else if (res.data && res.data.isActive !== next) {
-				// 서버가 다른 값 반환 시 ground truth 반영
-				setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: res.data!.isActive } : x));
-			}
-		} catch (err) {
-			// 네트워크/예외 → 롤백
-			setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
-			alert(err instanceof Error ? err.message : '활성 상태 변경에 실패했습니다.');
-		} finally {
-			setTogglingIDs(prev => {
-				const s = new Set(prev);
-				s.delete(item.id);
-				return s;
-			});
-		}
-	};
-
-	// ── 페이징 ───────────────────────────────────────
-	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={allChecked} indeterminate={isIndeterminate}
-										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>
-											<button
-												type="button"
-												role="switch"
-												aria-checked={item.isActive}
-												aria-busy={togglingIDs.has(item.id)}
-												aria-label={`${item.title || `#${item.id}`} 활성 상태 ${item.isActive ? '끄기' : '켜기'}`}
-												className={`alert-config__active-toggle${item.isActive ? ' alert-config__active-toggle--on' : ''}${togglingIDs.has(item.id) ? ' alert-config__active-toggle--busy' : ''}`}
-												onClick={() => handleToggleActive(item)}
-												disabled={togglingIDs.has(item.id) || saving}
-												title={item.isActive ? '비활성화' : '활성화'}
-											>
-												<span className="alert-config__active-toggle-knob" aria-hidden="true" />
-											</button>
-										</td>
-										<td className="alert-config__td--media">
-											<div>
-												{hasImage && (
-													thumbErrors.has(item.id) ? (
-														<button
-															type="button"
-															className="alert-config__media-btn alert-config__media-btn--image"
-															title="이미지 보기"
-															onClick={() => setPreviewImageUrl(item.imageUrl!)}
-														>
-															<FontAwesomeIcon icon={faImage} />
-														</button>
-													) : (
-														<button
-															type="button"
-															className="alert-config__media-thumb"
-															title="이미지 크게 보기"
-															onClick={() => setPreviewImageUrl(item.imageUrl!)}
-														>
-															<Image
-																src={item.imageUrl!}
-																alt="알림 이미지"
-																width={96}
-																height={96}
-																sizes="96px"
-																quality={90}
-																className="alert-config__media-thumb-img"
-																loading="lazy"
-																onError={() => setThumbErrors(prev => new Set(prev).add(item.id))}
-															/>
-														</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={playingAudio === item.id ? faStop : faPlay} />
-													</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>
-			)}
-		{/* 이미지 확대 모달 */}
-		<Dialog open={!!previewImageUrl} onOpenChange={open => { if (!open) { setPreviewImageUrl(null); } }}>
-			<DialogContent className="alert-config__media-dialog">
-				<DialogTitle className="sr-only">이미지 미리보기</DialogTitle>
-				{previewImageUrl && (
-					<img
-						src={previewImageUrl}
-						alt="알림 이미지"
-						className="alert-config__media-dialog-img"
-					/>
-				)}
-			</DialogContent>
-		</Dialog>
-		</div>
-	);
-}

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

@@ -1,129 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { faPlay } from '@fortawesome/free-solid-svg-icons';
-import View from '@/app/widget/alert/[widgetToken]/view';
-import { DonationAlertConfig, DonationAlertData } from '@/types/donation';
-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';
-import type { FormState } from '../types';
-
-type Props = {
-	form: FormState;
-};
-
-export default function AlertPreviewPanel({ form }: Props) {
-	const [previewName, setPreviewName] = useState(PREVIEW_DEFAULT_NAME);
-	const [previewAmount, setPreviewAmount] = useState(DONATION_AMOUNT_DEFAULT);
-	const [previewMessage, setPreviewMessage] = useState(PREVIEW_DEFAULT_MESSAGE);
-	const [previewKey, setPreviewKey] = useState(0);
-	const [showPreview, setShowPreview] = useState(false);
-
-	// form → DonationAlertConfig 매핑 (id만 추가)
-	const config: DonationAlertConfig = { id: 0, ...form };
-
-	const fakeAlert: DonationAlertData = {
-		alertID: previewKey,
-		donationID: 0,
-		correlationID: '',
-		sponsorMemberID: 0,
-		sendName: previewName || PREVIEW_DEFAULT_NAME,
-		amount: previewAmount || DONATION_AMOUNT_DEFAULT,
-		netAmount: previewAmount || DONATION_AMOUNT_DEFAULT,
-		message: previewMessage || null,
-		channelID: 0,
-		channelName: '',
-		crewMemberID: null,
-		crewMemberNickname: null,
-		isTest: true,
-		createdAt: new Date().toISOString()
-	};
-
-	const handlePreview = () => {
-		setShowPreview(false);
-		// 다음 tick에 다시 마운트해서 애니메이션 재실행 (key + show 토글)
-		requestAnimationFrame(() => {
-			setPreviewKey(prev => prev + 1);
-			setShowPreview(true);
-		});
-	};
-
-	const handleComplete = () => {
-		setShowPreview(false);
-	};
-
-	return (
-		<aside className="alert-config__preview-panel">
-			<div className="alert-config__widget">
-				<div className="alert-config__widget-label">미리보기</div>
-				<div className="alert-config__widget-frame">
-					{showPreview && (
-						<View
-							key={previewKey}
-							alert={fakeAlert}
-							config={config}
-							isAudioOnly={false}
-							isVideoOnly={false}
-							onComplete={handleComplete}
-						/>
-					)}
-				</div>
-			</div>
-
-			<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}
-					>
-						<FontAwesomeIcon icon={faPlay} />
-						미리보기
-					</button>
-				</div>
-			</fieldset>
-		</aside>
-	);
-}

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

@@ -1,186 +0,0 @@
-'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 Loading from '@/app/component/Loading';
-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 { setSaving, fetchList } = useAlertConfigContext();
-
-	const [form, setForm] = useState<FormState>(createEmptyForm());
-	const [pendingFiles, setPendingFiles] = useState<PendingFiles>({ image: null, sound: null });
-	const [localSaving, setLocalSaving] = useState(false);
-	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);
-		};
-	}, []);
-
-	// ── 폼 필드 변경 ────────────────────────────────
-	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 (
-		<>
-			{localSaving && <Loading type={1} fullscreen />}
-			<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 form={form} />
-
-				<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>
-		</>
-	);
-}

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

@@ -1,165 +0,0 @@
-// 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
-};

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

@@ -1,25 +0,0 @@
-'use client';
-
-import { createContext, useContext } from 'react';
-import type { Dispatch, SetStateAction } from 'react';
-import type { AlertConfigItem } from '@/types/response/donation/alertConfig';
-
-type AlertConfigContextValue = {
-	items: AlertConfigItem[];
-	setItems: Dispatch<SetStateAction<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;
-}

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

@@ -1,211 +0,0 @@
-'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 Loading from '@/app/component/Loading';
-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, 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 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);
-		};
-	}, []);
-
-	// ── 폼 필드 변경 ────────────────────────────────
-	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 (
-		<>
-		{localSaving && <Loading type={1} fullscreen />}
-		<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 form={form} />
-
-			<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>
-		</>
-	);
-}

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

@@ -1,55 +0,0 @@
-'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, setItems, widgetToken, loading, saving, setSaving, fetchList }}>
-			<div className="alert-config">
-				{children}
-			</div>
-		</AlertConfigContext.Provider>
-	);
-}

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

@@ -1,74 +0,0 @@
-'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}
-			/>
-		</>
-	);
-}

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

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

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

@@ -1,791 +0,0 @@
-.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;
-		background: #1a1a2e;
-	}
-
-	&__widget-label {
-		padding: 8px 14px;
-		font-size: 0.8125rem;
-		font-weight: 500;
-		color: hsl(var(--muted));
-		background:#0e0e1a;
-	}
-
-	&__widget-frame {
-		display: block;
-		width: 100%;
-		height: clamp(200px, 30vw, 450px);
-		border: none;
-		background: #1a1a2e;
-		position: relative;
-		overflow: hidden;
-		container-type: inline-size;
-	}
-
-	&__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;
-			width: 100%;
-			flex-grow: 1;
-		}
-	}
-
-	&__media-thumb {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		flex: 1 1 auto;
-		max-width: 96px;
-		border: 1px solid hsl(var(--border));
-		border-radius: calc(var(--radius) - 2px);
-		background: hsl(var(--background));
-		cursor: pointer;
-		overflow: hidden;
-		padding: 0;
-		transition: border-color 0.15s;
-
-		&:hover {
-			border-color: hsl(var(--primary));
-		}
-	}
-
-	&__media-thumb-img {
-		width: 100%;
-		height: 100%;
-		object-fit: contain;
-	}
-
-	&__media-btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		flex-shrink: 0;
-		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);
-			animation: alert-pulse 1.5s ease-in-out infinite;
-		}
-	}
-
-	&__media-dialog {
-		max-width: 90vw;
-		width: auto;
-		padding: 16px;
-	}
-
-	&__media-dialog-img {
-		display: block;
-		max-width: 100%;
-		max-height: 80vh;
-		margin: 0 auto;
-		border-radius: calc(var(--radius) - 2px);
-		object-fit: contain;
-	}
-
-	&__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%);
-		}
-	}
-
-	// 활성 토글 스위치
-	&__active-toggle {
-		position: relative;
-		display: inline-flex;
-		align-items: center;
-		width: 42px;
-		height: 22px;
-		padding: 2px;
-		border: 1px solid hsl(var(--border));
-		border-radius: 999px;
-		background: hsl(var(--muted));
-		cursor: pointer;
-		transition: background 0.18s, border-color 0.18s;
-
-		&-knob {
-			width: 16px;
-			height: 16px;
-			border-radius: 50%;
-			background: #fff;
-			box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
-			transition: transform 0.18s;
-		}
-
-		&--on {
-			background: hsl(142 71% 45%);
-			border-color: hsl(142 71% 45%);
-
-			.alert-config__active-toggle-knob {
-				transform: translateX(20px);
-			}
-		}
-
-		&--busy {
-			opacity: 0.7;
-			cursor: progress;
-		}
-
-		&:disabled {
-			cursor: not-allowed;
-
-			&:not(.alert-config__active-toggle--busy) {
-				opacity: 0.6;
-			}
-		}
-
-		&:not(:disabled):hover {
-			border-color: hsl(142 71% 45%);
-		}
-	}
-
-	&__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: 140px;
-			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;
-		}
-
-		&__media-thumb {
-			width: 32px;
-			height: 32px;
-		}
-	}
-}
-
-@keyframes alert-pulse {
-	0%, 100% {
-		opacity: 1;
-	}
-	50% {
-		opacity: 0.5;
-	}
-}

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

@@ -1,11 +0,0 @@
-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: '{이름}님이 {금액}원을 후원했습니다!' });

+ 0 - 460
app/studio/donation/crew/[id]/_components/CrewMembersTab.tsx

@@ -1,460 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback, useRef } from 'react';
-import * as signalR from '@microsoft/signalr';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import { useSignalRContext } from '@/contexts/signalrProvider';
-import type { CrewMemberItem, CrewMemberListResponse, SearchResultItem, SearchMemberResponse } from '@/types/response/crew/member';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
-import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
-import { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } from '@/components/ui/tooltip';
-import { Info } from 'lucide-react';
-
-function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
-	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
-}
-
-const EMPTY_MEMBER_FORM = {
-	nickname: '',
-	role: '',
-	sortOrder: 0,
-	isActive: true
-};
-
-type Props = {
-	crewID: number;
-};
-
-export default function CrewMembersTab({ crewID }: Props)
-{
-	const { channelID } = useStudioContext();
-	const { appConnection, appConnected } = useSignalRContext();
-	const [members, setMembers] = useState<CrewMemberItem[]>([]);
-	const [loading, setLoading] = useState(true);
-
-	// 초대 코드
-	const [inviteCode, setInviteCode] = useState<string|null>(null);
-	const [generatingCode, setGeneratingCode] = useState(false);
-
-	// 회원 검색
-	const [searchQuery, setSearchQuery] = useState('');
-	const [searchResults, setSearchResults] = useState<SearchResultItem[]>([]);
-	const [showSearch, setShowSearch] = useState(false);
-	const searchTimer = useRef<ReturnType<typeof setTimeout>|null>(null);
-
-	// 크루원 편집 모달
-	const [editModal, setEditModal] = useState<{ open: boolean; member: CrewMemberItem|null }>({ open: false, member: null });
-	const [editForm, setEditForm] = useState(EMPTY_MEMBER_FORM);
-	const [saving, setSaving] = useState(false);
-
-	const fetchMembers = useCallback(async () => {
-		try {
-
-			const res = await fetchApi<CrewMemberListResponse>(`/api/studio/crew/member/list/${crewID}`);
-			setMembers(res.data?.list ?? []);
-
-		} catch {
-
-		} finally {
-			setLoading(false);
-		}
-	}, [crewID]);
-
-	useEffect(() => { fetchMembers(); }, [fetchMembers]);
-
-	// SignalR 멤버 변경 이벤트 수신 — 운영자 그룹(crew:{crewID}) 구독 + 200ms debounce refetch.
-	// 다중 이벤트(예: 일괄 작업) 시 한 번만 refetch 하도록 합침.
-	useEffect(() => {
-		if (!appConnection || !appConnected) {
-			return;
-		}
-
-		if (appConnection.state !== signalR.HubConnectionState.Connected) {
-			return;
-		}
-
-		let refetchTimer: ReturnType<typeof setTimeout>|null = null;
-		const scheduleRefetch = () => {
-			if (refetchTimer) {
-				clearTimeout(refetchTimer);
-			}
-			refetchTimer = setTimeout(() => {
-				fetchMembers();
-			}, 200);
-		};
-
-		const handler = (_payload: unknown) => {
-			scheduleRefetch();
-		};
-
-		appConnection.on('ReceiveCrewMemberChange', handler);
-		appConnection.invoke('JoinCrew', crewID).catch(err => {
-			console.warn('[crew] JoinCrew failed:', err);
-		});
-
-		return () => {
-			if (refetchTimer) {
-				clearTimeout(refetchTimer);
-			}
-			appConnection.off('ReceiveCrewMemberChange', handler);
-
-			if (appConnection.state === signalR.HubConnectionState.Connected) {
-				appConnection.invoke('LeaveCrew', crewID).catch(() => {});
-			}
-		};
-	}, [appConnection, appConnected, crewID, fetchMembers]);
-
-	// SignalR presence 구독 — 멤버 목록 변경 시 재구독, 이벤트 수신 시 isOnline 갱신.
-	// members 가 빈 배열일 때는 구독할 대상이 없어 early return — 첫 fetchMembers 완료 후 의존성 변화로 재실행되어 정상 구독.
-	const memberIDsKey = members.map(m => m.memberID).join(',');
-
-	useEffect(() => {
-		if (!appConnection || !appConnected) {
-			return;
-		}
-
-		if (appConnection.state !== signalR.HubConnectionState.Connected) {
-			return;
-		}
-
-		const memberIDs = Array.from(new Set(members.map(m => m.memberID))).filter(id => id > 0);
-		if (memberIDs.length === 0) {
-			return;
-		}
-
-		const handler = (memberID: number, isOnline: boolean) => {
-			setMembers(prev => prev.map(m => m.memberID === memberID ? { ...m, isOnline } : m));
-		};
-
-		appConnection.on('ReceivePresenceChange', handler);
-		appConnection.invoke('SubscribePresence', memberIDs).catch(err => {
-			console.warn('[presence] SubscribePresence failed:', err);
-		});
-
-		return () => {
-			appConnection.off('ReceivePresenceChange', handler);
-
-			if (appConnection.state === signalR.HubConnectionState.Connected) {
-				appConnection.invoke('UnsubscribePresence', memberIDs).catch(() => {});
-			}
-		};
-	// memberIDsKey 가 동일하면 effect 재실행 안됨 (불필요한 sub/unsub 방지).
-	// members 자체를 의존성에 넣지 않는 이유: members 가 같은 ID 집합이어도 객체 reference 가 바뀌면 매번 재실행되므로.
-	// eslint-disable-next-line react-hooks/exhaustive-deps
-	}, [appConnection, appConnected, memberIDsKey]);
-
-	// 회원 검색 (디바운스)
-	useEffect(() => {
-		if (searchTimer.current) {
-			clearTimeout(searchTimer.current);
-		}
-
-		if (searchQuery.trim().length < 2) {
-			setSearchResults([]);
-			return;
-		}
-
-		searchTimer.current = setTimeout(async () => {
-			try {
-
-				const res = await fetchApi<SearchMemberResponse>(
-					`/api/studio/crew/member/search?channelID=${channelID}&q=${encodeURIComponent(searchQuery)}&crewID=${crewID}`
-				);
-
-				setSearchResults(res.data?.list ?? []);
-			} catch {}
-		}, 300);
-
-		return () => {
-			if (searchTimer.current) {
-				clearTimeout(searchTimer.current);
-			}
-		};
-	}, [searchQuery, channelID, crewID]);
-
-	const handleAddMember = async (item: SearchResultItem) => {
-		try {
-
-			await fetchApi('/api/studio/crew/member/add', {
-				method: 'POST',
-				body: {
-					crewID,
-					targetMemberID: item.memberID,
-					nickname: item.channelName ?? item.name ?? item.email,
-					sortOrder: members.length + 1
-				}
-			});
-
-			setSearchQuery('');
-			setSearchResults([]);
-			setShowSearch(false);
-			fetchMembers();
-
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '추가에 실패했습니다.');
-		}
-	};
-
-	const handleRemove = async (memberID: number) => {
-		if (!confirm('이 크루원을 삭제하시겠습니까?')) {
-			return;
-		}
-
-		try {
-
-			await fetchApi(`/api/studio/crew/member/${memberID}`, { method: 'DELETE' });
-			fetchMembers();
-
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '삭제에 실패했습니다.');
-		}
-	};
-
-	const openEdit = (member: CrewMemberItem) => {
-		setEditForm({ nickname: member.nickname, role: member.role ?? '', sortOrder: member.sortOrder, isActive: member.isActive });
-		setEditModal({ open: true, member });
-	};
-
-	const handleUpdate = async () => {
-		if (!editModal.member) {
-			return;
-		}
-
-		setSaving(true);
-
-		try {
-
-			await fetchApi(`/api/studio/crew/member/${editModal.member.id}`, {
-				method: 'PUT',
-				body: {
-					crewMemberID: editModal.member.id,
-					nickname: editForm.nickname,
-					role: editForm.role || null,
-					sortOrder: editForm.sortOrder,
-					isActive: editForm.isActive
-				}
-			});
-
-			setEditModal({ open: false, member: null });
-			fetchMembers();
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '수정에 실패했습니다.');
-		} finally {
-			setSaving(false);
-		}
-	};
-
-	const handleGenerateCode = async () => {
-		setGeneratingCode(true);
-
-		try {
-
-			const res = await fetchApi<{ inviteCode: string }>('/api/studio/crew/invite/generate', {
-				method: 'POST',
-				body: { crewID }
-			});
-
-			setInviteCode(res.data?.inviteCode ?? null);
-
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '코드 생성에 실패했습니다.');
-		} finally {
-			setGeneratingCode(false);
-		}
-	};
-
-	const copyCode = () => {
-		if (inviteCode) {
-			navigator.clipboard.writeText(inviteCode);
-			alert('복사되었습니다.');
-		}
-	};
-
-	if (loading) return <p className="studio-page__empty">준비 중...</p>;
-
-	return (
-		<>
-			{/* 초대 코드 */}
-			<fieldset className="crew-invite">
-				<legend className="crew-invite__legend">
-					<span>초대 코드</span>
-					<TooltipProvider delay={0}>
-						<Tooltip>
-							<TooltipTrigger
-								render={
-									<button type="button" className="crew-invite__help" aria-label="초대 코드 안내">
-										<Info size={14} aria-hidden="true" />
-									</button>
-								}
-							/>
-							<TooltipContent side="top" align="start">
-								이 코드를 입력한 회원은 크루 방송 합류에 동의하는 것으로 처리됩니다.<br />
-								입력 페이지: /crew/join
-							</TooltipContent>
-						</Tooltip>
-					</TooltipProvider>
-				</legend>
-				<div className="crew-invite__body">
-					<Input
-						readOnly
-						value={inviteCode ?? ''}
-						placeholder="코드를 생성해 주세요"
-						className="crew-invite__input"
-					/>
-					<div className="crew-invite__actions">
-						{inviteCode ? (
-							<>
-								<Button type="button" variant="outline" onClick={copyCode}>복사</Button>
-								<Button type="button" variant="outline" onClick={handleGenerateCode} disabled={generatingCode}>
-									{generatingCode ? '생성 중...' : '재생성'}
-								</Button>
-							</>
-						) : (
-							<Button type="button" onClick={handleGenerateCode} disabled={generatingCode}>
-								{generatingCode ? '생성 중...' : '코드 생성'}
-							</Button>
-						)}
-					</div>
-				</div>
-			</fieldset>
-
-			{/* 크루원 관리 */}
-			<div className="crew-members">
-				<div className="crew-members__toolbar pb-3">
-					<h2 className="crew-members__subtitle">크루원 ({members.length}명)</h2>
-					<Button size="sm" onClick={() => setShowSearch(!showSearch)}>
-						{showSearch ? '닫기' : '+ 크루원 추가'}
-					</Button>
-				</div>
-
-				{showSearch && (
-					<div className="member-search mb-4">
-						<Input className="member-search__input" placeholder="채널명, 핸들, 이메일로 검색 (2자 이상)" value={searchQuery} onChange={e => setSearchQuery(e.target.value)} autoComplete='off'/>
-						{searchResults.length > 0 && (
-							<div className="member-search__results">
-								{searchResults.map(item => (
-									<button type="button" key={item.memberID} className="member-search__item" onClick={() => handleAddMember(item)}>
-										{item.channelThumb ? <img src={item.channelThumb} alt="" className="member-search__thumb" /> : <div className="member-search__thumb" />}
-										<div className="member-search__info">
-											<div className="member-search__name">{item.channelName ?? item.name ?? '(이름 없음)'}</div>
-											<div className="member-search__email">{item.email}</div>
-											{item.channelHandle && (
-												<div className="member-search__handle">@{item.channelHandle}</div>
-											)}
-										</div>
-									</button>
-								))}
-							</div>
-						)}
-					</div>
-				)}
-
-				<div className="studio-page__table-wrap">
-					<table className="studio-page__table">
-						<thead>
-							<tr>
-								<th>순서</th>
-								<th>별명</th>
-								<th>역할</th>
-								<th>채널</th>
-								<th>접속</th>
-								<th>상태</th>
-								<th>가입일</th>
-								<th>작업</th>
-							</tr>
-						</thead>
-						<tbody>
-							{members.length === 0 ? (
-								<tr><td colSpan={8} className="studio-page__empty">등록된 크루원이 없습니다.</td></tr>
-							) : members.map(m => {
-								const isAccepted = m.inviteStatus === 'Accepted';
-								const statusLabel = m.inviteStatus === 'Accepted'
-									? (m.isActive ? '활성' : '비활성')
-									: m.inviteStatus === 'Pending' ? '초대 중'
-									: m.inviteStatus === 'Declined' ? '거절됨'
-									: '만료';
-								const statusModifier = m.inviteStatus === 'Accepted'
-									? (m.isActive ? 'active' : 'inactive')
-									: m.inviteStatus === 'Pending' ? 'pending'
-									: 'expired';
-								return (
-									<tr key={m.id}>
-										<td>{m.sortOrder}</td>
-										<td>
-											<Avatar className="member-row__thumb">
-												<AvatarImage src={(m.channelThumb ?? m.thumb) || undefined} alt={m.nickname} />
-												<AvatarFallback className="member-row__thumb-fallback p-1">
-													<img src="/icons/layout/avatar.svg" alt={m.nickname} />
-												</AvatarFallback>
-											</Avatar>
-											{m.nickname}
-										</td>
-										<td>{m.role ?? '-'}</td>
-										<td>{m.channelName ?? '-'}</td>
-										<td>
-											<span
-												className={`member-row__online-dot${m.isOnline ? ' member-row__online-dot--on' : ''}`}
-												title={m.isOnline ? '온라인' : '오프라인'}
-												aria-label={m.isOnline ? '온라인' : '오프라인'}
-											/>
-										</td>
-										<td><span className={`studio-page__badge studio-page__badge--${statusModifier}`}>{statusLabel}</span></td>
-										<td>{new Date(m.joinedAt).toLocaleDateString('ko-KR')}</td>
-										<td>
-											<div className="studio-page__actions">
-												{isAccepted ? (
-													<>
-														<Button variant="outline" size="sm" onClick={() => openEdit(m)}>수정</Button>
-														<Button variant="destructive" size="sm" onClick={() => handleRemove(m.id)}>삭제</Button>
-													</>
-												) : (
-													<span className="text-xs text-muted-foreground">{m.inviteStatus === 'Pending' ? '응답 대기' : '-'}</span>
-												)}
-											</div>
-										</td>
-									</tr>
-								);
-							})}
-						</tbody>
-					</table>
-				</div>
-			</div>
-
-			{/* 크루원 편집 모달 */}
-			<Dialog open={editModal.open} onOpenChange={open => { if (!open) setEditModal({ open: false, member: null }); }}>
-				<DialogContent>
-					<DialogHeader>
-						<DialogTitle>크루원 수정</DialogTitle>
-					</DialogHeader>
-					<div className="space-y-4">
-						<div className="space-y-2">
-							<RequiredLabel htmlFor="member-nickname">별명</RequiredLabel>
-							<Input id="member-nickname" value={editForm.nickname} onChange={e => setEditForm(f => ({ ...f, nickname: e.target.value }))} />
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="member-role">역할</Label>
-							<Input id="member-role" value={editForm.role} onChange={e => setEditForm(f => ({ ...f, role: e.target.value }))} placeholder="예: 게이머, MC" />
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="member-order">순서</Label>
-							<Input id="member-order" type="number" min={0} value={editForm.sortOrder} onChange={e => setEditForm(f => ({ ...f, sortOrder: Number(e.target.value) }))} />
-						</div>
-						<div className="flex items-center gap-2">
-							<Checkbox id="member-active" checked={editForm.isActive} onCheckedChange={v => setEditForm(f => ({ ...f, isActive: !!v }))} />
-							<Label htmlFor="member-active">활성화</Label>
-						</div>
-					</div>
-					<DialogFooter className="flex flex-row justify-center gap-2 sm:justify-center">
-						<Button variant="outline" className="flex-1 sm:flex-none" onClick={() => setEditModal({ open: false, member: null })}>취소</Button>
-						<Button className="flex-1 sm:flex-none" onClick={handleUpdate} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
-					</DialogFooter>
-				</DialogContent>
-			</Dialog>
-		</>
-	);
-}

+ 0 - 169
app/studio/donation/crew/[id]/_components/CrewSessionTab.tsx

@@ -1,169 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import type { ActiveSessionResponse, SessionHistoryResponse, SessionHistoryItem } from '@/types/response/crew/session';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-
-type Props = { crewID: number };
-
-export default function CrewSessionTab({ crewID }: Props)
-{
-	const [activeSession, setActiveSession] = useState<ActiveSessionResponse>(null);
-	const [history, setHistory] = useState<SessionHistoryItem[]>([]);
-	const [historyTotal, setHistoryTotal] = useState(0);
-	const [loading, setLoading] = useState(true);
-	const [sessionTitle, setSessionTitle] = useState('');
-	const [starting, setStarting] = useState(false);
-	const [ending, setEnding] = useState(false);
-
-	const fetchActiveSession = useCallback(async () => {
-		try {
-			const res = await fetchApi<ActiveSessionResponse>(`/api/studio/crew/session/active/${crewID}`);
-			setActiveSession(res.data ?? null);
-		} catch {}
-	}, [crewID]);
-
-	const fetchHistory = useCallback(async () => {
-		try {
-			const res = await fetchApi<SessionHistoryResponse>(`/api/studio/crew/session/history/${crewID}?page=1&perPage=10`);
-			setHistory(res.data?.list ?? []);
-			setHistoryTotal(res.data?.total ?? 0);
-		} catch {}
-	}, [crewID]);
-
-	useEffect(() => {
-		setLoading(true);
-		Promise.all([fetchActiveSession(), fetchHistory()]).finally(() => setLoading(false));
-	}, [fetchActiveSession, fetchHistory]);
-
-	const handleStart = async () => {
-		if (!sessionTitle.trim()) { alert('방송 제목을 입력해 주세요.'); return; }
-		setStarting(true);
-		try {
-			await fetchApi('/api/crew/session/start', { method: 'POST', body: { crewID, title: sessionTitle.trim() } });
-			setSessionTitle('');
-			fetchActiveSession();
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '세션 시작에 실패했습니다.');
-		} finally { setStarting(false); }
-	};
-
-	const handleEnd = async () => {
-		if (!activeSession) {
-			return;
-		}
-
-		if (!confirm('크루 방송을 종료하시겠습니까?\n종료 시 크루원에게 정산 결과가 전송됩니다.')) {
-			return;
-		}
-
-		setEnding(true);
-
-		try {
-			await fetchApi('/api/crew/session/end', {
-				method: 'POST',
-				body: { crewSessionID: activeSession.crewSessionID }
-			});
-
-			setActiveSession(null);
-			fetchHistory();
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '세션 종료에 실패했습니다.');
-		} finally {
-			setEnding(false);
-		}
-	};
-
-	if (loading) return <p className="studio-page__empty">준비 중...</p>;
-
-	return (
-		<>
-			{activeSession ? (
-				<div className="session-active">
-					<div className="session-active__card">
-						<div className="session-active__top">
-							<div className="session-active__info">
-								<span className="session-active__session-title">{activeSession.title}</span>
-								<span className={`studio-page__badge studio-page__badge--${activeSession.status === 'Active' ? 'active' : 'warning'}`}>
-									{activeSession.status === 'Inviting' ? '동의 대기 중' : '방송 중'}
-								</span>
-							</div>
-							<Button variant="destructive" size="sm" onClick={handleEnd} disabled={ending || activeSession.status === 'Inviting'}>
-								{ending ? '종료 중...' : '방송 종료'}
-							</Button>
-						</div>
-						<div className="session-active__stats">
-							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.totalAmount.toLocaleString()}원</div><div className="session-active__stat-label">총 후원액</div></div>
-							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.totalDonationCount}건</div><div className="session-active__stat-label">후원 건수</div></div>
-							<div className="session-active__stat"><div className="session-active__stat-value">{activeSession.consents.filter(c => c.isConsented).length}/{activeSession.consents.length}</div><div className="session-active__stat-label">동의 현황</div></div>
-						</div>
-						<div className="session-consents">
-							<div className="session-consents__title">크루원 동의 현황</div>
-							<div className="session-consents__list">
-								{activeSession.consents.map(c => (
-									<div key={c.crewMemberID} className={`session-consents__item session-consents__item--${c.isConsented ? 'consented' : 'pending'}`}>
-										<span className="session-consents__icon">{c.isConsented ? '✓' : '⏳'}</span>{c.nickname}
-									</div>
-								))}
-							</div>
-						</div>
-						{activeSession.status === 'Active' && activeSession.summaries.length > 0 && (
-							<div className="mt-4">
-								<table className="studio-page__table">
-									<thead><tr><th>순위</th><th>크루원</th><th>후원액</th><th>건수</th><th>기여율</th></tr></thead>
-									<tbody>
-										{activeSession.summaries.map(s => (
-											<tr key={s.crewMemberID}><td>{s.rank}위</td><td>{s.nickname}</td><td>{s.totalAmount.toLocaleString()}원</td><td>{s.donationCount}건</td><td>{s.contributionRate.toFixed(1)}%</td></tr>
-										))}
-									</tbody>
-								</table>
-							</div>
-						)}
-					</div>
-				</div>
-			) : (
-				<div className="session-start">
-					<div className="session-start__title">새 크루 방송 시작</div>
-					<div className="session-start__form">
-						<Input className="session-start__input" placeholder="방송 제목을 입력하세요" value={sessionTitle} onChange={e => setSessionTitle(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleStart()} />
-						<Button onClick={handleStart} disabled={starting}>{starting ? '시작 중...' : '방송 시작'}</Button>
-					</div>
-				</div>
-			)}
-
-			<div className="session-history mt-4">
-				<div className="session-history__title">지난 방송 ({historyTotal}건)</div>
-				<div className="studio-page__table-wrap">
-					<table className="studio-page__table">
-						<thead>
-							<tr>
-								<th>제목</th>
-								<th>총 후원액</th>
-								<th>건수</th>
-								<th>시작</th>
-								<th>종료</th>
-							</tr>
-						</thead>
-						<tbody>
-							{history.length === 0 ? (
-								<tr>
-									<td colSpan={5} className="studio-page__empty">아직 진행한 크루 방송이 없습니다.</td>
-								</tr>
-							) : history.map(h => (
-								<tr key={h.id}>
-									<td>{h.title}</td>
-									<td>{h.totalAmount.toLocaleString()}원</td>
-									<td>{h.totalDonationCount}건</td>
-									<td>{h.startedAt ? new Date(h.startedAt).toLocaleString('ko-KR') : '-'}</td>
-									<td>{h.endedAt ? new Date(h.endedAt).toLocaleString('ko-KR') : '-'}</td>
-								</tr>
-							))}
-						</tbody>
-					</table>
-				</div>
-			</div>
-		</>
-	);
-}

+ 0 - 105
app/studio/donation/crew/[id]/_components/CrewSettingsTab.tsx

@@ -1,105 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import type { CrewItem } from '@/types/response/crew/list';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
-
-function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
-	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
-}
-
-function MoneyInput({ id, value, onChange, placeholder }: { id: string; value: string|number; onChange: (v: string) => void; placeholder?: string }) {
-	const [focused, setFocused] = useState(false);
-	const raw = String(value);
-	const display = focused || !raw ? raw : (Number(raw) ? Number(raw).toLocaleString() : raw);
-
-	return (
-		<Input
-			id={id} type={focused ? 'number' : 'text'} min={0} placeholder={placeholder}
-			value={display}
-			onChange={e => onChange(e.target.value)}
-			onFocus={() => setFocused(true)}
-			onBlur={() => setFocused(false)}
-		/>
-	);
-}
-
-type Props = {
-	crew: CrewItem;
-	onUpdated: () => void;
-};
-
-export default function CrewSettingsTab({ crew, onUpdated }: Props)
-{
-	const { channelID } = useStudioContext();
-	const [form, setForm] = useState({
-		name: crew.name,
-		description: crew.description ?? '',
-		minAmount: crew.minAmount ?? ('' as string|number),
-		isActive: crew.isActive
-	});
-	const [saving, setSaving] = useState(false);
-
-	const handleSave = async () => {
-		if (!form.name.trim()) {
-			alert('크루명을 입력해 주세요.'); return;
-		}
-
-		setSaving(true);
-
-		try {
-
-			await fetchApi('/api/studio/crew/save', {
-				method: 'POST',
-				body: {
-					channelID,
-					id: crew.id,
-					name: form.name,
-					description: form.description || undefined,
-					minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
-					isActive: form.isActive
-				}
-			});
-
-			alert('저장되었습니다.');
-			onUpdated();
-
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
-		} finally {
-			setSaving(false);
-		}
-	};
-
-	return (
-		<div className="crew-settings">
-			<div className="space-y-4 max-w-lg">
-				<div className="space-y-2">
-					<RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
-					<Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
-				</div>
-				<div className="space-y-2">
-					<Label htmlFor="crew-desc">설명</Label>
-					<Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
-				</div>
-				<div className="space-y-2">
-					<Label htmlFor="crew-min">최소 후원금 (원)</Label>
-					<MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
-					<p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
-				</div>
-				<div className="flex items-center gap-2">
-					<Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
-					<Label htmlFor="crew-active">활성화</Label>
-				</div>
-				<div className="pt-4 border-t">
-					<Button onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
-				</div>
-			</div>
-		</div>
-	);
-}

+ 0 - 240
app/studio/donation/crew/[id]/_components/CrewWidgetTab.tsx

@@ -1,240 +0,0 @@
-'use client';
-
-import { useState, useEffect, useCallback } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import type { CrewWidgetConfigResponse, CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
-import { CREW_PERIODS, CREW_WIDGET_THEMES } from '../../widget/constants';
-import { type FormState, createEmptyForm, formatDateTime } from '../../widget/types';
-import CrewWidgetFormPanel from '../../widget/_components/CrewWidgetFormPanel';
-import CrewWidgetPreviewPanel from '../../widget/_components/CrewWidgetPreviewPanel';
-import { Button } from '@/components/ui/button';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Separator } from '@/components/ui/separator';
-
-type Props = { crewID: number };
-
-// eslint-disable-next-line @typescript-eslint/no-unused-vars
-export default function CrewWidgetTab({ crewID: _crewID }: Props)
-{
-	const { channelID } = useStudioContext();
-	const [items, setItems] = useState<CrewWidgetConfigItem[]>([]);
-	const [loading, setLoading] = useState(true);
-	const [saving, setSaving] = useState(false);
-	const [editItem, setEditItem] = useState<CrewWidgetConfigItem|null>(null);
-	const [showForm, setShowForm] = useState(false);
-	const [selected, setSelected] = useState<Set<number>>(new Set());
-	const [togglingIDs, setTogglingIDs] = useState<Set<number>>(new Set());
-	const [form, setForm] = useState<FormState>(createEmptyForm());
-
-	// ── 활성/비활성 토글 ─────────────────────────────
-	const handleToggleActive = async (item: CrewWidgetConfigItem) => {
-		if (!channelID || togglingIDs.has(item.id)) {
-			return;
-		}
-
-		const next = !item.isActive;
-
-		setTogglingIDs(prev => {
-			const s = new Set(prev);
-			s.add(item.id);
-			return s;
-		});
-
-		setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: next } : x));
-
-		try {
-			const res = await fetchApi<{ id: number; isActive: boolean }>(
-				`/api/studio/crew/widget/config/${item.id}/active`,
-				{
-					method: 'PATCH',
-					body: { channelID, isActive: next },
-					silent: true
-				}
-			);
-
-			if (!res.success) {
-				setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
-				alert(res.message || '활성 상태 변경에 실패했습니다.');
-			} else if (res.data && res.data.isActive !== next) {
-				setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: res.data!.isActive } : x));
-			}
-		} catch (err) {
-			setItems(prev => prev.map(x => x.id === item.id ? { ...x, isActive: item.isActive } : x));
-			alert(err instanceof Error ? err.message : '활성 상태 변경에 실패했습니다.');
-		} finally {
-			setTogglingIDs(prev => {
-				const s = new Set(prev);
-				s.delete(item.id);
-				return s;
-			});
-		}
-	};
-
-	const fetchList = useCallback(() => {
-		if (!channelID) {
-			setLoading(false);
-			return;
-		}
-
-		setLoading(true);
-		fetchApi<CrewWidgetConfigResponse>(`/api/studio/crew/widget/config/${channelID}`)
-			.then(res => setItems(res.data?.list ?? []))
-			.catch(() => {})
-			.finally(() => setLoading(false));
-
-	}, [channelID]);
-
-	useEffect(() => {
-		fetchList();
-	}, [fetchList]);
-
-	const getPeriodLabel = (p: number) => CREW_PERIODS.find(x => x.value === p)?.label ?? '-';
-	const getThemeLabel = (t: number) => CREW_WIDGET_THEMES.find(x => x.value === t)?.label ?? '-';
-
-	const openAdd = () => {
-		setEditItem(null);
-		setForm(createEmptyForm());
-		setShowForm(true);
-	};
-
-	const openEdit = (item: CrewWidgetConfigItem) => {
-		setEditItem(item);
-		setShowForm(true);
-	};
-
-	const handleSaved = () => {
-		setShowForm(false);
-		setEditItem(null);
-		fetchList();
-	};
-
-	const toggleSelect = (id: number) => {
-		setSelected(prev => {
-			const next = new Set(prev);
-			if (next.has(id)) {
-				next.delete(id);
-			} else {
-				next.add(id);
-			}
-			return next;
-		});
-	};
-
-	const handleBatchDelete = async () => {
-		if (selected.size === 0) {
-			return;
-		}
-
-		if (!confirm(`${selected.size}개 설정을 삭제하시겠습니까?`)) {
-			return;
-		}
-
-		for (const id of selected) {
-			try {
-				await fetchApi(`/api/studio/crew/widget/config/${id}/${channelID}`, { method: 'DELETE' });
-			} catch {
-
-			}
-		}
-
-		setSelected(new Set());
-		fetchList();
-	};
-
-	if (loading) {
-		return <p className="studio-page__empty">준비 중...</p>;
-	}
-
-	if (showForm) {
-		return (
-			<>
-				<div className="crew-members__toolbar">
-					<h2 className="crew-members__subtitle">{editItem ? '위젯 수정' : '위젯 추가'}</h2>
-					<Button variant="outline" size="sm" type="button" onClick={() => setShowForm(false)}>< 뒤로가기</Button>
-				</div>
-				<div className="pt-3 pb-3">
-					<Separator orientation="horizontal" />
-				</div>
-				<div className="crew-widget-layout">
-					<CrewWidgetPreviewPanel form={form} />
-					<Separator orientation="vertical" />
-					<CrewWidgetFormPanel
-						editItem={editItem ?? undefined}
-						form={form}
-						onFormChange={setForm}
-						onSaved={handleSaved}
-						onCancel={() => setShowForm(false)}
-						channelID={channelID ?? undefined}
-						saving={saving}
-						setSaving={setSaving}
-					/>
-				</div>
-			</>
-		);
-	}
-
-	return (
-		<div className="crew-widget">
-			<div className="crew-members__toolbar pb-3">
-				<h2 className="crew-members__subtitle">위젯 설정 ({items.length}개)</h2>
-				<div className="studio-page__actions">
-					{selected.size > 0 && (
-						<Button variant="destructive" size="sm" type="button" onClick={handleBatchDelete}>{selected.size}개 삭제</Button>
-					)}
-					<Button size="sm" type="button" onClick={openAdd}>+ 추가</Button>
-				</div>
-			</div>
-
-			<div className="studio-page__table-wrap">
-				<table className="studio-page__table">
-					<thead>
-						<tr>
-							<th><Checkbox checked={items.length > 0 && selected.size === items.length} onCheckedChange={() => setSelected(items.length === selected.size ? new Set() : new Set(items.map(i => i.id)))} /></th>
-							<th>제목</th>
-							<th>기간</th>
-							<th>테마</th>
-							<th>최대 표시</th>
-							<th>활성</th>
-							<th>작업</th>
-						</tr>
-					</thead>
-					<tbody>
-						{items.length === 0 ? (
-							<tr><td colSpan={7} className="studio-page__empty">등록된 위젯 설정이 없습니다.</td></tr>
-						) : items.map(item => (
-							<tr key={item.id}>
-								<td><Checkbox checked={selected.has(item.id)} onCheckedChange={() => toggleSelect(item.id)} /></td>
-								<td>{item.title}</td>
-								<td>
-									{getPeriodLabel(item.period)}
-									{item.period === 5 && item.startAt && item.endAt && (
-										<div className="text-xs text-muted-foreground">{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}</div>
-									)}
-								</td>
-								<td>{getThemeLabel(item.theme)}</td>
-								<td>{item.maxDisplayCount}명</td>
-								<td>
-									<button
-										type="button"
-										role="switch"
-										aria-checked={item.isActive}
-										aria-busy={togglingIDs.has(item.id)}
-										aria-label={`${item.title || `#${item.id}`} 활성 상태 ${item.isActive ? '끄기' : '켜기'}`}
-										className={`crew-widget__active-toggle${item.isActive ? ' crew-widget__active-toggle--on' : ''}${togglingIDs.has(item.id) ? ' crew-widget__active-toggle--busy' : ''}`}
-										onClick={() => handleToggleActive(item)}
-										disabled={togglingIDs.has(item.id) || saving}
-										title={item.isActive ? '비활성화' : '활성화'}
-									>
-										<span className="crew-widget__active-toggle-knob" aria-hidden="true" />
-									</button>
-								</td>
-								<td><Button variant="outline" size="sm" type="button" onClick={() => openEdit(item)}>수정</Button></td>
-							</tr>
-						))}
-					</tbody>
-				</table>
-			</div>
-		</div>
-	);
-}

+ 0 - 104
app/studio/donation/crew/[id]/page.tsx

@@ -1,104 +0,0 @@
-'use client';
-
-import './style.scss';
-import '../../crew/widget/style.scss';
-import { useState, useEffect, useCallback } from 'react';
-import { useParams } from 'next/navigation';
-import Link from 'next/link';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import type { CrewItem, CrewListResponse } from '@/types/response/crew/list';
-import { Button } from '@/components/ui/button';
-import CrewSettingsTab from './_components/CrewSettingsTab';
-import CrewMembersTab from './_components/CrewMembersTab';
-import CrewWidgetTab from './_components/CrewWidgetTab';
-import CrewSessionTab from './_components/CrewSessionTab';
-
-const TABS = [
-	{ key: 'settings', label: '기본 설정' },
-	{ key: 'members', label: '크루원' },
-	{ key: 'widget', label: '위젯' },
-	{ key: 'session', label: '세션' }
-] as const;
-
-type TabKey = typeof TABS[number]['key'];
-
-export default function CrewDetailPage()
-{
-	const { id } = useParams<{ id: string }>();
-	const { channelID } = useStudioContext();
-	const crewID = Number(id);
-
-	const [crew, setCrew] = useState<CrewItem|null>(null);
-	const [loading, setLoading] = useState(true);
-	const [activeTab, setActiveTab] = useState<TabKey>('members');
-
-	const fetchCrew = useCallback(async () => {
-		if (!channelID) return;
-		try {
-			const res = await fetchApi<CrewListResponse>(`/api/studio/crew/list/${channelID}`);
-			const found = res.data?.list.find(c => c.id === crewID);
-			if (found) setCrew(found);
-		} catch {}
-	}, [channelID, crewID]);
-
-	useEffect(() => {
-		setLoading(true);
-		fetchCrew().finally(() => setLoading(false));
-	}, [fetchCrew]);
-
-	if (!channelID) {
-		return (
-			<div className="studio-page">
-				<p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
-			</div>
-		);
-	}
-
-	if (loading) {
-		return <div className="studio-page"><p className="studio-page__empty">준비 중...</p></div>;
-	}
-
-	if (!crew) {
-		return <div className="studio-page"><p className="studio-page__empty">크루를 찾을 수 없습니다.</p></div>;
-	}
-
-	return (
-		<div className="studio-page crew-detail">
-			{/* 헤더: 제목 + 액션 버튼 */}
-			<div className="crew-detail__header">
-				<div className="crew-detail__info">
-					<h1 className="crew-detail__title">{crew.name}</h1>
-					{crew.description && <p className="crew-detail__desc">{crew.description}</p>}
-				</div>
-				<div className="crew-detail__actions">
-					<Button variant="outline" size="sm" asChild>
-						<Link href="/studio/donation/crew">< 목록으로</Link>
-					</Button>
-				</div>
-			</div>
-
-			{/* 탭 네비게이션 */}
-			<nav className="crew-tabs">
-				{TABS.map(tab => (
-					<button
-						type="button"
-						key={tab.key}
-						className={`crew-tabs__item${activeTab === tab.key ? ' crew-tabs__item--active' : ''}`}
-						onClick={() => setActiveTab(tab.key)}
-					>
-						{tab.label}
-					</button>
-				))}
-			</nav>
-
-			{/* 탭 컨텐츠 */}
-			<div className="crew-tabs__content">
-				{activeTab === 'settings' && <CrewSettingsTab crew={crew} onUpdated={fetchCrew} />}
-				{activeTab === 'members' && <CrewMembersTab crewID={crewID} />}
-				{activeTab === 'widget' && <CrewWidgetTab crewID={crewID} />}
-				{activeTab === 'session' && <CrewSessionTab crewID={crewID} />}
-			</div>
-		</div>
-	);
-}

+ 0 - 585
app/studio/donation/crew/[id]/style.scss

@@ -1,585 +0,0 @@
-.crew-detail {
-	&__header {
-		display: flex;
-		align-items: flex-start;
-		justify-content: space-between;
-		gap: 16px;
-		margin-bottom: 14px;
-
-		@media (max-width: 768px) {
-			flex-direction: column;
-			gap: 12px;
-		}
-	}
-
-	&__info {
-		flex: 1;
-		min-width: 0;
-	}
-
-	&__title {
-		font-size: 1.5rem;
-		font-weight: 700;
-		margin: 0;
-		line-height: 1.3;
-	}
-
-	&__desc {
-		color: var(--text-muted);
-		font-size: 0.875rem;
-		margin-top: 4px;
-	}
-
-	&__actions {
-		display: flex;
-		gap: 8px;
-		flex-shrink: 0;
-		align-items: center;
-	}
-}
-
-.crew-tabs {
-	display: flex;
-	gap: 0;
-	border-bottom: 1px solid var(--border-default);
-	margin-bottom: 24px;
-
-	@media (max-width: 768px) {
-		overflow-x: auto;
-		-webkit-overflow-scrolling: touch;
-
-		&::-webkit-scrollbar {
-			display: none;
-		}
-	}
-
-	&__item {
-		padding: 14px 24px;
-		border: none;
-		background: none;
-		cursor: pointer;
-		font-size: 0.875rem;
-		font-weight: 500;
-		color: var(--text-muted);
-		border-bottom: 3px solid transparent;
-		margin-bottom: -1px;
-		transition: color 0.15s;
-		white-space: nowrap;
-		letter-spacing: 0.01em;
-
-		&:first-of-type {
-			padding-left: 0;
-		}
-
-		&:hover {
-			color: var(--text-primary);
-		}
-
-		&--active {
-			color: var(--text-primary);
-			font-weight: 600;
-			border-bottom-color: hsl(var(--foreground));
-		}
-	}
-
-	&__content {
-		min-height: 200px;
-	}
-}
-
-.crew-members {
-	&__toolbar {
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		margin-bottom: 0;
-
-		@media (max-width: 768px) {
-			align-items: flex-start;
-			gap: 8px;
-		}
-	}
-
-	&__subtitle {
-		font-size: 1rem;
-		font-weight: 600;
-		margin: 0;
-	}
-
-	&__actions {
-		display: flex;
-		gap: 8px;
-	}
-
-	.studio-page__table-wrap {
-		@media (max-width: 768px) {
-			overflow-x: auto;
-		}
-	}
-
-	.studio-page__table {
-		@media (max-width: 768px) {
-			min-width: 640px;
-		}
-	}
-}
-
-.crew-invite {
-	margin-bottom: 24px;
-	padding: 16px;
-	border: 1px solid hsl(var(--border));
-	border-radius: 8px;
-
-	@media (min-width: 1024px) {
-		max-width: 420px;
-	}
-
-	&__legend {
-		font-size: 0.875rem;
-		font-weight: 600;
-		padding: 0 6px;
-		display: inline-flex;
-		align-items: center;
-		gap: 6px;
-	}
-
-	&__help {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 18px;
-		height: 18px;
-		border: none;
-		background: transparent;
-		color: var(--text-muted);
-		border-radius: 50%;
-		cursor: help;
-		padding: 0;
-		transition: color 0.15s, background 0.15s;
-
-		&:hover,
-		&:focus-visible {
-			color: var(--text-primary);
-			background: hsl(var(--muted));
-			outline: none;
-		}
-	}
-
-	&__body {
-		display: flex;
-		gap: 8px;
-		align-items: center;
-
-		@media (max-width: 768px) {
-			flex-direction: column;
-		}
-	}
-
-	&__input {
-		flex: 1;
-		font-family: monospace;
-		font-size: 1rem;
-		font-weight: 600;
-		letter-spacing: -1px;
-
-		@media (max-width: 768px) {
-			width: 100%;
-		}
-	}
-
-	&__actions {
-		display: flex;
-		gap: 8px;
-		flex-shrink: 0;
-
-		@media (max-width: 768px) {
-			width: 100%;
-
-			> button {
-				flex: 1;
-			}
-		}
-	}
-}
-
-.member-search {
-	position: relative;
-
-	&__input {
-		width: 100%;
-		padding: 8px 12px;
-		border: 1px solid var(--border-default);
-		border-radius: 6px;
-		font-size: 0.875rem;
-		background: var(--bg-page);
-		color: var(--text-primary);
-	}
-
-	&__results {
-		position: absolute;
-		top: 100%;
-		left: 0;
-		right: 0;
-		z-index: 10;
-		background: var(--bg-page);
-		border: 1px solid var(--border-default);
-		border-radius: 6px;
-		max-height: 240px;
-		overflow-y: auto;
-		margin-top: 4px;
-		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
-	}
-
-	&__item {
-		display: flex;
-		align-items: center;
-		gap: 10px;
-		padding: 8px 12px;
-		cursor: pointer;
-		border: none;
-		background: none;
-		width: 100%;
-		text-align: left;
-		font: inherit;
-		color: var(--text-primary);
-
-		&:hover {
-			background: hsl(var(--accent));
-		}
-	}
-
-	&__thumb {
-		width: 32px;
-		height: 32px;
-		border-radius: 50%;
-		background: hsl(var(--muted));
-		object-fit: cover;
-	}
-
-	&__info {
-		flex: 1;
-		min-width: 0;
-	}
-
-	&__name {
-		font-weight: 500;
-		font-size: 0.875rem;
-	}
-
-	&__email {
-		font-size: 0.75rem;
-		color: var(--text-muted);
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-	}
-
-	&__channel {
-		font-size: 0.75rem;
-		color: hsl(var(--primary));
-		display: flex;
-		align-items: center;
-		gap: 6px;
-		flex-wrap: wrap;
-	}
-
-	&__handle {
-		color: var(--text-muted);
-		font-weight: 500;
-	}
-}
-
-// ── 위젯 설정 탭 ──────────────────────────────────
-.crew-widget {
-	// 활성 토글 스위치
-	&__active-toggle {
-		position: relative;
-		display: inline-flex;
-		align-items: center;
-		width: 42px;
-		height: 22px;
-		padding: 2px;
-		border: 1px solid hsl(var(--border));
-		border-radius: 999px;
-		background: hsl(var(--muted));
-		cursor: pointer;
-		transition: background 0.18s, border-color 0.18s;
-
-		&-knob {
-			width: 16px;
-			height: 16px;
-			border-radius: 50%;
-			background: #fff;
-			box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
-			transition: transform 0.18s;
-		}
-
-		&--on {
-			background: hsl(142 71% 45%);
-			border-color: hsl(142 71% 45%);
-
-			.crew-widget__active-toggle-knob {
-				transform: translateX(20px);
-			}
-		}
-
-		&--busy {
-			opacity: 0.7;
-			cursor: progress;
-		}
-
-		&:disabled {
-			cursor: not-allowed;
-
-			&:not(.crew-widget__active-toggle--busy) {
-				opacity: 0.6;
-			}
-		}
-
-		&:not(:disabled):hover {
-			border-color: hsl(142 71% 45%);
-		}
-	}
-
-	.studio-page__table-wrap {
-		@media (max-width: 768px) {
-			overflow-x: auto;
-		}
-	}
-
-	.studio-page__table {
-		@media (max-width: 768px) {
-			min-width: 640px;
-		}
-	}
-}
-
-// ── 세션 탭 ────────────────────────────────────
-.session-active {
-	margin-bottom: 32px;
-
-	&__card {
-		border: 1px solid var(--border-default);
-		border-radius: 8px;
-		padding: 20px;
-		background: hsl(var(--card));
-
-		@media (max-width: 768px) {
-			padding: 14px;
-		}
-	}
-
-	&__top {
-		display: flex;
-		justify-content: space-between;
-		align-items: center;
-		margin-bottom: 16px;
-
-		@media (max-width: 768px) {
-			flex-direction: column;
-			align-items: flex-start;
-			gap: 10px;
-		}
-	}
-
-	&__info {
-		display: flex;
-		align-items: center;
-		gap: 12px;
-
-		@media (max-width: 768px) {
-			flex-wrap: wrap;
-		}
-	}
-
-	&__session-title {
-		font-size: 1.1rem;
-		font-weight: 600;
-	}
-
-	&__stats {
-		display: flex;
-		gap: 24px;
-		margin-bottom: 20px;
-		padding: 12px 16px;
-		background: hsl(var(--accent));
-		border-radius: 6px;
-
-		@media (max-width: 768px) {
-			flex-direction: column;
-			gap: 12px;
-		}
-	}
-
-	&__stat {
-		text-align: center;
-
-		@media (max-width: 768px) {
-			display: flex;
-			align-items: center;
-			justify-content: space-between;
-			text-align: left;
-		}
-
-		&-value {
-			font-size: 1.5rem;
-			font-weight: 700;
-			color: hsl(var(--primary));
-
-			@media (max-width: 768px) {
-				font-size: 1.25rem;
-			}
-		}
-
-		&-label {
-			font-size: 0.75rem;
-			color: var(--text-muted);
-			margin-top: 2px;
-
-			@media (max-width: 768px) {
-				margin-top: 0;
-			}
-		}
-	}
-
-	.studio-page__table-wrap {
-		@media (max-width: 768px) {
-			overflow-x: auto;
-		}
-	}
-
-	.studio-page__table {
-		@media (max-width: 768px) {
-			min-width: 480px;
-		}
-	}
-}
-
-.session-consents {
-	margin-bottom: 16px;
-
-	&__title {
-		font-size: 0.875rem;
-		font-weight: 600;
-		margin-bottom: 8px;
-	}
-
-	&__list {
-		display: flex;
-		flex-wrap: wrap;
-		gap: 8px;
-
-		@media (max-width: 768px) {
-			gap: 6px;
-		}
-	}
-
-	&__item {
-		display: flex;
-		align-items: center;
-		gap: 6px;
-		padding: 6px 12px;
-		border-radius: 20px;
-		font-size: 0.875rem;
-		border: 1px solid var(--border-default);
-
-		&--consented {
-			background: hsl(var(--primary) / 0.1);
-			border-color: hsl(var(--primary) / 0.3);
-		}
-
-		&--pending {
-			background: hsl(var(--accent));
-		}
-	}
-
-	&__icon {
-		font-size: 0.875rem;
-	}
-}
-
-.session-start {
-	border: 2px dashed var(--border-default);
-	border-radius: 8px;
-	padding: 32px;
-	text-align: center;
-	margin-bottom: 0;
-
-	@media (max-width: 768px) {
-		padding: 20px;
-	}
-
-	&__title {
-		font-size: 1.1rem;
-		font-weight: 600;
-		margin-bottom: 16px;
-	}
-
-	&__form {
-		display: flex;
-		gap: 12px;
-		justify-content: center;
-		align-items: center;
-		max-width: 480px;
-		margin: 0 auto;
-
-		@media (max-width: 768px) {
-			flex-direction: column;
-		}
-	}
-
-	&__input {
-		flex: 1;
-		padding: 8px 12px;
-		font-size: 0.875rem;
-
-		@media (max-width: 768px) {
-			width: 100%;
-		}
-	}
-}
-
-.session-history {
-	&__title {
-		font-size: 1rem;
-		font-weight: 600;
-		margin-bottom: 16px;
-	}
-
-	.studio-page__table-wrap {
-		@media (max-width: 768px) {
-			overflow-x: auto;
-		}
-	}
-
-	.studio-page__table {
-		@media (max-width: 768px) {
-			min-width: 580px;
-		}
-	}
-}
-
-// ── 멤버 아바타 ─────────────────────────────────
-.member-row {
-	&__thumb {
-		display: inline-flex;
-		width: 28px;
-		height: 28px;
-		vertical-align: middle;
-		margin-right: 8px;
-	}
-
-	&__online-dot {
-		display: inline-block;
-		width: 10px;
-		height: 10px;
-		border-radius: 50%;
-		background: var(--text-muted);
-		vertical-align: middle;
-
-		&--on {
-			background: var(--color-success);
-			box-shadow: 0 0 0 2px hsl(142 71% 45% / 0.18);
-		}
-	}
-}

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

@@ -1,224 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useState, useEffect, useCallback } from 'react';
-import { useRouter } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import type { CrewListResponse, CrewItem } from '@/types/response/crew/list';
-import { Button } from '@/components/ui/button';
-import { Input } from '@/components/ui/input';
-import { Label } from '@/components/ui/label';
-import { Checkbox } from '@/components/ui/checkbox';
-import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
-
-function RequiredLabel({ htmlFor, children }: { htmlFor: string; children: React.ReactNode }) {
-	return <Label htmlFor={htmlFor}><span className="text-destructive mr-0.5">*</span>{children}</Label>;
-}
-
-function MoneyInput({ id, value, onChange, placeholder }: { id: string; value: string|number; onChange: (v: string) => void; placeholder?: string }) {
-	const [focused, setFocused] = useState(false);
-	const raw = String(value);
-	const display = focused || !raw ? raw : (Number(raw) ? Number(raw).toLocaleString() : raw);
-
-	return (
-		<Input
-			id={id} type={focused ? 'number' : 'text'} min={0} placeholder={placeholder}
-			value={display}
-			onKeyDown={e => { if (!/^[0-9]$/.test(e.key) && !['Backspace','Delete','ArrowLeft','ArrowRight','Tab','Home','End'].includes(e.key)) e.preventDefault(); }}
-			onCompositionStart={e => e.preventDefault()}
-			onChange={e => onChange(e.target.value.replace(/[^0-9]/g, ''))}
-			onFocus={() => setFocused(true)}
-			onBlur={() => setFocused(false)}
-		/>
-	);
-}
-
-const EMPTY_FORM = {
-	name: '',
-	description: '',
-	minAmount: '' as string|number,
-	isActive: true
-};
-
-export default function CrewConfigPage()
-{
-	const router = useRouter();
-	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 {
-
-			await fetchApi('/api/studio/crew/save', {
-				method: 'POST',
-				body: {
-					channelID,
-					id: modal.editing?.id ?? undefined,
-					name: form.name,
-					description: form.description || undefined,
-					minAmount: form.minAmount !== '' ? Number(form.minAmount) : undefined,
-					isActive: form.isActive
-				}
-			});
-
-			closeModal();
-			fetchList();
-		} catch (err: unknown) {
-			setError(err instanceof Error ? err.message : '저장에 실패했습니다.');
-		} finally {
-			setSaving(false);
-		}
-	};
-
-	if (!channelID) {
-		return (
-			<div className="studio-page">
-				<p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
-			</div>
-		);
-	}
-
-	return (
-		<div className="studio-page crew-config">
-			<div className="studio-page__header">
-				<h1 className="studio-page__title">크루 후원 설정</h1>
-				<Button 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">
-											<button type="button" className="crew-config__link" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>
-												{item.name}
-											</button>
-										</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 variant="outline" size="sm" onClick={() => openEdit(item)}>수정</Button>
-												<Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/${item.id}`)}>관리</Button>
-											</div>
-										</td>
-									</tr>
-								))
-							)}
-						</tbody>
-					</table>
-				)}
-			</div>
-
-			<Dialog open={modal.open} onOpenChange={open => { if (!open) closeModal(); }}>
-				<DialogContent>
-					<DialogHeader>
-						<DialogTitle>{modal.editing ? '크루 수정' : '크루 추가'}</DialogTitle>
-					</DialogHeader>
-					<div className="space-y-4">
-						<div className="space-y-2">
-							<RequiredLabel htmlFor="crew-name">크루명</RequiredLabel>
-							<Input id="crew-name" value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} />
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="crew-desc">설명</Label>
-							<Input id="crew-desc" value={form.description} onChange={e => setForm(f => ({ ...f, description: e.target.value }))} />
-						</div>
-						<div className="space-y-2">
-							<Label htmlFor="crew-min">최소 후원금 (원)</Label>
-							<MoneyInput id="crew-min" placeholder="미설정" value={form.minAmount} onChange={v => setForm(f => ({ ...f, minAmount: v }))} />
-							<p className="text-xs text-muted-foreground">이 금액 이상 후원한 회원만 크루에 가입할 수 있습니다.</p>
-						</div>
-						<div className="flex items-center gap-2">
-							<Checkbox id="crew-active" checked={form.isActive} onCheckedChange={v => setForm(f => ({ ...f, isActive: !!v }))} />
-							<Label htmlFor="crew-active">활성화</Label>
-						</div>
-					</div>
-					<DialogFooter className="flex flex-row justify-center gap-0 sm:justify-center">
-						<Button variant="outline" className="flex-1 sm:flex-none" onClick={closeModal}>취소</Button>
-						<Button className="flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>{saving ? '저장 중...' : '저장'}</Button>
-					</DialogFooter>
-				</DialogContent>
-			</Dialog>
-		</div>
-	);
-}

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

@@ -1,41 +0,0 @@
-.crew-config {
-	&__name {
-		font-weight: 500;
-	}
-
-	&__link {
-		color: var(--primary);
-		text-decoration: none;
-		cursor: pointer;
-		background: none;
-		border: none;
-		padding: 0;
-		font: inherit;
-		font-weight: 500;
-
-		&:hover {
-			text-decoration: underline;
-		}
-	}
-
-	&__desc {
-		max-width: 180px;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-		color: var(--muted-foreground);
-	}
-
-	// 목록 테이블 모바일 가로 스크롤
-	.studio-page__table-wrap {
-		@media (max-width: 768px) {
-			overflow-x: auto;
-		}
-	}
-
-	.studio-page__table {
-		@media (max-width: 768px) {
-			min-width: 580px;
-		}
-	}
-}

+ 0 - 419
app/studio/donation/crew/widget/_components/CrewWidgetFormPanel.tsx

@@ -1,419 +0,0 @@
-'use client';
-
-import { useState, useEffect } from 'react';
-import { useRouter } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import { useCrewWidgetConfigContext } from '../context';
-import { CREW_WIDGET_THEMES, CREW_PERIODS, FONT_FAMILIES } from '../constants';
-import { type FormState, createEmptyForm, formatInput, parseInput } from '../types';
-import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
-import { Checkbox } from '@/components/ui/checkbox';
-
-type CrewListItem = { id: number; name: string };
-
-/** 색상 입력 (color picker + hex text) */
-function ColorInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
-	return (
-		<div className="crew-widget-form__color-field">
-			<input
-				type="color"
-				className="crew-widget-form__color-picker"
-				value={value}
-				onChange={e => onChange(e.target.value)}
-			/>
-			<input
-				type="text"
-				className="crew-widget-form__input crew-widget-form__input--color-text"
-				value={value}
-				onChange={e => onChange(e.target.value)}
-				maxLength={7}
-			/>
-		</div>
-	);
-}
-
-type Props = {
-	editItem?: CrewWidgetConfigItem;
-	form?: FormState;
-	onFormChange?: (form: FormState) => void;
-	onSaved?: () => void;
-	onCancel?: () => void;
-	channelID?: number;
-	saving?: boolean;
-	setSaving?: (v: boolean) => void;
-};
-
-export default function CrewWidgetFormPanel({ editItem, form: externalForm, onFormChange, onSaved, onCancel, channelID: directChannelID, saving: directSaving, setSaving: directSetSaving }: Props)
-{
-	const router = useRouter();
-	const studio = useStudioContext();
-	const channelID = directChannelID ?? studio.channelID;
-
-	// context 사용 가능 여부에 따라 분기
-	let ctxSaving = false;
-	let ctxSetSaving: (v: boolean) => void = () => {};
-	let ctxFetchList: () => void = () => {};
-
-	try {
-		const ctx = useCrewWidgetConfigContext();
-		ctxSaving = ctx.saving;
-		ctxSetSaving = ctx.setSaving;
-		ctxFetchList = ctx.fetchList;
-	} catch {
-
-	}
-
-	const saving = directSaving ?? ctxSaving;
-	const setSaving = directSetSaving ?? ctxSetSaving;
-	const fetchList = ctxFetchList;
-
-	const [internalForm, setInternalForm] = useState<FormState>(createEmptyForm());
-	const form = externalForm ?? internalForm;
-	const [crews, setCrews] = useState<CrewListItem[]>([]);
-
-	// 채널의 활성 Crew 목록 로드
-	useEffect(() => {
-		if (!channelID) {
-			return;
-		}
-		fetchApi<{ list: CrewListItem[] }>(`/api/crew?channelID=${channelID}`, { silent: true }).then(res => {
-			if (res.data?.list) {
-				setCrews(res.data.list);
-				if (!editItem && res.data.list.length > 0 && form.crewID === 0) {
-					if (onFormChange) {
-						onFormChange({ ...form, crewID: res.data.list[0].id, crewName: res.data.list[0].name });
-					} else {
-						setInternalForm(prev => ({ ...prev, crewID: res.data!.list[0].id, crewName: res.data!.list[0].name }));
-					}
-				}
-			}
-		}).catch(() => {});
-	}, [channelID]);
-
-	useEffect(() => {
-		if (!editItem) {
-			return;
-		}
-
-		const data: FormState = {
-			crewID: editItem.crewID,
-			crewName: editItem.crewName,
-			title: editItem.title,
-			theme: editItem.theme,
-			period: editItem.period,
-			startAt: editItem.startAt,
-			endAt: editItem.endAt,
-			maxDisplayCount: editItem.maxDisplayCount,
-			isShowAmount: editItem.isShowAmount,
-			isShowDonationCount: editItem.isShowDonationCount,
-			isShowContributionRate: editItem.isShowContributionRate,
-			isShowMemberIcon: editItem.isShowMemberIcon,
-			isActive: editItem.isActive,
-			bgColor: editItem.bgColor,
-			titleFontFamily: editItem.titleFontFamily,
-			titleFontSizePx: editItem.titleFontSizePx,
-			titleFontColor: editItem.titleFontColor,
-			rank1FontFamily: editItem.rank1FontFamily,
-			rank1FontSizePx: editItem.rank1FontSizePx,
-			rank1FontColor: editItem.rank1FontColor,
-			rank2FontFamily: editItem.rank2FontFamily,
-			rank2FontSizePx: editItem.rank2FontSizePx,
-			rank2FontColor: editItem.rank2FontColor,
-			rank3FontFamily: editItem.rank3FontFamily,
-			rank3FontSizePx: editItem.rank3FontSizePx,
-			rank3FontColor: editItem.rank3FontColor,
-			rowFontFamily: editItem.rowFontFamily,
-			rowFontSizePx: editItem.rowFontSizePx,
-			rowFontColor: editItem.rowFontColor
-		};
-
-		if (onFormChange) {
-			onFormChange(data);
-		} else {
-			setInternalForm(data);
-		}
-	}, [editItem]);
-
-	const set = (key: keyof FormState, value: FormState[keyof FormState]) => {
-		const updater = (f: FormState): FormState => ({ ...f, [key]: value });
-		if (onFormChange) {
-			onFormChange(updater(form));
-		} else {
-			setInternalForm(updater);
-		}
-	};
-
-	const handleSave = async () => {
-		if (!form.crewID || form.crewID === 0) {
-			alert('크루를 선택해 주세요.');
-			return;
-		}
-		if (!form.title.trim()) {
-			alert('제목을 입력해 주세요.');
-			return;
-		}
-
-		if (form.period === 5) {
-			if (!form.startAt || !form.endAt) {
-				alert('사용자 지정 기간을 입력해 주세요.');
-				return;
-			}
-		}
-
-		setSaving(true);
-
-		try {
-
-			await fetchApi('/api/studio/crew/widget/config', {
-				method: 'POST',
-				body: { ...form, channelID, id: editItem?.id ?? undefined }
-			});
-
-			fetchList();
-
-			if (onSaved) {
-				onSaved();
-			} else {
-				router.push('/studio/donation/crew/widget/list');
-			}
-
-		} catch (err: unknown) {
-			alert(err instanceof Error ? err.message : '저장에 실패했습니다.');
-		} finally {
-			setSaving(false);
-		}
-	};
-
-	const renderFontFields = (prefix: string) =>
-	{
-		const familyKey = `${prefix}FontFamily` as keyof FormState;
-		const sizeKey = `${prefix}FontSizePx` as keyof FormState;
-		const colorKey = `${prefix}FontColor` as keyof FormState;
-
-		return (
-			<>
-				<div className="crew-widget-form__field">
-					<label className="crew-widget-form__field-label">글꼴</label>
-					<select
-						className="crew-widget-form__select"
-						aria-label="글꼴"
-						value={(form[familyKey] as string) ?? ''}
-						onChange={e => set(familyKey, e.target.value || null)}
-					>
-						{FONT_FAMILIES.map(f => <option key={f.value} value={f.value}>{f.label}</option>)}
-					</select>
-				</div>
-				<div className="crew-widget-form__row">
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__field-label">크기(px)</label>
-						<input
-							type="number"
-							className="crew-widget-form__input"
-							min={10}
-							max={48}
-							value={form[sizeKey] as number}
-							onChange={e => set(sizeKey, Number(e.target.value))}
-						/>
-					</div>
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__field-label">색상</label>
-						<ColorInput
-							value={form[colorKey] as string}
-							onChange={v => set(colorKey, v)}
-						/>
-					</div>
-				</div>
-			</>
-		);
-	};
-
-	const renderFontDetails = (label: string, prefix: string) => {
-		return (
-			<details className="crew-widget-form__details">
-				<summary className="crew-widget-form__details-summary">{label} 폰트 설정</summary>
-				<div className="crew-widget-form__details-body">
-					{renderFontFields(prefix)}
-				</div>
-			</details>
-		);
-	};
-
-	return (
-		<main className="crew-widget-form">
-			{/* 기본 설정 */}
-			<details className="crew-widget-form__section" open>
-				<summary className="crew-widget-form__section-title">기본 설정</summary>
-				<div className="crew-widget-form__section-body">
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__field-label"><span className="text-destructive mr-0.5">*</span> 크루</label>
-						<select
-							className="crew-widget-form__select"
-							aria-label="크루 선택"
-							value={form.crewID}
-							onChange={e => {
-								const id = Number(e.target.value);
-								const sel = crews.find(c => c.id === id);
-								set('crewID', id);
-								set('crewName', sel?.name ?? '');
-							}}
-						>
-							<option value={0}>-- 크루 선택 --</option>
-							{crews.map(c => (
-								<option key={c.id} value={c.id}>{c.name}</option>
-							))}
-						</select>
-					</div>
-
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__field-label"><span className="text-destructive mr-0.5">*</span> 제목</label>
-						<input
-							type="text"
-							className="crew-widget-form__input"
-							value={form.title}
-							onChange={e => set('title', e.target.value)}
-							maxLength={300}
-						/>
-					</div>
-
-					<div className="crew-widget-form__row">
-						<div className="crew-widget-form__field">
-							<label className="crew-widget-form__field-label">테마</label>
-							<select
-								className="crew-widget-form__select"
-								aria-label="테마"
-								value={form.theme}
-								onChange={e => set('theme', Number(e.target.value))}
-							>
-								{CREW_WIDGET_THEMES.map(t => <option key={t.value} value={t.value}>{t.label}</option>)}
-							</select>
-						</div>
-						<div className="crew-widget-form__field">
-							<label className="crew-widget-form__field-label">기간</label>
-							<select
-								className="crew-widget-form__select"
-								aria-label="기간"
-								value={form.period}
-								onChange={e => set('period', Number(e.target.value))}
-							>
-								{CREW_PERIODS.map(p => <option key={p.value} value={p.value}>{p.label}</option>)}
-							</select>
-						</div>
-					</div>
-
-					{form.period === 5 && (
-						<div className="crew-widget-form__row">
-							<div className="crew-widget-form__field">
-								<label className="crew-widget-form__field-label">시작</label>
-								<input
-									type="text"
-									className="crew-widget-form__input"
-									placeholder="2026.04.12 14:00"
-									maxLength={16}
-									value={form.startAt ? formatInput(new Date(form.startAt)) : ''}
-									onChange={e => set('startAt', parseInput(e.target.value) || null)}
-								/>
-							</div>
-							<div className="crew-widget-form__field">
-								<label className="crew-widget-form__field-label">종료</label>
-								<input
-									type="text"
-									className="crew-widget-form__input"
-									placeholder="2026.04.13 14:00"
-									maxLength={16}
-									value={form.endAt ? formatInput(new Date(form.endAt)) : ''}
-									onChange={e => set('endAt', parseInput(e.target.value) || null)}
-								/>
-							</div>
-						</div>
-					)}
-
-					<div className="crew-widget-form__row">
-						<div className="crew-widget-form__field">
-							<label className="crew-widget-form__field-label">최대 표시 수</label>
-							<input
-								type="number"
-								className="crew-widget-form__input"
-								min={1}
-								max={20}
-								value={form.maxDisplayCount}
-								onChange={e => set('maxDisplayCount', Number(e.target.value))}
-							/>
-						</div>
-						<div className="crew-widget-form__field">
-							<label className="crew-widget-form__field-label">배경 색상</label>
-							<ColorInput
-								value={form.bgColor}
-								onChange={v => set('bgColor', v)}
-							/>
-						</div>
-					</div>
-				</div>
-			</details>
-
-			{/* 표시 옵션 */}
-			<details className="crew-widget-form__section" open>
-				<summary className="crew-widget-form__section-title">표시 옵션</summary>
-				<div className="crew-widget-form__section-body">
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__checkbox-label">
-							<Checkbox checked={form.isShowAmount} onCheckedChange={v => set('isShowAmount', !!v)} />
-							후원 금액 표시
-						</label>
-					</div>
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__checkbox-label">
-							<Checkbox checked={form.isShowDonationCount} onCheckedChange={v => set('isShowDonationCount', !!v)} />
-							후원 건수 표시
-						</label>
-					</div>
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__checkbox-label">
-							<Checkbox checked={form.isShowContributionRate} onCheckedChange={v => set('isShowContributionRate', !!v)} />
-							기여율 표시
-						</label>
-					</div>
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__checkbox-label">
-							<Checkbox checked={form.isShowMemberIcon} onCheckedChange={v => set('isShowMemberIcon', !!v)} />
-							크루원 아이콘 표시
-						</label>
-					</div>
-					<div className="crew-widget-form__field">
-						<label className="crew-widget-form__checkbox-label">
-							<Checkbox checked={form.isActive} onCheckedChange={v => set('isActive', !!v)} />
-							활성화
-						</label>
-					</div>
-				</div>
-			</details>
-
-			{/* 제목 폰트 */}
-			<details className="crew-widget-form__section" open>
-				<summary className="crew-widget-form__section-title">제목 폰트</summary>
-				<div className="crew-widget-form__section-body">
-					{renderFontFields('title')}
-				</div>
-			</details>
-
-			{/* 순위별 폰트 */}
-			<details className="crew-widget-form__section" open>
-				<summary className="crew-widget-form__section-title">순위별 폰트</summary>
-				<div className="crew-widget-form__section-body">
-					{renderFontDetails('1위', 'rank1')}
-					{renderFontDetails('2위', 'rank2')}
-					{renderFontDetails('3위', 'rank3')}
-					{renderFontDetails('일반', 'row')}
-				</div>
-			</details>
-
-			{/* 버튼 */}
-			<div className="crew-widget-form__footer flex-1 w-full sm:justify-end gap-2">
-				<button type="button" className="crew-widget-form__btn flex-1 sm:flex-none" onClick={() => onCancel ? onCancel() : router.push('/studio/donation/crew/widget/list')}>취소</button>
-				<button type="button" className="crew-widget-form__btn crew-widget-form__btn--primary flex-1 sm:flex-none" onClick={handleSave} disabled={saving}>
-					{saving ? '저장 중...' : '저장'}
-				</button>
-			</div>
-		</main>
-	);
-}

+ 0 - 127
app/studio/donation/crew/widget/_components/CrewWidgetListPanel.tsx

@@ -1,127 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useRouter } from 'next/navigation';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import { useCrewWidgetConfigContext } from '../context';
-import { CREW_PERIODS, CREW_WIDGET_THEMES } from '../constants';
-import { formatDateTime } from '../types';
-import { Button } from '@/components/ui/button';
-import { Checkbox } from '@/components/ui/checkbox';
-
-export default function CrewWidgetListPanel()
-{
-	const router = useRouter();
-	const { channelID } = useStudioContext();
-	const { items, loading, fetchList } = useCrewWidgetConfigContext();
-	const [selected, setSelected] = useState<Set<number>>(new Set());
-
-	const toggleSelect = (id: number) => {
-		setSelected(prev => {
-			const next = new Set(prev);
-			if (next.has(id)) next.delete(id); else next.add(id);
-			return next;
-		});
-	};
-
-	const toggleAll = () => {
-		if (selected.size === items.length) {
-			setSelected(new Set());
-		} else {
-			setSelected(new Set(items.map(i => i.id)));
-		}
-	};
-
-	const handleBatchDelete = async () => {
-		if (selected.size === 0) {
-			return;
-		}
-
-		if (!confirm(`${selected.size}개 설정을 삭제하시겠습니까?`)) {
-			return;
-		}
-
-		for (const id of selected) {
-			try {
-				await fetchApi(`/api/studio/crew/widget/config/${id}/${channelID}`, { method: 'DELETE' });
-			} catch {}
-		}
-
-		setSelected(new Set());
-		fetchList();
-	};
-
-	const getPeriodLabel = (p: number) => CREW_PERIODS.find(x => x.value === p)?.label ?? '-';
-	const getThemeLabel = (t: number) => CREW_WIDGET_THEMES.find(x => x.value === t)?.label ?? '-';
-
-	if (loading) return <p className="studio-page__empty">준비 중...</p>;
-
-	return (
-		<div className="studio-page">
-			<div className="studio-page__header">
-				<h1 className="studio-page__title">크루 위젯 설정</h1>
-				<div className="studio-page__header-actions">
-					{selected.size > 0 && (
-						<Button variant="destructive" size="sm" onClick={handleBatchDelete}>
-							{selected.size}개 삭제
-						</Button>
-					)}
-					<Button size="sm" onClick={() => router.push('/studio/donation/crew/widget/add')}>
-						+ 추가
-					</Button>
-				</div>
-			</div>
-
-			<div className="studio-page__table-wrap">
-				<table className="studio-page__table">
-					<thead>
-						<tr>
-							<th>
-								<Checkbox checked={items.length > 0 && selected.size === items.length} onCheckedChange={toggleAll} />
-							</th>
-							<th>제목</th>
-							<th>기간</th>
-							<th>테마</th>
-							<th>최대 표시</th>
-							<th>활성</th>
-							<th>작업</th>
-						</tr>
-					</thead>
-					<tbody>
-						{items.length === 0 ? (
-							<tr><td colSpan={7} className="studio-page__empty">등록된 위젯 설정이 없습니다.</td></tr>
-						) : items.map(item => (
-							<tr key={item.id}>
-								<td>
-									<Checkbox checked={selected.has(item.id)} onCheckedChange={() => toggleSelect(item.id)} />
-								</td>
-								<td>{item.title}</td>
-								<td>
-									{getPeriodLabel(item.period)}
-									{item.period === 5 && item.startAt && item.endAt && (
-										<div className="text-xs text-muted-foreground">
-											{formatDateTime(item.startAt)} ~ {formatDateTime(item.endAt)}
-										</div>
-									)}
-								</td>
-								<td>{getThemeLabel(item.theme)}</td>
-								<td>{item.maxDisplayCount}명</td>
-								<td>
-									<span className={`studio-page__badge studio-page__badge--${item.isActive ? 'active' : 'inactive'}`}>
-										{item.isActive ? '활성' : '비활성'}
-									</span>
-								</td>
-								<td>
-									<Button variant="outline" size="sm" onClick={() => router.push(`/studio/donation/crew/widget/edit/${item.id}`)}>
-										수정
-									</Button>
-								</td>
-							</tr>
-						))}
-					</tbody>
-				</table>
-			</div>
-		</div>
-	);
-}

+ 0 - 225
app/studio/donation/crew/widget/_components/CrewWidgetPreviewPanel.tsx

@@ -1,225 +0,0 @@
-'use client';
-
-import { useState, type CSSProperties } 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, PREVIEW_DEFAULT_NAME
-} from '@/constants/donation';
-import { MOCK_CREW_RANKING } from '../constants';
-import type { FormState } from '../types';
-
-type Props = {
-	form: FormState;
-};
-
-type SimulatedDonation = {
-	name: string;
-	totalAmount: number;
-	donationCount: number;
-};
-
-const THEME_NAMES = ['basic', 'dark', 'minimal'];
-
-export default function CrewWidgetPreviewPanel({ form }: Props)
-{
-	const [previewName, setPreviewName] = useState(PREVIEW_DEFAULT_NAME);
-	const [previewAmount, setPreviewAmount] = useState(DONATION_AMOUNT_DEFAULT);
-	const [simulatedDonations, setSimulatedDonations] = useState<SimulatedDonation[]>([]);
-
-	// 시뮬레이션 데이터 있으면 contributionRate 자동 계산, 없으면 mock
-	const visibleItems = simulatedDonations.length > 0
-		? (() => {
-			const sorted = simulatedDonations.slice().sort((a, b) => b.totalAmount - a.totalAmount);
-			const total = sorted.reduce((sum, d) => sum + d.totalAmount, 0) || 1;
-			return sorted.slice(0, form.maxDisplayCount).map((d, i) => ({
-				rank: i + 1,
-				nickname: d.name,
-				totalAmount: d.totalAmount,
-				donationCount: d.donationCount,
-				contributionRate: (d.totalAmount / total) * 100
-			}));
-		})()
-		: MOCK_CREW_RANKING.slice(0, form.maxDisplayCount);
-
-	const themeName = THEME_NAMES[form.theme] ?? 'basic';
-
-	const getFontStyle = (rank: number): CSSProperties => {
-		if (rank === 1) {
-			return {
-				'--row-font-family': form.rank1FontFamily || 'inherit',
-				'--row-font-size': `${form.rank1FontSizePx}px`,
-				'--row-font-color': form.rank1FontColor
-			} as CSSProperties;
-		}
-		if (rank === 2) {
-			return {
-				'--row-font-family': form.rank2FontFamily || 'inherit',
-				'--row-font-size': `${form.rank2FontSizePx}px`,
-				'--row-font-color': form.rank2FontColor
-			} as CSSProperties;
-		}
-		if (rank === 3) {
-			return {
-				'--row-font-family': form.rank3FontFamily || 'inherit',
-				'--row-font-size': `${form.rank3FontSizePx}px`,
-				'--row-font-color': form.rank3FontColor
-			} as CSSProperties;
-		}
-		return {
-			'--row-font-family': form.rowFontFamily || 'inherit',
-			'--row-font-size': `${form.rowFontSizePx}px`,
-			'--row-font-color': form.rowFontColor
-		} as CSSProperties;
-	};
-
-	const getBadgeClass = (rank: number) => {
-		if (rank <= 3) {
-			return `crew-widget-preview__badge--${rank}`;
-		}
-		return 'crew-widget-preview__badge--default';
-	};
-
-	const bodyStyle = { '--preview-bg': form.bgColor } as CSSProperties;
-	const titleStyle = {
-		'--preview-title-font': form.titleFontFamily || 'inherit',
-		'--preview-title-size': `${form.titleFontSizePx}px`,
-		'--preview-title-color': form.titleFontColor
-	} as CSSProperties;
-
-	const handlePreview = () => {
-		const name = (previewName || PREVIEW_DEFAULT_NAME).trim();
-		const amount = previewAmount || DONATION_AMOUNT_DEFAULT;
-
-		setSimulatedDonations(prev => {
-			const existing = prev.find(d => d.name === name);
-			if (existing) {
-				return prev.map(d => d.name === name
-					? { ...d, totalAmount: d.totalAmount + amount, donationCount: d.donationCount + 1 }
-					: d
-				);
-			}
-			return [...prev, { name, totalAmount: amount, donationCount: 1 }];
-		});
-	};
-
-	const handleReset = () => {
-		setSimulatedDonations([]);
-	};
-
-	const totalSimulatedAmount = simulatedDonations.reduce((sum, d) => sum + d.totalAmount, 0);
-
-	return (
-		<aside className="crew-widget-preview">
-			<div className="crew-widget-preview__widget">
-				<div className="crew-widget-preview__widget-label">미리보기</div>
-				<div
-					className={`crew-widget-preview__widget-body crew-widget-preview__widget-body--${themeName}`}
-					style={bodyStyle}
-				>
-					{/* 위젯 제목 */}
-					<div className="crew-widget-preview__title" style={titleStyle}>
-						{form.title || '크루 순위'}
-					</div>
-
-					{/* 컬럼 헤더 */}
-					<div className="crew-widget-preview__columns">
-						<span className="crew-widget-preview__col-rank">순위</span>
-						{form.isShowMemberIcon && <span className="crew-widget-preview__col-icon" />}
-						<span className="crew-widget-preview__col-name">이름</span>
-						{form.isShowContributionRate && <span className="crew-widget-preview__col-rate">기여도</span>}
-						{form.isShowAmount && <span className="crew-widget-preview__col-amount">후원 점수</span>}
-						{form.isShowDonationCount && <span className="crew-widget-preview__col-count">건수</span>}
-					</div>
-
-					{/* 순위 리스트 */}
-					<div className="crew-widget-preview__list">
-						{visibleItems.map(item => {
-							const style = getFontStyle(item.rank);
-							return (
-								<div
-									key={item.rank}
-									className={`crew-widget-preview__item${item.rank <= 3 ? ` crew-widget-preview__item--${item.rank}` : ''}`}
-									style={style}
-								>
-									<span className={`crew-widget-preview__badge ${getBadgeClass(item.rank)}`}>
-										{item.rank}
-									</span>
-									{form.isShowMemberIcon && <span className="crew-widget-preview__member-icon" />}
-									<span className="crew-widget-preview__name">{item.nickname}</span>
-									{form.isShowContributionRate && (
-										<span className="crew-widget-preview__rate">{item.contributionRate.toFixed(1)}%</span>
-									)}
-									{form.isShowAmount && (
-										<span className="crew-widget-preview__amount">{item.totalAmount.toLocaleString()}원</span>
-									)}
-									{form.isShowDonationCount && (
-										<span className="crew-widget-preview__count">{item.donationCount}건</span>
-									)}
-								</div>
-							);
-						})}
-						{visibleItems.length === 0 && (
-							<div className="crew-widget-preview__empty">순위 데이터 없음</div>
-						)}
-					</div>
-				</div>
-			</div>
-
-			<fieldset className="crew-widget-preview__fieldset">
-				<legend className="crew-widget-preview__legend">미리보기</legend>
-				<div className="crew-widget-preview__preview-form">
-					<div className="crew-widget-preview__field">
-						<label htmlFor="crew-preview-amount" className="crew-widget-preview__field-label">후원 금액 (원)</label>
-						<input
-							id="crew-preview-amount"
-							type="number"
-							className="crew-widget-preview__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="crew-widget-preview__field">
-						<label htmlFor="crew-preview-name" className="crew-widget-preview__field-label">이름</label>
-						<input
-							id="crew-preview-name"
-							type="text"
-							className="crew-widget-preview__input"
-							maxLength={ALERT_NAME_MAX_LENGTH}
-							value={previewName}
-							onChange={e => setPreviewName(e.target.value)}
-							placeholder="후원자 이름"
-						/>
-					</div>
-					<div className="crew-widget-preview__preview-actions">
-						<button
-							type="button"
-							className="crew-widget-preview__btn crew-widget-preview__btn--primary crew-widget-preview__preview-btn"
-							onClick={handlePreview}
-						>
-							<FontAwesomeIcon icon={faPlay} />
-							미리보기
-						</button>
-						<button
-							type="button"
-							className="crew-widget-preview__btn"
-							onClick={handleReset}
-							disabled={simulatedDonations.length === 0}
-						>
-							초기화
-						</button>
-					</div>
-					{simulatedDonations.length > 0 && (
-						<p className="crew-widget-preview__field-hint">
-							후원자 {simulatedDonations.length}명 (총 {totalSimulatedAmount.toLocaleString()}원)
-						</p>
-					)}
-				</div>
-			</fieldset>
-		</aside>
-	);
-}

+ 0 - 30
app/studio/donation/crew/widget/add/page.tsx

@@ -1,30 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import Link from 'next/link';
-import { type FormState, createEmptyForm } from '../types';
-import CrewWidgetFormPanel from '../_components/CrewWidgetFormPanel';
-import CrewWidgetPreviewPanel from '../_components/CrewWidgetPreviewPanel';
-import { Separator } from '@/components/ui/separator';
-
-export default function CrewWidgetAddPage()
-{
-	const [form, setForm] = useState<FormState>(createEmptyForm());
-
-	return (
-		<>
-			<div className="studio-page__title-row">
-				<h1 className="studio-page__title">크루 위젯 추가</h1>
-				<Link href="/studio/donation/crew/widget/list" className="text-sm text-muted-foreground hover:text-foreground">< 목록으로</Link>
-			</div>
-			<div className="pt-4 pb-4">
-				<Separator orientation="horizontal" />
-			</div>
-			<div className="crew-widget-layout">
-				<CrewWidgetPreviewPanel form={form} />
-				<Separator orientation="vertical" />
-				<CrewWidgetFormPanel form={form} onFormChange={setForm} />
-			</div>
-		</>
-	);
-}

+ 0 - 35
app/studio/donation/crew/widget/constants.ts

@@ -1,35 +0,0 @@
-export const CREW_WIDGET_THEMES = [
-	{ value: 0, label: 'Basic' },
-	{ value: 1, label: 'Dark' },
-	{ value: 2, label: 'Minimal' }
-];
-
-export const CREW_PERIODS = [
-	{ value: 1, label: '일간' },
-	{ value: 2, label: '주간' },
-	{ value: 3, label: '월간' },
-	{ value: 4, label: '연간' },
-	{ value: 5, label: '사용자 지정' },
-	{ value: 6, label: '전체' }
-];
-
-export const FONT_FAMILIES = [
-	{ value: '', label: '기본 (시스템)' },
-	{ value: 'Pretendard', label: 'Pretendard' },
-	{ value: 'Noto Sans KR', label: 'Noto Sans KR' },
-	{ value: 'Nanum Gothic', label: '나눔고딕' },
-	{ value: 'Nanum Myeongjo', label: '나눔명조' },
-	{ value: 'Black Han Sans', label: '블랙한산스' },
-	{ value: 'Jua', label: '주아' },
-	{ value: 'Do Hyeon', label: '도현' },
-	{ value: 'Gaegu', label: '개구' },
-	{ value: 'Gothic A1', label: 'Gothic A1' }
-];
-
-export const MOCK_CREW_RANKING = [
-	{ rank: 1, nickname: '크루원A', totalAmount: 50000, donationCount: 10, contributionRate: 40.0 },
-	{ rank: 2, nickname: '크루원B', totalAmount: 30000, donationCount: 7, contributionRate: 24.0 },
-	{ rank: 3, nickname: '크루원C', totalAmount: 20000, donationCount: 5, contributionRate: 16.0 },
-	{ rank: 4, nickname: '크루원D', totalAmount: 15000, donationCount: 4, contributionRate: 12.0 },
-	{ rank: 5, nickname: '크루원E', totalAmount: 10000, donationCount: 3, contributionRate: 8.0 }
-];

+ 0 - 24
app/studio/donation/crew/widget/context.tsx

@@ -1,24 +0,0 @@
-'use client';
-
-import { createContext, useContext, type Dispatch, type SetStateAction } from 'react';
-import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
-
-type CrewWidgetConfigContextValue = {
-	items: CrewWidgetConfigItem[];
-	loading: boolean;
-	saving: boolean;
-	setSaving: Dispatch<SetStateAction<boolean>>;
-	fetchList: () => void;
-};
-
-export const CrewWidgetConfigContext = createContext<CrewWidgetConfigContextValue>({
-	items: [],
-	loading: true,
-	saving: false,
-	setSaving: () => {},
-	fetchList: () => {}
-});
-
-export function useCrewWidgetConfigContext() {
-	return useContext(CrewWidgetConfigContext);
-}

+ 0 - 44
app/studio/donation/crew/widget/edit/[id]/page.tsx

@@ -1,44 +0,0 @@
-'use client';
-
-import { useState } from 'react';
-import { useParams } from 'next/navigation';
-import Link from 'next/link';
-import { useCrewWidgetConfigContext } from '../../context';
-import { type FormState, createEmptyForm } from '../../types';
-import CrewWidgetFormPanel from '../../_components/CrewWidgetFormPanel';
-import CrewWidgetPreviewPanel from '../../_components/CrewWidgetPreviewPanel';
-import { Separator } from '@/components/ui/separator';
-
-export default function CrewWidgetEditPage()
-{
-	const { id } = useParams<{ id: string }>();
-	const { items, loading } = useCrewWidgetConfigContext();
-	const [form, setForm] = useState<FormState>(createEmptyForm());
-
-	if (loading) {
-		return <p className="studio-page__empty">준비 중...</p>;
-	}
-
-	const item = items.find(i => i.id === Number(id));
-
-	if (!item) {
-		return <p className="studio-page__empty">설정을 찾을 수 없습니다.</p>;
-	}
-
-	return (
-		<>
-			<div className="studio-page__title-row">
-				<h1 className="studio-page__title">크루 위젯 수정</h1>
-				<Link href="/studio/donation/crew/widget/list" className="text-sm text-muted-foreground hover:text-foreground">< 목록으로</Link>
-			</div>
-			<div className="pt-4 pb-4">
-				<Separator orientation="horizontal" />
-			</div>
-			<div className="crew-widget-layout">
-				<CrewWidgetPreviewPanel form={form} />
-				<Separator orientation="vertical" />
-				<CrewWidgetFormPanel editItem={item} form={form} onFormChange={setForm} />
-			</div>
-		</>
-	);
-}

+ 0 - 50
app/studio/donation/crew/widget/layout.tsx

@@ -1,50 +0,0 @@
-'use client';
-
-import './style.scss';
-import { useState, useEffect, useCallback, type ReactNode } from 'react';
-import { fetchApi } from '@/lib/utils/client';
-import { useStudioContext } from '@/app/studio/context';
-import type { CrewWidgetConfigResponse, CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
-import { CrewWidgetConfigContext } from './context';
-
-export default function CrewWidgetLayout({ children }: { children: ReactNode })
-{
-	const { channelID } = useStudioContext();
-	const [items, setItems] = useState<CrewWidgetConfigItem[]>([]);
-	const [loading, setLoading] = useState(true);
-	const [saving, setSaving] = useState(false);
-
-	const fetchList = useCallback(() => {
-		if (!channelID) {
-			setLoading(false);
-			return;
-		}
-
-		setLoading(true);
-
-		fetchApi<CrewWidgetConfigResponse>(`/api/studio/crew/widget/config/${channelID}`)
-			.then(res => setItems(res.data?.list ?? []))
-			.catch(() => {})
-			.finally(() => setLoading(false)
-		);
-
-	}, [channelID]);
-
-	useEffect(() => {
-		fetchList();
-	}, [fetchList]);
-
-	if (!channelID) {
-		return (
-			<div className="studio-page">
-				<p className="studio-page__empty">채널을 먼저 연동해 주세요.</p>
-			</div>
-		);
-	}
-
-	return (
-		<CrewWidgetConfigContext.Provider value={{ items, loading, saving, setSaving, fetchList }}>
-			{children}
-		</CrewWidgetConfigContext.Provider>
-	);
-}

+ 0 - 8
app/studio/donation/crew/widget/list/page.tsx

@@ -1,8 +0,0 @@
-'use client';
-
-import CrewWidgetListPanel from '../_components/CrewWidgetListPanel';
-
-export default function CrewWidgetListPage()
-{
-	return <CrewWidgetListPanel />;
-}

+ 0 - 15
app/studio/donation/crew/widget/page.tsx

@@ -1,15 +0,0 @@
-'use client';
-
-import { useRouter } from 'next/navigation';
-import { useEffect } from 'react';
-
-export default function CrewWidgetPage()
-{
-	const router = useRouter();
-
-	useEffect(() => {
-		router.replace('/studio/donation/crew/widget/list');
-	}, [router]);
-
-	return null;
-}

+ 0 - 581
app/studio/donation/crew/widget/style.scss

@@ -1,581 +0,0 @@
-// ── 편집 3-column 레이아웃 (rank-config__layout 패턴) ──
-.crew-widget-layout {
-	display: grid;
-	grid-template-columns: minmax(0, 500px) auto 1fr;
-	gap: 24px;
-	align-items: start;
-}
-
-// ── 좌측 패널 (미리보기, sticky) ──
-.crew-widget-preview {
-	position: sticky;
-	top: 16px;
-	display: flex;
-	flex-direction: column;
-	gap: 16px;
-	max-height: calc(100vh - 48px);
-	overflow-y: auto;
-
-	&__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-body {
-		padding: 16px;
-		min-height: 200px;
-		background: var(--preview-bg);
-
-		&--basic { background: var(--preview-bg, #1a1a2e); }
-		&--dark { background: var(--preview-bg, #0a0a14); }
-		&--minimal { background: var(--preview-bg, #222); }
-	}
-
-	// ── fieldset (미리보기 패널용) ────────────────────
-	&__fieldset {
-		border: 1px solid hsl(var(--border));
-		border-radius: var(--radius);
-		padding: 16px 20px 20px;
-		margin: 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;
-	}
-
-	&__field {
-		display: flex;
-		flex-direction: column;
-		gap: 6px;
-	}
-
-	&__field-label {
-		font-size: 0.875rem;
-		font-weight: 500;
-		color: hsl(var(--foreground));
-	}
-
-	&__field-hint {
-		margin: 0;
-		font-size: 0.8125rem;
-		color: hsl(var(--muted-foreground));
-	}
-
-	&__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));
-		}
-	}
-
-	&__preview-actions {
-		display: flex;
-		gap: 8px;
-		margin-top: 4px;
-	}
-
-	&__btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: 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;
-
-		&:hover:not(:disabled) {
-			background: hsl(var(--accent));
-			color: hsl(var(--accent-foreground));
-		}
-
-		&:disabled {
-			opacity: 0.5;
-			cursor: not-allowed;
-		}
-
-		&--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));
-			}
-		}
-	}
-
-	&__preview-btn {
-		flex: 1;
-	}
-
-	&__empty {
-		text-align: center;
-		padding: 24px 16px;
-		color: hsl(var(--muted-foreground));
-		font-size: 0.875rem;
-	}
-
-	// ── 위젯 제목 ──
-	&__title {
-		text-align: center;
-		font-weight: 700;
-		font-size: var(--preview-title-size, 18px);
-		font-family: var(--preview-title-font, inherit);
-		color: var(--preview-title-color, #fff);
-		margin-bottom: 12px;
-		text-shadow: 1px 1px 3px rgba(0, 0, 0, 0.4);
-	}
-
-	// ── 컬럼 헤더 ──
-	&__columns {
-		display: flex;
-		align-items: center;
-		gap: 8px;
-		padding: 6px 12px;
-		margin-bottom: 6px;
-		border-bottom: 1px solid rgba(255, 255, 255, 0.15);
-		font-size: 0.6875rem;
-		font-weight: 500;
-		color: rgba(255, 255, 255, 0.5);
-		letter-spacing: 0.03em;
-	}
-
-	&__col-rank { width: 28px; text-align: center; flex-shrink: 0; }
-	&__col-icon { width: 28px; flex-shrink: 0; }
-	&__col-name { flex: 1; min-width: 0; }
-	&__col-rate { width: 50px; text-align: right; flex-shrink: 0; }
-	&__col-amount { width: 70px; text-align: right; flex-shrink: 0; }
-	&__col-count { width: 40px; text-align: right; flex-shrink: 0; }
-
-	// ── 순위 리스트 ──
-	&__list {
-		display: flex;
-		flex-direction: column;
-		gap: 6px;
-	}
-
-	&__item {
-		display: flex;
-		align-items: center;
-		gap: 8px;
-		padding: 8px 12px;
-		border-radius: 8px;
-		background: rgba(255, 255, 255, 0.08);
-		font-family: var(--row-font-family, inherit);
-		font-size: var(--row-font-size, inherit);
-		color: var(--row-font-color, inherit);
-
-		&--1 { background: linear-gradient(135deg, rgba(255, 215, 0, 0.2), rgba(255, 215, 0, 0.05)); }
-		&--2 { background: linear-gradient(135deg, rgba(192, 192, 192, 0.15), rgba(192, 192, 192, 0.05)); }
-		&--3 { background: linear-gradient(135deg, rgba(205, 127, 50, 0.15), rgba(205, 127, 50, 0.05)); }
-	}
-
-	// ── 순위 배지 ──
-	&__badge {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		width: 28px;
-		height: 28px;
-		border-radius: 50%;
-		font-size: 14px;
-		font-weight: 800;
-		flex-shrink: 0;
-
-		&--1 { background: #FFD700; color: #000; }
-		&--2 { background: #C0C0C0; color: #000; }
-		&--3 { background: #CD7F32; color: #fff; }
-		&--default { background: #555; color: #fff; }
-	}
-
-	// ── 크루원 아이콘 ──
-	&__member-icon {
-		width: 28px;
-		height: 28px;
-		border-radius: 50%;
-		background: #444;
-		flex-shrink: 0;
-		border: 1px solid rgba(255, 255, 255, 0.15);
-	}
-
-	// ── 이름 ──
-	&__name {
-		flex: 1;
-		font-weight: 600;
-		color: #fff;
-		min-width: 0;
-		overflow: hidden;
-		text-overflow: ellipsis;
-		white-space: nowrap;
-	}
-
-	// ── 기여도 ──
-	&__rate {
-		width: 50px;
-		text-align: right;
-		flex-shrink: 0;
-		font-size: 0.85em;
-		color: #A0D2FF;
-		white-space: nowrap;
-	}
-
-	// ── 후원 금액 ──
-	&__amount {
-		width: 70px;
-		text-align: right;
-		flex-shrink: 0;
-		font-weight: 700;
-		color: #FF6B35;
-		font-size: 0.85em;
-		white-space: nowrap;
-	}
-
-	// ── 후원 건수 ──
-	&__count {
-		width: 40px;
-		text-align: right;
-		flex-shrink: 0;
-		font-size: 0.8em;
-		color: #aaa;
-		white-space: nowrap;
-	}
-}
-
-// ── 우측 패널 (폼) ──
-.crew-widget-form {
-	display: flex;
-	flex-direction: column;
-	gap: 0;
-
-	// ── 섹션 (details 기반, 좌측 제목 / 우측 입력 그리드) ──
-	&__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;
-		}
-
-		// details 마커 숨김
-		&::-webkit-details-marker {
-			display: none;
-		}
-	}
-
-	&__section-title {
-		font-size: 0.875rem;
-		font-weight: 600;
-		color: hsl(var(--foreground));
-		padding-top: 2px;
-		margin: 0;
-		cursor: pointer;
-		user-select: none;
-		list-style: none;
-
-		&::-webkit-details-marker {
-			display: none;
-		}
-
-		&::before {
-			content: '▸';
-			display: inline-block;
-			font-size: 0.7rem;
-			margin-right: 6px;
-			transition: transform 0.15s;
-		}
-
-		&:hover {
-			color: hsl(var(--foreground) / 0.7);
-		}
-	}
-
-	// 열린 상태: 화살표 회전
-	&__section[open] > .crew-widget-form__section-title::before {
-		transform: rotate(90deg);
-	}
-
-	&__section-body {
-		display: flex;
-		flex-direction: column;
-		gap: 17px;
-		max-width: 520px;
-	}
-
-	// ── 필드 행 (2-column 그리드) ──
-	&__row {
-		display: grid;
-		grid-template-columns: 1fr 1fr;
-		gap: 10px;
-
-		@media (max-width: 640px) {
-			grid-template-columns: 1fr;
-		}
-	}
-
-	// ── 개별 필드 ──
-	&__field {
-		display: flex;
-		flex-direction: column;
-		gap: 6px;
-	}
-
-	&__field-label {
-		font-size: 0.875rem;
-		font-weight: 500;
-		color: hsl(var(--foreground));
-	}
-
-	&__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));
-		}
-
-		&--color-text {
-			flex: 1;
-			min-width: 0;
-			font-family: monospace;
-		}
-	}
-
-	&__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;
-	}
-
-	// ── 색상 선택 필드 ──
-	&__color-field {
-		display: flex;
-		align-items: center;
-		gap: 8px;
-	}
-
-	&__color-picker {
-		width: 36px;
-		height: 36px;
-		border: 1px solid hsl(var(--border));
-		border-radius: calc(var(--radius) - 2px);
-		background: none;
-		cursor: pointer;
-		padding: 2px;
-		flex-shrink: 0;
-
-		&::-webkit-color-swatch-wrapper {
-			padding: 0;
-		}
-
-		&::-webkit-color-swatch {
-			border: none;
-			border-radius: 2px;
-		}
-
-		&::-moz-color-swatch {
-			border: none;
-			border-radius: 2px;
-		}
-	}
-
-	// ── 체크박스 행 ──
-	&__checkbox-label {
-		display: inline-flex;
-		align-items: center;
-		gap: 8px;
-		font-size: 0.875rem;
-		color: hsl(var(--foreground));
-		cursor: pointer;
-	}
-
-	// ── 순위별 폰트 접이식 (details 내부) ──
-	&__details {
-		border: 1px solid hsl(var(--border));
-		border-radius: calc(var(--radius) - 2px);
-		overflow: hidden;
-
-		&[open] > .crew-widget-form__details-summary {
-			border-bottom: 1px solid hsl(var(--border));
-		}
-	}
-
-	&__details-summary {
-		padding: 10px 14px;
-		font-size: 0.8125rem;
-		font-weight: 500;
-		color: hsl(var(--foreground));
-		cursor: pointer;
-		user-select: none;
-		list-style: none;
-		display: flex;
-		align-items: center;
-		gap: 6px;
-		transition: background-color 0.15s;
-
-		&:hover {
-			background: hsl(var(--accent) / 0.5);
-		}
-
-		&::before {
-			content: '▸';
-			font-size: 0.75rem;
-			transition: transform 0.15s;
-		}
-
-		&::-webkit-details-marker {
-			display: none;
-		}
-	}
-
-	&__details[open] > .crew-widget-form__details-summary::before {
-		transform: rotate(90deg);
-	}
-
-	&__details-body {
-		display: flex;
-		flex-direction: column;
-		gap: 14px;
-		padding: 14px;
-	}
-
-	// ── 버튼 ──
-	&__btn {
-		display: inline-flex;
-		align-items: center;
-		justify-content: center;
-		padding: 8px 20px;
-		border: 1px solid hsl(var(--border));
-		border-radius: calc(var(--radius) - 2px);
-		font-size: 0.875rem;
-		font-weight: 500;
-		cursor: pointer;
-		background: hsl(var(--background));
-		color: hsl(var(--foreground));
-		transition: background-color 0.15s;
-
-		&:hover:not(:disabled) {
-			background: hsl(var(--accent));
-		}
-
-		&:disabled {
-			opacity: 0.5;
-			cursor: not-allowed;
-		}
-
-		&--primary {
-			background: hsl(var(--primary));
-			color: hsl(var(--primary-foreground));
-			border-color: hsl(var(--primary));
-
-			&:hover:not(:disabled) {
-				background: hsl(var(--primary) / 0.9);
-			}
-		}
-	}
-
-	// ── 하단 버튼 ──
-	&__footer {
-		display: flex;
-		gap: 8px;
-		justify-content: center;
-		padding-top: 20px;
-		margin-top: 4px;
-	}
-}
-
-// ── 반응형 ──
-@media (max-width: 1380px) {
-	.crew-widget-form {
-		&__section {
-			grid-template-columns: 1fr;
-			gap: 5px;
-		}
-
-		&__section-title {
-			margin-bottom: 9px;
-		}
-	}
-}
-
-@media (max-width: 1120px) {
-	.crew-widget-layout {
-		grid-template-columns: minmax(130px, auto) auto 3fr;
-	}
-}
-
-@media (max-width: 768px) {
-	.crew-widget-layout {
-		grid-template-columns: 1fr;
-	}
-
-	.crew-widget-preview {
-		position: static;
-		max-height: none;
-	}
-
-	.crew-widget-form {
-		&__section {
-			grid-template-columns: 1fr;
-			gap: 8px;
-		}
-
-		&__section-title {
-			margin-bottom: 12px;
-		}
-
-		&__section-body {
-			max-width: none;
-		}
-
-		&__row {
-			grid-template-columns: 1fr;
-		}
-	}
-}

+ 0 - 51
app/studio/donation/crew/widget/types.ts

@@ -1,51 +0,0 @@
-import type { CrewWidgetConfigItem } from '@/types/response/crew/widgetConfig';
-import { formatDateInput } from '@/lib/utils/format';
-
-export type FormState = Omit<CrewWidgetConfigItem, 'id'>;
-
-export { formatDateInput as formatInput, parseDateInput as parseInput } from '@/lib/utils/format';
-
-export function createEmptyForm(): FormState {
-	return {
-		crewID: 0,
-		crewName: '',
-		title: '크루 순위',
-		theme: 0,
-		period: 3,
-		startAt: null,
-		endAt: null,
-		maxDisplayCount: 5,
-		isShowAmount: true,
-		isShowDonationCount: false,
-		isShowContributionRate: true,
-		isShowMemberIcon: true,
-		isActive: true,
-		bgColor: '#1A1A2E',
-		titleFontFamily: null,
-		titleFontSizePx: 18,
-		titleFontColor: '#FFFFFF',
-		rank1FontFamily: null,
-		rank1FontSizePx: 15,
-		rank1FontColor: '#FFD700',
-		rank2FontFamily: null,
-		rank2FontSizePx: 15,
-		rank2FontColor: '#C0C0C0',
-		rank3FontFamily: null,
-		rank3FontSizePx: 15,
-		rank3FontColor: '#CD7F32',
-		rowFontFamily: null,
-		rowFontSizePx: 14,
-		rowFontColor: '#FFFFFF'
-	};
-}
-
-export function formatDateTime(dateStr: string|null): string {
-	if (!dateStr) {
-		return '-';
-	}
-	const d = new Date(dateStr);
-	if (isNaN(d.getTime())) {
-		return '-';
-	}
-	return formatDateInput(d);
-}

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

@@ -1,297 +0,0 @@
-'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, PERIOD_OPTIONS, PERIOD_CUSTOM } 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-row">
-						<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">
-							<label htmlFor="goal-period" className="goal-config__field-label">기간</label>
-							<select
-								id="goal-period"
-								className="goal-config__select"
-								value={form.period}
-								onChange={e => onFormChange('period', parseInt(e.target.value))}
-							>
-								{PERIOD_OPTIONS.map(p => (
-									<option key={p.value} value={p.value}>{p.label}</option>
-								))}
-							</select>
-						</div>
-					</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">
-						<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>
-
-			{/* ── 기간 설정 (사용자 지정일 때만) ────────── */}
-			{form.period === PERIOD_CUSTOM && (
-				<section className="goal-config__section">
-					<h3 className="goal-config__section-title">기간 설정</h3>
-					<div className="goal-config__section-body">
-						<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 || null)}
-									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 || null)}
-									placeholder="2026.03.31 14:00"
-									maxLength={16}
-								/>
-							</div>
-						</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>
-	);
-}

Някои файлове не бяха показани, защото твърде много файлове са промени