Kaynağa Gözat

feat(reward): 캐시/보상·응원·레벨·아이템 + 수익인증 프론트

- 응원(Cheer/CheerModal), 레벨 보상(/account/level), 소모품 아이템 보관함·장착(items·useEquippedItems)
- types/reward·lib/api/reward·reward.scss, app/api/rewards 프록시
- P3 소모품 ItemKind 문자열→숫자 정합(types/account/items)
- 수익인증 글쓰기 이미지 필수 enforcement(boardMeta.requireImage + post/write)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 hafta önce
ebeveyn
işleme
e80f522f00

+ 280 - 63
app/(main)/(account)/items/page.tsx

@@ -1,135 +1,352 @@
 'use client';
 'use client';
 
 
+import '@/app/styles/reward.scss';
 import { useCallback, useEffect, useState } from 'react';
 import { useCallback, useEffect, useState } from 'react';
 import { fetchApi } from '@/lib/utils/client';
 import { fetchApi } from '@/lib/utils/client';
 import Loading from '@/app/component/Loading';
 import Loading from '@/app/component/Loading';
 import Pagination from '@/app/component/Pagination';
 import Pagination from '@/app/component/Pagination';
 import NavTabs from '../navTabs';
 import NavTabs from '../navTabs';
-import { ITEM_KIND_LABEL, ITEM_KIND_DESC, DIRECT_USE_KINDS } from '@/types/account/items';
-import type { MemberItemRow, MemberItemsResponse, UseItemResult } from '@/types/account/items';
+import { ItemKind, ITEM_KIND_LABEL, ITEM_KIND_DESC, DIRECT_USE_KINDS } from '@/types/account/items';
+import type { MemberItemRow as ConsumableItemRow, MemberItemsResponse as ConsumableItemsResponse, UseItemResult } from '@/types/account/items';
+import { EquippedBadge, NicknameText } from '@/components/community/EquippedBadges';
+import {
+	EQUIP_SLOT_ORDER,
+	EQUIP_SLOT_LABEL,
+	EQUIP_SLOT_DESC,
+	ITEM_KIND_LABEL as STATUS_KIND_LABEL,
+	parseEffectPayload
+} from '@/types/reward';
+import type {
+	MemberItemRow as StatusItemRow,
+	MemberItemsResponse as StatusItemsResponse,
+	EquipResponse,
+	EquipSlotValue,
+	EquippedItem
+} from '@/types/reward';
 
 
 const PER_PAGE = 20;
 const PER_PAGE = 20;
 
 
-export default function MemberItemsPage()
+// StatusItemRow → EquippedItem 변환 (배지/닉네임 미리보기용)
+function toEquippedItem(row: StatusItemRow): EquippedItem {
+	return {
+		slot: row.equipSlot!,
+		kind: row.kind,
+		productName: row.productName,
+		effectPayload: row.effectPayload,
+		thumbnail: row.thumbnail
+	};
+}
+
+function formatExpiry(expiresAt: string|null): string {
+	if (!expiresAt) {
+		return '기간 제한 없음';
+	}
+	const d = new Date(expiresAt);
+	return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} 만료`;
+}
+
+// ── 지위 아이템 (슬롯별 장착 관리) ──
+function StatusItemsSection()
 {
 {
-	const [items, setItems] = useState<MemberItemRow[]>([]);
-	const [total, setTotal] = useState(0);
-	const [page, setPage] = useState(1);
+	const [items, setItems] = useState<StatusItemRow[]>([]);
 	const [loading, setLoading] = useState(true);
 	const [loading, setLoading] = useState(true);
 	const [busyID, setBusyID] = useState<number|null>(null);
 	const [busyID, setBusyID] = useState<number|null>(null);
 
 
 	const load = useCallback(async () => {
 	const load = useCallback(async () => {
 		setLoading(true);
 		setLoading(true);
 
 
-		const res = await fetchApi<MemberItemsResponse>(`/api/member/me/items?page=${page}&perPage=${PER_PAGE}`, { silent: true });
+		const res = await fetchApi<StatusItemsResponse>('/api/members/me/items?page=1&perPage=100', { silent: true });
 
 
 		if (res.success && res.data) {
 		if (res.success && res.data) {
-			setItems(res.data.list);
-			setTotal(res.data.total);
+			// 지위 아이템(슬롯 보유)만 취급 — 소모품은 아래 별도 섹션
+			setItems(res.data.list.filter(c => c.isStatusItem && c.equipSlot != null));
 		}
 		}
 		else {
 		else {
 			setItems([]);
 			setItems([]);
-			setTotal(0);
 		}
 		}
 
 
 		setLoading(false);
 		setLoading(false);
-	}, [page]);
+	}, []);
 
 
 	useEffect(() => {
 	useEffect(() => {
 		load();
 		load();
 	}, [load]);
 	}, [load]);
 
 
-	const useItem = async (item: MemberItemRow) => {
-		if (item.kind === 'PostReset' && !confirm('신고·댓글이 없는 예전 게시글이 모두 삭제됩니다. 계속하시겠어요?')) {
-			return;
-		}
-
+	const toggleEquip = async (item: StatusItemRow) => {
 		setBusyID(item.id);
 		setBusyID(item.id);
 
 
-		const res = await fetchApi<UseItemResult>(`/api/member/me/items/${item.id}/use`, { method: 'POST', silent: true });
+		const action = item.isEquipped ? 'unequip' : 'equip';
+		const res = await fetchApi<EquipResponse>(`/api/members/me/items/${item.id}/${action}`, { method: 'POST', silent: true });
 
 
 		setBusyID(null);
 		setBusyID(null);
 
 
-		if (res.success && res.data) {
-			alert(res.data.message || '아이템을 사용했습니다.');
+		if (res.success) {
 			await load();
 			await load();
 		}
 		}
 		else {
 		else {
-			alert(res.message || '아이템 사용에 실패했습니다.');
+			alert(res.message || '장착 상태 변경에 실패했습니다.');
 		}
 		}
 	};
 	};
 
 
-	return (
-		<>
-			<NavTabs />
+	if (loading) {
+		return <Loading />;
+	}
 
 
-			<div className='mx-auto px-4 sm:px-8 pb-8'>
-				<h1 className='text-2xl font-bold mb-1'>아이템</h1>
-				<p className='text-sm text-neutral-500 mb-4'>
-					상점에서 구매한 소모품 아이템입니다. 채팅 밴 해제·예전 글 초기화·경험치 부스터는 여기서 바로 사용하고, 쪽지권·프로필 변경권은 해당 기능 이용 시 자동으로 사용됩니다.
-				</p>
+	if (items.length === 0) {
+		return (
+			<div className='text-center py-8 text-neutral-500 text-sm'>
+				보유한 지위 아이템이 없습니다. 상점에서 배지·닉네임 이펙트·댓글 이펙트를 구매할 수 있습니다.
+			</div>
+		);
+	}
 
 
-				<div className='text-sm text-neutral-500 mb-3'>미사용 {total.toLocaleString()}개</div>
+	// 슬롯별 그룹
+	const bySlot = new Map<EquipSlotValue, StatusItemRow[]>();
+	for (const slot of EQUIP_SLOT_ORDER) {
+		bySlot.set(slot, []);
+	}
+	for (const item of items) {
+		const arr = bySlot.get(item.equipSlot!) ?? [];
+		arr.push(item);
+		bySlot.set(item.equipSlot!, arr);
+	}
 
 
-				{loading ? (
-					<Loading />
-				) : items.length === 0 ? (
-					<div className='text-center py-20 text-neutral-500'>보유한 아이템이 없습니다.</div>
-				) : (
-					<>
-						<ul className='flex flex-col gap-3'>
-							{items.map((item) => {
-								const directUse = DIRECT_USE_KINDS.includes(item.kind);
+	return (
+		<div className='flex flex-col gap-6'>
+			{EQUIP_SLOT_ORDER.map((slot) => {
+				const slotItems = bySlot.get(slot) ?? [];
+				if (slotItems.length === 0) {
+					return null;
+				}
+
+				return (
+					<div key={slot}>
+						<div className='mb-2'>
+							<h3 className='font-semibold'>{EQUIP_SLOT_LABEL[slot]}</h3>
+							<p className='text-xs text-neutral-500'>{EQUIP_SLOT_DESC[slot]} 슬롯당 1개만 장착됩니다.</p>
+						</div>
+
+						<ul className='flex flex-col gap-2'>
+							{slotItems.map((item) => {
+								const equipped = toEquippedItem(item);
 
 
 								return (
 								return (
 									<li
 									<li
 										key={item.id}
 										key={item.id}
-										className='flex items-center gap-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4'
+										className={`flex items-center gap-4 rounded-lg border p-4 ${
+											item.isEquipped
+												? 'border-red-400 dark:border-red-600 bg-red-50/50 dark:bg-red-950/20'
+												: 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
+										}`}
 									>
 									>
 										<div className='size-14 shrink-0 rounded bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
 										<div className='size-14 shrink-0 rounded bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
 											{item.thumbnail ? (
 											{item.thumbnail ? (
 												// eslint-disable-next-line @next/next/no-img-element
 												// eslint-disable-next-line @next/next/no-img-element
 												<img src={item.thumbnail} alt={item.productName} className='w-full h-full object-scale-down' />
 												<img src={item.thumbnail} alt={item.productName} className='w-full h-full object-scale-down' />
 											) : (
 											) : (
-												<span className='text-2xl'>🎁</span>
+												<span className='text-2xl'>🏅</span>
 											)}
 											)}
 										</div>
 										</div>
 
 
 										<div className='flex-1 min-w-0'>
 										<div className='flex-1 min-w-0'>
-											<div className='font-semibold truncate'>
-												{ITEM_KIND_LABEL[item.kind] ?? item.productName}
-												{item.kind === 'ExpBooster' && item.durationDays > 0 && (
-													<span className='ml-1 text-xs text-neutral-500'>({item.durationDays}일)</span>
-												)}
+											<div className='font-semibold truncate'>{item.productName}</div>
+											<div className='text-xs text-neutral-500'>
+												{STATUS_KIND_LABEL[item.kind]} · {formatExpiry(item.expiresAt)}
+												{item.giftFromName && <span className='ml-1'>· 선물 (from {item.giftFromName})</span>}
+											</div>
+											{/* 미리보기 — 배지 / 닉네임 이펙트 실제 렌더 */}
+											<div className='mt-1 text-sm'>
+												{parseEffectPayload(item.effectPayload) || item.thumbnail ? (
+													<span className='inline-flex items-center gap-1'>
+														<NicknameText name='미리보기' items={[equipped]} />
+														<EquippedBadge items={[equipped]} />
+													</span>
+												) : null}
 											</div>
 											</div>
-											<div className='text-xs text-neutral-500 line-clamp-2'>{ITEM_KIND_DESC[item.kind]}</div>
 										</div>
 										</div>
 
 
 										<div className='shrink-0'>
 										<div className='shrink-0'>
-											{directUse ? (
-												<button
-													type='button'
-													onClick={() => useItem(item)}
-													disabled={busyID === item.id}
-													className='px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-50'
-												>
-													{busyID === item.id ? '처리 중...' : '사용하기'}
-												</button>
-											) : (
-												<span className='text-xs text-neutral-400'>기능 이용 시 사용</span>
-											)}
+											<button
+												type='button'
+												onClick={() => toggleEquip(item)}
+												disabled={busyID === item.id}
+												className={`px-4 py-2 rounded-md text-sm font-semibold disabled:opacity-50 ${
+													item.isEquipped
+														? 'border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800'
+														: 'bg-red-600 text-white hover:bg-red-700'
+												}`}
+											>
+												{busyID === item.id ? '처리 중...' : (item.isEquipped ? '해제' : '장착')}
+											</button>
 										</div>
 										</div>
 									</li>
 									</li>
 								);
 								);
 							})}
 							})}
 						</ul>
 						</ul>
+					</div>
+				);
+			})}
+		</div>
+	);
+}
+
+// ── 소모품 아이템 (기존 — 사용/자동소모) ──
+function ConsumableItemsSection()
+{
+	const [items, setItems] = useState<ConsumableItemRow[]>([]);
+	const [total, setTotal] = useState(0);
+	const [page, setPage] = useState(1);
+	const [loading, setLoading] = useState(true);
+	const [busyID, setBusyID] = useState<number|null>(null);
+
+	const load = useCallback(async () => {
+		setLoading(true);
+
+		const res = await fetchApi<ConsumableItemsResponse>(`/api/member/me/items?page=${page}&perPage=${PER_PAGE}`, { silent: true });
+
+		if (res.success && res.data) {
+			setItems(res.data.list);
+			setTotal(res.data.total);
+		}
+		else {
+			setItems([]);
+			setTotal(0);
+		}
+
+		setLoading(false);
+	}, [page]);
+
+	useEffect(() => {
+		load();
+	}, [load]);
+
+	const useItem = async (item: ConsumableItemRow) => {
+		if (item.kind === ItemKind.PostReset && !confirm('신고·댓글이 없는 예전 게시글이 모두 삭제됩니다. 계속하시겠어요?')) {
+			return;
+		}
+
+		setBusyID(item.id);
+
+		const res = await fetchApi<UseItemResult>(`/api/member/me/items/${item.id}/use`, { method: 'POST', silent: true });
+
+		setBusyID(null);
+
+		if (res.success && res.data) {
+			alert(res.data.message || '아이템을 사용했습니다.');
+			await load();
+		}
+		else {
+			alert(res.message || '아이템 사용에 실패했습니다.');
+		}
+	};
+
+	if (loading) {
+		return <Loading />;
+	}
+
+	if (items.length === 0) {
+		return <div className='text-center py-8 text-neutral-500 text-sm'>보유한 소모품이 없습니다.</div>;
+	}
+
+	return (
+		<>
+			<div className='text-sm text-neutral-500 mb-3'>미사용 {total.toLocaleString()}개</div>
+
+			<ul className='flex flex-col gap-3'>
+				{items.map((item) => {
+					const directUse = DIRECT_USE_KINDS.includes(item.kind);
+
+					return (
+						<li
+							key={item.id}
+							className='flex items-center gap-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4'
+						>
+							<div className='size-14 shrink-0 rounded bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
+								{item.thumbnail ? (
+									// eslint-disable-next-line @next/next/no-img-element
+									<img src={item.thumbnail} alt={item.productName} className='w-full h-full object-scale-down' />
+								) : (
+									<span className='text-2xl'>🎁</span>
+								)}
+							</div>
+
+							<div className='flex-1 min-w-0'>
+								<div className='font-semibold truncate'>
+									{ITEM_KIND_LABEL[item.kind] ?? item.productName}
+									{item.kind === ItemKind.ExpBooster && item.durationDays > 0 && (
+										<span className='ml-1 text-xs text-neutral-500'>({item.durationDays}일)</span>
+									)}
+								</div>
+								<div className='text-xs text-neutral-500 line-clamp-2'>{ITEM_KIND_DESC[item.kind]}</div>
+							</div>
+
+							<div className='shrink-0'>
+								{directUse ? (
+									<button
+										type='button'
+										onClick={() => useItem(item)}
+										disabled={busyID === item.id}
+										className='px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-50'
+									>
+										{busyID === item.id ? '처리 중...' : '사용하기'}
+									</button>
+								) : (
+									<span className='text-xs text-neutral-400'>기능 이용 시 사용</span>
+								)}
+							</div>
+						</li>
+					);
+				})}
+			</ul>
+
+			<hr className='my-4 border-neutral-200 dark:border-neutral-800' />
+
+			<Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
+		</>
+	);
+}
+
+export default function MemberItemsPage()
+{
+	const [tab, setTab] = useState<'status'|'consumable'>('status');
+
+	return (
+		<>
+			<NavTabs />
+
+			<div className='mx-auto px-4 sm:px-8 pb-8'>
+				<h1 className='text-2xl font-bold mb-1'>아이템</h1>
+				<p className='text-sm text-neutral-500 mb-4'>
+					지위 아이템(배지·닉네임/댓글 이펙트)은 슬롯별로 장착하고, 소모품은 사용하거나 해당 기능 이용 시 자동으로 사용됩니다.
+				</p>
 
 
-						<hr className='my-4 border-neutral-200 dark:border-neutral-800' />
+				<div className='mb-5 flex gap-1 border-b border-neutral-200 dark:border-neutral-800'>
+					<button
+						type='button'
+						onClick={() => setTab('status')}
+						className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
+							tab === 'status'
+								? 'border-red-600 text-red-600 dark:text-red-400 dark:border-red-400'
+								: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
+						}`}
+					>
+						지위 아이템
+					</button>
+					<button
+						type='button'
+						onClick={() => setTab('consumable')}
+						className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
+							tab === 'consumable'
+								? 'border-red-600 text-red-600 dark:text-red-400 dark:border-red-400'
+								: 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
+						}`}
+					>
+						소모품
+					</button>
+				</div>
 
 
-						<Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
-					</>
-				)}
+				{tab === 'status' ? <StatusItemsSection /> : <ConsumableItemsSection />}
 			</div>
 			</div>
 		</>
 		</>
 	);
 	);
-}
+}

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

@@ -0,0 +1,156 @@
+'use client';
+
+// D3 레벨/보상 위젯 — 현재 등급·Exp·다음 등급 진행 + 오늘 획득(액션별 캡 잔여).
+// GET /api/members/me/level + GET /api/rewards/me/today (둘 다 JWT).
+import '@/app/styles/reward.scss';
+import { useEffect, useState } from 'react';
+import Image from 'next/image';
+import { fetchApi } from '@/lib/utils/client';
+import Loading from '@/app/component/Loading';
+import NavTabs from '../navTabs';
+import type { MemberLevelResponse, RewardsTodayResponse } from '@/types/reward';
+
+function LevelCard({ level }: { level: MemberLevelResponse })
+{
+	// 다음 등급 요구치 대비 현재 Exp 비율 (0~100). 현재등급 요구치는 응답에 없으므로 요구치 대비로 근사.
+	const hasNext = level.nextGradeRequiredExp != null && level.expToNextGrade != null;
+	const remaining = level.expToNextGrade ?? 0;
+	const percent = hasNext && level.nextGradeRequiredExp! > 0
+		? Math.min(100, Math.max(0, Math.round((level.exp / level.nextGradeRequiredExp!) * 100)))
+		: 100;
+
+	return (
+		<div className='level-card'>
+			<div className='level-card__grade'>
+				{level.currentGradeImage && (
+					<Image src={level.currentGradeImage} alt='' width={40} height={40} className='level-card__grade-img' />
+				)}
+				<div>
+					<div className='level-card__grade-name' style={level.currentGradeTextColor ? { color: level.currentGradeTextColor } : undefined}>
+						{level.currentGradeName ?? '등급 없음'}
+					</div>
+					<div className='level-card__exp'>경험치 {level.exp.toLocaleString()} XP</div>
+				</div>
+			</div>
+
+			{hasNext ? (
+				<>
+					<div className='level-card__bar'>
+						<div className='level-card__bar-fill' style={{ width: `${percent}%` }} />
+					</div>
+					<div className='level-card__next'>
+						다음 등급 <strong>{level.nextGradeName}</strong>까지 {remaining.toLocaleString()} XP 남음
+					</div>
+				</>
+			) : (
+				<div className='level-card__next'>최고 등급에 도달했습니다.</div>
+			)}
+		</div>
+	);
+}
+
+function RewardsToday({ rewards }: { rewards: RewardsTodayResponse })
+{
+	return (
+		<div className='reward-today'>
+			<div className='reward-today__header'>
+				<span>오늘 획득 <span className='text-xs font-normal text-neutral-400'>({rewards.rewardDate})</span></span>
+				<span className='reward-today__total'>+{rewards.totalExp.toLocaleString()} XP · +{rewards.totalPoint.toLocaleString()} 토큰</span>
+			</div>
+
+			{rewards.actions.map((a) => {
+				// 캡 잔여: count/exp/point 중 정의된 것을 보여준다. 전부 null 이면 무제한.
+				const parts: string[] = [];
+				if (a.remainingCount != null) {
+					parts.push(`횟수 ${a.remainingCount.toLocaleString()}회`);
+				}
+				if (a.remainingExp != null) {
+					parts.push(`XP ${a.remainingExp.toLocaleString()}`);
+				}
+				if (a.remainingPoint != null) {
+					parts.push(`토큰 ${a.remainingPoint.toLocaleString()}`);
+				}
+
+				const isFull = (a.remainingCount === 0) || (a.remainingExp === 0) || (a.remainingPoint === 0);
+				const capText = parts.length > 0 ? `남은 캡: ${parts.join(' · ')}` : '캡 없음';
+
+				return (
+					<div className='reward-today__row' key={a.actionType}>
+						<span className='reward-today__name'>{a.actionName}</span>
+						<span className='reward-today__earned'>
+							{a.count.toLocaleString()}회 · +{a.expEarned.toLocaleString()} XP{a.pointEarned > 0 ? ` · +${a.pointEarned.toLocaleString()} 토큰` : ''}
+						</span>
+						<span className={`reward-today__cap${isFull ? ' reward-today__cap--full' : ''}`}>
+							{isFull ? '오늘 한도 도달' : capText}
+						</span>
+					</div>
+				);
+			})}
+		</div>
+	);
+}
+
+export default function LevelPage()
+{
+	const [level, setLevel] = useState<MemberLevelResponse|null>(null);
+	const [rewards, setRewards] = useState<RewardsTodayResponse|null>(null);
+	const [loading, setLoading] = useState(true);
+
+	useEffect(() => {
+		let cancelled = false;
+
+		(async () => {
+			setLoading(true);
+
+			const [levelRes, rewardsRes] = await Promise.all([
+				fetchApi<MemberLevelResponse>('/api/members/me/level', { silent: true }),
+				fetchApi<RewardsTodayResponse>('/api/rewards/me/today', { silent: true })
+			]);
+
+			if (cancelled) {
+				return;
+			}
+
+			if (levelRes.success && levelRes.data) {
+				setLevel(levelRes.data);
+			}
+
+			if (rewardsRes.success && rewardsRes.data) {
+				setRewards(rewardsRes.data);
+			}
+
+			setLoading(false);
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, []);
+
+	return (
+		<>
+			<NavTabs />
+
+			<div className='mx-auto px-4 sm:px-8 pb-8'>
+				<h1 className='text-2xl font-bold mb-1'>레벨 · 보상</h1>
+				<p className='text-sm text-neutral-500 mb-4'>
+					활동(글·댓글·추천·채팅·출석)으로 경험치(XP)와 토큰을 얻어 등급이 오릅니다. 액션별로 하루 획득 한도가 있습니다.
+				</p>
+
+				{loading ? (
+					<Loading />
+				) : (
+					<div className='flex flex-col gap-6'>
+						{level ? (
+							<LevelCard level={level} />
+						) : (
+							<div className='text-center py-8 text-neutral-500'>레벨 정보를 불러올 수 없습니다.</div>
+						)}
+
+						{rewards && <RewardsToday rewards={rewards} />}
+					</div>
+				)}
+			</div>
+		</>
+	);
+}

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

@@ -32,6 +32,7 @@ export default function NavTabs()
 			<div id="tabs" ref={dragScroll.ref} onMouseDown={dragScroll.onMouseDown} onMouseMove={dragScroll.onMouseMove} onMouseUp={dragScroll.onMouseUp} onMouseLeave={dragScroll.onMouseLeave}>
 			<div id="tabs" ref={dragScroll.ref} onMouseDown={dragScroll.onMouseDown} onMouseMove={dragScroll.onMouseMove} onMouseUp={dragScroll.onMouseUp} onMouseLeave={dragScroll.onMouseLeave}>
 				{[
 				{[
 					{ href: "/profile", label: "내 정보" },
 					{ href: "/profile", label: "내 정보" },
+					{ href: "/level", label: "레벨·보상" },
 					{ href: "/inventory", label: "보관함" },
 					{ href: "/inventory", label: "보관함" },
 					{ href: "/items", label: "아이템" },
 					{ href: "/items", label: "아이템" },
 					{ href: "/orders", label: "주문 목록" },
 					{ href: "/orders", label: "주문 목록" },

+ 117 - 0
app/(main)/(forum)/post/_component/Cheer.tsx

@@ -0,0 +1,117 @@
+'use client';
+
+// D3 응원(Cheer) 섹션 — 글 상세 하단. 누적 응원 배지 + 최근 목록 + 응원 버튼(+모달).
+// 목록 조회는 익명 허용(GET). 발신은 로그인 필요 + 본인 글에는 버튼 숨김.
+import { useCallback, useEffect, useState } from 'react';
+import { Heart } from 'lucide-react';
+import { fetchApi, getDateTime } from '@/lib/utils/client';
+import useAuth from '@/hooks/useAuth';
+import type { CheerListResponse } from '@/types/reward';
+import CheerModal from './CheerModal';
+
+type Props = {
+	postID: number;
+	authorMemberID: number|null;
+};
+
+export default function Cheer({ postID, authorMemberID }: Props)
+{
+	const { member, loginCheck } = useAuth();
+	const [data, setData] = useState<CheerListResponse|null>(null);
+	const [open, setOpen] = useState(false);
+	const [expanded, setExpanded] = useState(false);
+
+	const isOwner = member?.id != null && member.id === authorMemberID;
+
+	const load = useCallback(async () => {
+		const res = await fetchApi<CheerListResponse>(`/api/posts/${postID}/cheers?take=20`, { silent: true });
+
+		if (res.success && res.data) {
+			setData(res.data);
+		}
+	}, [postID]);
+
+	useEffect(() => {
+		load();
+	}, [load]);
+
+	const handleOpen = useCallback(() => {
+		if (!loginCheck()) {
+			return;
+		}
+		setOpen(true);
+	}, [loginCheck]);
+
+	const handleDone = useCallback(() => {
+		setOpen(false);
+		load();
+	}, [load]);
+
+	const total = data?.totalAmount ?? 0;
+	const count = data?.count ?? 0;
+	const recent = data?.recent ?? [];
+	const shown = expanded ? recent : recent.slice(0, 5);
+
+	return (
+		<section className='my-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4'>
+			<div className='flex flex-wrap items-center justify-between gap-3'>
+				<div className='flex items-center gap-2'>
+					<Heart className='size-5 text-red-500' aria-hidden />
+					<span className='font-semibold'>응원</span>
+					{count > 0 && (
+						<span className='inline-flex items-center gap-1 rounded-full bg-red-50 dark:bg-red-950/40 px-2.5 py-1 text-sm text-red-600 dark:text-red-400 font-semibold'>
+							{total.toLocaleString()}
+							<span className='text-xs font-normal text-red-400'>({count.toLocaleString()}건)</span>
+						</span>
+					)}
+				</div>
+
+				{!isOwner && (
+					<button
+						type='button'
+						onClick={handleOpen}
+						className='inline-flex items-center gap-1.5 px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700'
+					>
+						<Heart className='size-4' aria-hidden />
+						응원하기
+					</button>
+				)}
+			</div>
+
+			{isOwner && count === 0 && (
+				<p className='mt-2 text-xs text-neutral-400'>내 글에는 응원할 수 없습니다.</p>
+			)}
+
+			{recent.length > 0 && (
+				<ul className='mt-3 flex flex-col gap-2'>
+					{shown.map((c) => (
+						<li key={c.id} className='flex items-start gap-2 text-sm'>
+							<span className='inline-flex shrink-0 items-center rounded bg-red-50 dark:bg-red-950/40 px-1.5 py-0.5 text-xs font-semibold text-red-600 dark:text-red-400'>
+								{c.amount.toLocaleString()}
+							</span>
+							<div className='min-w-0 flex-1'>
+								<span className='font-semibold'>{c.fromNickname ?? '익명'}</span>
+								{c.message && <span className='ml-1.5 text-neutral-600 dark:text-neutral-300 break-words'>{c.message}</span>}
+							</div>
+							<span className='shrink-0 text-xs text-neutral-400'>{getDateTime(c.createdAt)}</span>
+						</li>
+					))}
+				</ul>
+			)}
+
+			{recent.length > 5 && (
+				<button
+					type='button'
+					onClick={() => setExpanded((prev) => !prev)}
+					className='mt-2 text-xs text-neutral-500 hover:underline'
+				>
+					{expanded ? '접기' : `최근 응원 더보기 (${recent.length})`}
+				</button>
+			)}
+
+			{open && (
+				<CheerModal postID={postID} onClose={() => setOpen(false)} onDone={handleDone} />
+			)}
+		</section>
+	);
+}

+ 181 - 0
app/(main)/(forum)/post/_component/CheerModal.tsx

@@ -0,0 +1,181 @@
+'use client';
+
+// D3 응원(Cheer) 모달 — 금액/메시지 입력 후 POST /api/posts/{postID}/cheers.
+// 규제 가드라인(d3 §⑧): 응원은 사후·자율·무대가. 글 열람 조건 아님.
+import { useCallback, useEffect, useState } from 'react';
+import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
+import { fetchApi } from '@/lib/utils/client';
+import type { WalletResponse, CheerSendResponse } from '@/types/reward';
+import { sumCash, sumToken } from '@/types/reward';
+
+const PRESET_AMOUNTS = [100, 500, 1000, 5000];
+const MAX_MESSAGE = 100;
+
+type Props = {
+	postID: number;
+	onClose: () => void;
+	onDone: () => void;
+};
+
+export default function CheerModal({ postID, onClose, onDone }: Props)
+{
+	const [amount, setAmount] = useState<string>('');
+	const [message, setMessage] = useState<string>('');
+	const [cash, setCash] = useState<number|null>(null);
+	const [token, setToken] = useState<number|null>(null);
+	const [busy, setBusy] = useState(false);
+	const [error, setError] = useState<string|null>(null);
+
+	// 지갑 가용 잔액 조회 (캐시/토큰 분리 표시 — 응원은 캐시→토큰 순 차감)
+	useEffect(() => {
+		let cancelled = false;
+
+		(async () => {
+			const res = await fetchApi<WalletResponse>('/api/wallet', { silent: true });
+			if (cancelled) {
+				return;
+			}
+
+			if (res.success && res.data) {
+				setCash(sumCash(res.data.balances));
+				setToken(sumToken(res.data.balances));
+			}
+			else {
+				setCash(null);
+				setToken(null);
+			}
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, []);
+
+	const available = (cash ?? 0) + (token ?? 0);
+
+	const submit = useCallback(async () => {
+		setError(null);
+
+		const numeric = Number(amount);
+
+		if (!Number.isInteger(numeric) || numeric <= 0) {
+			setError('1 이상의 정수 금액을 입력해 주세요.');
+			return;
+		}
+
+		if (cash !== null && token !== null && numeric > available) {
+			setError('가용 잔액이 부족합니다.');
+			return;
+		}
+
+		setBusy(true);
+
+		const res = await fetchApi<CheerSendResponse>(`/api/posts/${postID}/cheers`, {
+			method: 'POST',
+			body: { amount: numeric, message: message.trim() || null },
+			silent: true
+		});
+
+		setBusy(false);
+
+		if (res.success && res.data) {
+			onDone();
+		}
+		else {
+			setError(res.message || '응원에 실패했습니다.');
+		}
+	}, [amount, message, cash, token, available, postID, onDone]);
+
+	return (
+		<Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
+			<DialogContent>
+				<DialogHeader>
+					<DialogTitle>응원 보내기</DialogTitle>
+				</DialogHeader>
+
+				<div className='flex flex-col gap-4'>
+					<div className='rounded-md bg-neutral-100 dark:bg-neutral-800 px-3 py-2 text-sm'>
+						<div className='flex justify-between'>
+							<span className='text-neutral-500'>가용 잔액</span>
+							<strong>{cash === null ? '—' : available.toLocaleString()}</strong>
+						</div>
+						<div className='mt-1 flex justify-between text-xs text-neutral-500'>
+							<span>캐시 {cash === null ? '—' : cash.toLocaleString()}</span>
+							<span>토큰 {token === null ? '—' : token.toLocaleString()}</span>
+						</div>
+					</div>
+
+					<div className='flex flex-wrap gap-2'>
+						{PRESET_AMOUNTS.map((preset) => (
+							<button
+								key={preset}
+								type='button'
+								onClick={() => setAmount(String(preset))}
+								className='px-3 py-1.5 rounded-md border border-neutral-300 dark:border-neutral-700 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800'
+							>
+								+{preset.toLocaleString()}
+							</button>
+						))}
+					</div>
+
+					<div className='flex flex-col gap-1'>
+						<label className='text-sm font-semibold' htmlFor='cheer-amount'>응원 금액</label>
+						<input
+							id='cheer-amount'
+							type='number'
+							min={1}
+							step={1}
+							inputMode='numeric'
+							value={amount}
+							onChange={(e) => setAmount(e.target.value)}
+							placeholder='금액 입력'
+							className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900'
+							autoFocus
+						/>
+					</div>
+
+					<div className='flex flex-col gap-1'>
+						<label className='text-sm font-semibold' htmlFor='cheer-message'>
+							응원 한마디 <span className='text-xs font-normal text-neutral-400'>(선택)</span>
+						</label>
+						<textarea
+							id='cheer-message'
+							value={message}
+							onChange={(e) => setMessage(e.target.value.slice(0, MAX_MESSAGE))}
+							rows={2}
+							maxLength={MAX_MESSAGE}
+							placeholder='따뜻한 한마디를 남겨보세요'
+							className='border border-neutral-300 dark:border-neutral-700 rounded px-3 py-2 text-sm bg-white dark:bg-neutral-900 resize-none'
+						/>
+						<div className='text-right text-xs text-neutral-400'>{message.length}/{MAX_MESSAGE}</div>
+					</div>
+
+					<p className='text-xs text-neutral-500'>
+						* 응원은 자발적 후원이며 글 열람 조건이 아닙니다. 캐시 → 토큰 순으로 차감되며 일부는 플랫폼 수수료로 사용됩니다.
+					</p>
+
+					{error && <p className='text-sm text-red-600' role='alert'>{error}</p>}
+
+					<div className='flex justify-end gap-2'>
+						<button
+							type='button'
+							onClick={onClose}
+							disabled={busy}
+							className='px-4 py-2 rounded-md border border-neutral-300 dark:border-neutral-700 text-sm hover:bg-neutral-100 dark:hover:bg-neutral-800 disabled:opacity-50'
+						>
+							취소
+						</button>
+						<button
+							type='button'
+							onClick={submit}
+							disabled={busy}
+							className='px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-50'
+						>
+							{busy ? '전송 중...' : '응원하기'}
+						</button>
+					</div>
+				</div>
+			</DialogContent>
+		</Dialog>
+	);
+}

+ 7 - 0
app/(main)/(forum)/post/write/style.scss

@@ -74,6 +74,13 @@
 				}
 				}
 			}
 			}
 
 
+			// 이미지 필수 안내
+			.write-image-required {
+				margin-top: 0.5rem;
+				font-size: 0.8125rem;
+				color: hsl(0, 72%, 51%);
+			}
+
 			// 태그
 			// 태그
 			&#postTag {
 			&#postTag {
 				margin-bottom: 0;
 				margin-bottom: 0;

+ 15 - 0
app/(main)/(forum)/post/write/view.tsx

@@ -208,6 +208,16 @@ export default function View({ _boardList, _board }: Props)
 		if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
 		if (board.boardMeta.write.allowTag && tags.length > board.boardMeta.write.tagLimit) {
 			throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
 			throw new Error(`태그는 ${board.boardMeta.write.tagLimit}개 이내로 작성해주세요.`);
 		}
 		}
+
+		// 이미지 필수 게시판(수익인증 등) — 첨부 이미지 또는 본문 임베드 이미지 중 하나라도 있어야 함 (백엔드 Post.ImageRequired 사전 차단)
+		if (board.boardMeta.write.requireImage) {
+			const attached = editorRef.current?.getImageStore()?.some(i => i.image?.size > 0) ?? false;
+			const embedded = /<img|data:image\//i.test(content);
+
+			if (!attached && !embedded) {
+				throw new Error('이미지를 1건 이상 첨부해야 합니다.');
+			}
+		}
 	}, [boardCode, board, boardPrefix, subject, content, tags]);
 	}, [boardCode, board, boardPrefix, subject, content, tags]);
 
 
 	// 게시글 등록 처리
 	// 게시글 등록 처리
@@ -402,6 +412,11 @@ export default function View({ _boardList, _board }: Props)
 					) : (
 					) : (
 						<textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
 						<textarea name='content' ref={contentRef} value={content} onChange={handleChange} placeholder='내용을 입력해주세요.' maxLength={PostConst.MaxAllowedContentLength}></textarea>
 					)}
 					)}
+
+					{/* 이미지 필수 게시판 안내 */}
+					{board?.boardMeta.write.requireImage && (
+						<p className='write-image-required'>* 이 게시판은 이미지를 1건 이상 첨부해야 등록할 수 있습니다.</p>
+					)}
 				</section>
 				</section>
 
 
 				{/* 태그 */}
 				{/* 태그 */}

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

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

+ 182 - 0
app/styles/reward.scss

@@ -0,0 +1,182 @@
+// ─────────────────────────────────────────────────────────────
+// D3 보상/응원/지위아이템 — 장착 배지 · 닉네임 이펙트 · 레벨 진행바 · 상점 미리보기
+// D6 토큰 변수만 사용. 등락엔 --az-accent 미사용(D6 원칙).
+// ─────────────────────────────────────────────────────────────
+
+// ── 장착 배지 (닉네임 옆) ──
+.equipped-badge {
+	display: inline-flex;
+	align-items: center;
+	gap: 3px;
+	margin-left: var(--sp-2);
+	padding: 1px 7px;
+	border-radius: 999px;
+	background: var(--bg-subtle);
+	border: 1px solid var(--border-default);
+	font-size: var(--fs-2xs);
+	font-weight: 600;
+	line-height: 1.5;
+	white-space: nowrap;
+	vertical-align: middle;
+
+	&__icon {
+		width: 12px;
+		height: 12px;
+		object-fit: contain;
+	}
+
+	&__emoji {
+		font-size: var(--fs-xs);
+		line-height: 1;
+	}
+
+	&__label {
+		max-width: 8em;
+		overflow: hidden;
+		text-overflow: ellipsis;
+	}
+}
+
+// ── 닉네임 이펙트 ──
+.nickname-effect {
+	font-weight: 700;
+
+	// EffectPayload.animation 키에 대응하는 프리셋 (없으면 색상만)
+	&--glow {
+		text-shadow: 0 0 6px currentColor;
+	}
+
+	&--pulse {
+		animation: nickname-pulse 1.6s ease-in-out infinite;
+	}
+
+	&--gradient {
+		background: linear-gradient(90deg, var(--az-accent), var(--price-up));
+		-webkit-background-clip: text;
+		background-clip: text;
+		color: transparent;
+	}
+}
+
+@keyframes nickname-pulse {
+	0%, 100% { opacity: 1; }
+	50% { opacity: 0.55; }
+}
+
+// 접근성 — 모션 최소화 선호 시 애니메이션 정지
+@media (prefers-reduced-motion: reduce) {
+	.nickname-effect--pulse {
+		animation: none;
+	}
+}
+
+// ── 레벨/보상 위젯 ──
+.level-card {
+	display: flex;
+	flex-direction: column;
+	gap: var(--sp-5);
+	padding: var(--sp-6);
+	border: 1px solid var(--border-default);
+	border-radius: var(--radius);
+	background: var(--bg-elevated);
+
+	&__grade {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-4);
+	}
+
+	&__grade-img {
+		width: 40px;
+		height: 40px;
+		object-fit: contain;
+	}
+
+	&__grade-name {
+		font-size: var(--fs-xl);
+		font-weight: 800;
+	}
+
+	&__exp {
+		font-size: var(--fs-sm);
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__bar {
+		position: relative;
+		height: 12px;
+		border-radius: 999px;
+		background: var(--bg-subtle);
+		overflow: hidden;
+	}
+
+	&__bar-fill {
+		position: absolute;
+		inset: 0 auto 0 0;
+		background: var(--az-accent);
+		border-radius: 999px;
+		transition: width .3s ease;
+	}
+
+	&__next {
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+		font-variant-numeric: tabular-nums;
+	}
+}
+
+// ── 오늘 보상 캡 표 ──
+.reward-today {
+	border: 1px solid var(--border-default);
+	border-radius: var(--radius);
+	overflow: hidden;
+
+	&__header {
+		display: flex;
+		justify-content: space-between;
+		gap: var(--sp-4);
+		padding: var(--sp-5) var(--sp-6);
+		background: var(--bg-elevated);
+		border-bottom: 1px solid var(--border-default);
+		font-weight: 700;
+	}
+
+	&__total {
+		font-variant-numeric: tabular-nums;
+		color: var(--az-accent);
+	}
+
+	&__row {
+		display: grid;
+		grid-template-columns: 1fr auto auto;
+		gap: var(--sp-4);
+		align-items: center;
+		padding: var(--sp-4) var(--sp-6);
+		border-bottom: 1px solid var(--border-light);
+		font-size: var(--fs-sm);
+
+		&:last-child {
+			border-bottom: 0;
+		}
+	}
+
+	&__name {
+		font-weight: 600;
+	}
+
+	&__earned {
+		font-variant-numeric: tabular-nums;
+		color: var(--text-secondary);
+	}
+
+	&__cap {
+		font-variant-numeric: tabular-nums;
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__cap--full {
+		color: var(--price-down);
+	}
+}

+ 51 - 0
hooks/useEquippedItems.ts

@@ -0,0 +1,51 @@
+'use client';
+
+// D3 장착 지위 아이템 배치 조회 훅 — 여러 작성자 ID 를 모아 한 번에 GET /api/members/equipped-items?ids=.
+// 댓글 목록/프로필에서 작성자별 배지·닉네임 이펙트 렌더용. 익명 허용.
+import { useEffect, useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import type { EquippedItem, EquippedItemsResponse } from '@/types/reward';
+
+type EquippedMap = Record<number, EquippedItem[]>;
+
+export default function useEquippedItems(memberIDs: (number|null|undefined)[]): EquippedMap
+{
+	const [map, setMap] = useState<EquippedMap>({});
+
+	// 유효 ID 만 정렬·중복 제거해 안정적인 key 생성 (불필요한 재조회 방지)
+	const ids = Array.from(new Set(memberIDs.filter((id): id is number => typeof id === 'number' && id > 0))).sort((a, b) => a - b);
+	const key = ids.join(',');
+
+	useEffect(() => {
+		if (ids.length === 0) {
+			setMap({});
+			return;
+		}
+
+		let cancelled = false;
+
+		(async () => {
+			const res = await fetchApi<EquippedItemsResponse>(`/api/members/equipped-items?ids=${key}`, { silent: true });
+			if (cancelled) {
+				return;
+			}
+
+			if (res.success && res.data) {
+				const next: EquippedMap = {};
+				for (const m of res.data.members) {
+					next[m.memberID] = m.items;
+				}
+				setMap(next);
+			}
+			else {
+				setMap({});
+			}
+		})();
+
+		return () => {
+			cancelled = true;
+		};
+	}, [key]); // eslint-disable-line react-hooks/exhaustive-deps
+
+	return map;
+}

+ 71 - 0
lib/api/reward.ts

@@ -0,0 +1,71 @@
+'use server';
+
+// D3 보상/레벨/응원/지위아이템 서버 사이드 API (SSR / Server Action)
+// Backend Web.Api/Endpoints/{Rewards/Today, Member/Level, Member/*, Forum/PostCheer/*}.cs 대응.
+import { ResultDto } from '@/types/response/common';
+import {
+	MemberLevelResponse,
+	RewardsTodayResponse,
+	CheerListResponse,
+	MemberItemsResponse,
+	EquippedItemsResponse
+} from '@/types/reward';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 내 레벨 요약 (JWT) — GET /api/members/me/level
+export async function fetchMyLevel(): Promise<ResultDto<MemberLevelResponse>>
+{
+	return await fetchJson<MemberLevelResponse>('/api/members/me/level', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 오늘 획득 보상 + 캡 잔여 (JWT) — GET /api/rewards/me/today
+export async function fetchRewardsToday(): Promise<ResultDto<RewardsTodayResponse>>
+{
+	return await fetchJson<RewardsTodayResponse>('/api/rewards/me/today', {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 글 응원 합계 + 최근 N건 (익명) — GET /api/posts/{postID}/cheers?take=
+export async function fetchPostCheers(postID: number, take = 20): Promise<ResultDto<CheerListResponse>>
+{
+	return await fetchJson<CheerListResponse>(`/api/posts/${postID}/cheers?take=${take}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 내 지위 아이템 보관함 (JWT) — GET /api/members/me/items
+export async function fetchMyStatusItems(params: { includeExpired?: boolean; page?: number; perPage?: number } = {}): Promise<ResultDto<MemberItemsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	queryParams.set('perPage', String(params.perPage ?? 50));
+
+	if (params.includeExpired) {
+		queryParams.set('includeExpired', 'true');
+	}
+
+	return await fetchJson<MemberItemsResponse>(`/api/members/me/items?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 장착 지위 아이템 배치 조회 (익명) — GET /api/members/equipped-items?ids=1,2,3
+export async function fetchEquippedItems(memberIDs: number[]): Promise<ResultDto<EquippedItemsResponse>>
+{
+	const ids = memberIDs.filter(id => id > 0).join(',');
+
+	return await fetchJson<EquippedItemsResponse>(`/api/members/equipped-items?ids=${ids}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}

+ 22 - 30
types/account/items.ts

@@ -1,19 +1,20 @@
-// 상점 소모품 아이템 (백엔드 ItemKind enum — JSON 은 문자열로 직렬화)
-export type ItemKind =
-	'ChatBanLift'
-	| 'PostReset'
-	| 'NotePass'
-	| 'ExpBooster'
-	| 'ProfileImageChange'
-	| 'TaglineChange'
-	| 'NicknameChange';
+// 상점 소모품 아이템 (백엔드 ItemKind enum)
+// ⚠️ Web.Api Program.cs 에 JsonStringEnumConverter 미등록 → enum 은 전부 숫자로 직렬화됨.
+//    ItemKind 숫자 코드·라벨의 단일 소스는 types/reward.ts (D3). 여기서는 소모품 전용 설명/사용가능 종류만 추가 정의.
+import { ItemKind, ITEM_KIND_LABEL } from '@/types/reward';
+import type { ItemKindValue } from '@/types/reward';
 
 
+export { ItemKind, ITEM_KIND_LABEL };
+export type { ItemKindValue };
+
+// 소모품 보관함 행 (GET /api/member/me/items — Member/Items/List/Response.Row)
+// kind 는 숫자(ItemKindValue). 이 화면은 소모품(5~11)만 다룬다.
 export interface MemberItemRow {
 export interface MemberItemRow {
 	id: number;
 	id: number;
 	productID: number;
 	productID: number;
 	productName: string;
 	productName: string;
 	thumbnail: string|null;
 	thumbnail: string|null;
-	kind: ItemKind;
+	kind: ItemKindValue;
 	durationDays: number;
 	durationDays: number;
 	acquiredAt: string;
 	acquiredAt: string;
 	usedAt: string|null;
 	usedAt: string|null;
@@ -25,29 +26,20 @@ export interface MemberItemsResponse {
 }
 }
 
 
 export interface UseItemResult {
 export interface UseItemResult {
-	kind: ItemKind;
+	kind: ItemKindValue;
 	message: string;
 	message: string;
 }
 }
 
 
-export const ITEM_KIND_LABEL: Record<ItemKind, string> = {
-	ChatBanLift: '채팅 밴 해제권',
-	PostReset: '예전 글 초기화권',
-	NotePass: '쪽지권',
-	ExpBooster: '경험치 2배 모래시계',
-	ProfileImageChange: '프로필 이미지 변경권',
-	TaglineChange: '오늘의 한마디 변경권',
-	NicknameChange: '닉네임 변경권'
-};
-
-export const ITEM_KIND_DESC: Record<ItemKind, string> = {
-	ChatBanLift: '현재 적용 중인 채팅 제재를 즉시 해제합니다.',
-	PostReset: '신고·댓글이 없는 예전 게시글을 일괄 삭제합니다.',
-	NotePass: '쪽지 보내기에서 일일 한도를 넘겨 보내거나 무작위 회원에게 보낼 때 자동으로 사용됩니다.',
-	ExpBooster: '사용 후 기간 동안 획득 경험치가 2배가 됩니다.',
-	ProfileImageChange: '변경 주기 제한 없이 프로필 이미지를 즉시 변경할 때 자동으로 사용됩니다.',
-	TaglineChange: '변경 주기 제한 없이 오늘의 한마디를 즉시 변경할 때 자동으로 사용됩니다.',
-	NicknameChange: '변경 주기 제한 없이 닉네임을 즉시 변경할 때 자동으로 사용됩니다.'
+// 소모품 종류별 설명 (소모품 5~11 만)
+export const ITEM_KIND_DESC: Partial<Record<ItemKindValue, string>> = {
+	[ItemKind.ChatBanLift]: '현재 적용 중인 채팅 제재를 즉시 해제합니다.',
+	[ItemKind.PostReset]: '신고·댓글이 없는 예전 게시글을 일괄 삭제합니다.',
+	[ItemKind.NotePass]: '쪽지 보내기에서 일일 한도를 넘겨 보내거나 무작위 회원에게 보낼 때 자동으로 사용됩니다.',
+	[ItemKind.ExpBooster]: '사용 후 기간 동안 획득 경험치가 2배가 됩니다.',
+	[ItemKind.ProfileImageChange]: '변경 주기 제한 없이 프로필 이미지를 즉시 변경할 때 자동으로 사용됩니다.',
+	[ItemKind.TaglineChange]: '변경 주기 제한 없이 오늘의 한마디를 즉시 변경할 때 자동으로 사용됩니다.',
+	[ItemKind.NicknameChange]: '변경 주기 제한 없이 닉네임을 즉시 변경할 때 자동으로 사용됩니다.'
 };
 };
 
 
 // 보관함에서 직접 "사용하기" 가능한 종류 (나머지는 해당 기능 이용 시 자동 소모)
 // 보관함에서 직접 "사용하기" 가능한 종류 (나머지는 해당 기능 이용 시 자동 소모)
-export const DIRECT_USE_KINDS: ItemKind[] = ['ChatBanLift', 'PostReset', 'ExpBooster'];
+export const DIRECT_USE_KINDS: ItemKindValue[] = [ItemKind.ChatBanLift, ItemKind.PostReset, ItemKind.ExpBooster];

+ 1 - 0
types/forum/boardMeta.ts

@@ -144,6 +144,7 @@ export interface BoardWriteMeta {
 	allowTag: boolean;
 	allowTag: boolean;
 	tagLimit: number;
 	tagLimit: number;
 	allowImage: boolean;
 	allowImage: boolean;
+	requireImage: boolean;
 	imageUploadLimit: number;
 	imageUploadLimit: number;
 	imageUploadMaxSize: number;
 	imageUploadMaxSize: number;
 	allowMedia: boolean;
 	allowMedia: boolean;

+ 245 - 0
types/reward.ts

@@ -0,0 +1,245 @@
+// D3 재화/보상/레벨/응원/지위아이템 도메인 타입
+// Backend Application/Features/Api/{Rewards/GetToday, Member/GetLevel, Member/Items/*, Forum/PostCheer/*}/Response.cs 대응.
+// ⚠️ Web.Api Program.cs 에 JsonStringEnumConverter 미등록 → enum 은 전부 숫자로 직렬화됨
+//    (ItemKind·EquipSlot 은 정수 코드로 옴 — types/community.ts 와 동일 convention).
+//    라벨 맵은 이 파일 하단에 한 곳으로 모은다 (지시 6번 — ItemKind/EquipSlot 라벨 단일 소스).
+
+// ── 재화 (WalletBalanceType) — UI 이원화: 캐시 / 토큰 (d0 §2.2, Frontend/CLAUDE.md 용어 규칙) ──
+// 캐시 = PgCharged + Deposit + Adjusted (충전 재화, 상점 구매 전용)
+// 토큰 = Reward + Airdrop (활동 적립 재화, 응원·모의투자 전용)
+export const CASH_BALANCE_TYPES = ['PgCharged', 'Deposit', 'Adjusted'] as const;
+export const TOKEN_BALANCE_TYPES = ['Reward', 'Airdrop'] as const;
+
+// GET /api/wallet → { balances: [{ type, amount }] }
+export interface WalletBalanceRow {
+	type: string;
+	amount: number;
+}
+
+export interface WalletResponse {
+	id: number;
+	memberID: number;
+	balance: number;
+	balances: WalletBalanceRow[];
+}
+
+// 캐시/토큰 합산 헬퍼 — balances 배열에서 파티션별 합계
+export function sumCash(balances: WalletBalanceRow[]|undefined|null): number {
+	if (!balances) {
+		return 0;
+	}
+
+	return balances
+		.filter(c => (CASH_BALANCE_TYPES as readonly string[]).includes(c.type))
+		.reduce((acc, c) => acc + c.amount, 0);
+}
+
+export function sumToken(balances: WalletBalanceRow[]|undefined|null): number {
+	if (!balances) {
+		return 0;
+	}
+
+	return balances
+		.filter(c => (TOKEN_BALANCE_TYPES as readonly string[]).includes(c.type))
+		.reduce((acc, c) => acc + c.amount, 0);
+}
+
+// 응원 가용액 = 캐시 + 토큰 (Backend Wallet.GetCheerAvailable 대응 — 캐시→토큰 순 차감)
+export function sumCheerAvailable(balances: WalletBalanceRow[]|undefined|null): number {
+	return sumCash(balances) + sumToken(balances);
+}
+
+// ── GET /api/members/me/level ──
+export interface MemberLevelResponse {
+	exp: number;
+	currentGradeID: number|null;
+	currentGradeName: string|null;
+	currentGradeImage: string|null;
+	currentGradeTextColor: string|null;
+	nextGradeID: number|null;
+	nextGradeName: string|null;
+	nextGradeRequiredExp: number|null;
+	expToNextGrade: number|null;
+}
+
+// ── GET /api/rewards/me/today ──
+// ActionType 은 tinyint 숫자 (Post=1, Comment=2, LikeGiven=3, LikeReceived=4, Attendance=5, CheerReceived=6, Chat=7, PaperReward=8)
+export interface RewardActionSummary {
+	actionType: number;
+	actionName: string;
+	count: number;
+	expEarned: number;
+	pointEarned: number;
+	remainingCount: number|null;
+	remainingExp: number|null;
+	remainingPoint: number|null;
+}
+
+export interface RewardsTodayResponse {
+	rewardDate: string;
+	totalExp: number;
+	totalPoint: number;
+	actions: RewardActionSummary[];
+}
+
+// ── GET /api/posts/{postID}/cheers ──
+export interface CheerItem {
+	id: number;
+	fromMemberID: number;
+	fromNickname: string|null;
+	amount: number;
+	message: string|null;
+	createdAt: string;
+}
+
+export interface CheerListResponse {
+	totalAmount: number;
+	count: number;
+	recent: CheerItem[];
+}
+
+// POST /api/posts/{postID}/cheers → { cheerID }
+export interface CheerSendRequest {
+	amount: number;
+	message?: string|null;
+}
+
+export interface CheerSendResponse {
+	cheerID: number;
+}
+
+// ── ItemKind (Domain Store/ValueObject/ItemKind.cs — 숫자 직렬화) ──
+export const ItemKind = {
+	Coupon: 1,
+	Badge: 2,
+	NicknameEffect: 3,
+	CommentEffect: 4,
+	ChatBanLift: 5,
+	PostReset: 6,
+	NotePass: 7,
+	ExpBooster: 8,
+	ProfileImageChange: 9,
+	TaglineChange: 10,
+	NicknameChange: 11
+} as const;
+export type ItemKindValue = (typeof ItemKind)[keyof typeof ItemKind];
+
+// ── EquipSlot (Domain Members/ValueObject/EquipSlot.cs — 숫자 직렬화) ──
+export const EquipSlot = {
+	Badge: 1,
+	NicknameEffect: 2,
+	CommentEffect: 3
+} as const;
+export type EquipSlotValue = (typeof EquipSlot)[keyof typeof EquipSlot];
+
+// 장착형(지위) 아이템 종류 — 슬롯을 가지는 것들
+export const STATUS_ITEM_KINDS: ItemKindValue[] = [ItemKind.Badge, ItemKind.NicknameEffect, ItemKind.CommentEffect];
+
+// ── GET /api/members/me/items (List/Response.Row) ──
+// Kind·EquipSlot 은 숫자. isStatusItem = EquipSlot != null.
+export interface MemberItemRow {
+	id: number;
+	productID: number;
+	productName: string;
+	thumbnail: string|null;
+	kind: ItemKindValue;
+	equipSlot: EquipSlotValue|null;
+	isEquipped: boolean;
+	isStatusItem: boolean;
+	effectPayload: string|null;
+	durationDays: number;
+	acquiredAt: string;
+	expiresAt: string|null;
+	usedAt: string|null;
+	giftFromMemberID: number|null;
+	giftFromName: string|null;
+}
+
+export interface MemberItemsResponse {
+	total: number;
+	list: MemberItemRow[];
+}
+
+// POST /api/members/me/items/{id}/equip · /unequip → { id, slot, isEquipped }
+export interface EquipResponse {
+	id: number;
+	slot: EquipSlotValue;
+	isEquipped: boolean;
+}
+
+// ── GET /api/members/equipped-items?ids= (Items/Equipped/Response) ──
+export interface EquippedItem {
+	slot: EquipSlotValue;
+	kind: ItemKindValue;
+	productName: string;
+	effectPayload: string|null;
+	thumbnail: string|null;
+}
+
+export interface MemberEquip {
+	memberID: number;
+	items: EquippedItem[];
+}
+
+export interface EquippedItemsResponse {
+	members: MemberEquip[];
+}
+
+// EffectPayload JSON — 색상·아이콘·애니메이션 키 (Product.EffectPayload 스냅샷)
+// 자유 형식이나 렌더에서 안전하게 참조할 필드만 선언 (없으면 무시).
+export interface EffectPayload {
+	color?: string;
+	background?: string;
+	textColor?: string;
+	icon?: string;
+	animation?: string;
+	label?: string;
+}
+
+export function parseEffectPayload(raw: string|null|undefined): EffectPayload|null {
+	if (!raw) {
+		return null;
+	}
+
+	try {
+		const parsed = JSON.parse(raw);
+		if (parsed && typeof parsed === 'object') {
+			return parsed as EffectPayload;
+		}
+	} catch {
+		// 잘못된 JSON 은 무시 (배지 미표시)
+	}
+
+	return null;
+}
+
+// ── 라벨 맵 (지시 6번 — ItemKind / EquipSlot 라벨 단일 소스) ──
+
+export const ITEM_KIND_LABEL: Record<ItemKindValue, string> = {
+	[ItemKind.Coupon]: '쿠폰',
+	[ItemKind.Badge]: '배지',
+	[ItemKind.NicknameEffect]: '닉네임 이펙트',
+	[ItemKind.CommentEffect]: '댓글 이펙트',
+	[ItemKind.ChatBanLift]: '채팅 밴 해제권',
+	[ItemKind.PostReset]: '예전 글 초기화권',
+	[ItemKind.NotePass]: '쪽지권',
+	[ItemKind.ExpBooster]: '경험치 2배 모래시계',
+	[ItemKind.ProfileImageChange]: '프로필 이미지 변경권',
+	[ItemKind.TaglineChange]: '오늘의 한마디 변경권',
+	[ItemKind.NicknameChange]: '닉네임 변경권'
+};
+
+export const EQUIP_SLOT_LABEL: Record<EquipSlotValue, string> = {
+	[EquipSlot.Badge]: '배지',
+	[EquipSlot.NicknameEffect]: '닉네임 이펙트',
+	[EquipSlot.CommentEffect]: '댓글 이펙트'
+};
+
+export const EQUIP_SLOT_DESC: Record<EquipSlotValue, string> = {
+	[EquipSlot.Badge]: '닉네임 옆에 표시되는 배지입니다.',
+	[EquipSlot.NicknameEffect]: '닉네임에 색상·강조 효과를 적용합니다.',
+	[EquipSlot.CommentEffect]: '내가 쓴 댓글에 배경·강조 효과를 적용합니다.'
+};
+
+// 슬롯 노출 순서 (아이템 관리 화면)
+export const EQUIP_SLOT_ORDER: EquipSlotValue[] = [EquipSlot.Badge, EquipSlot.NicknameEffect, EquipSlot.CommentEffect];