page.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352
  1. 'use client';
  2. import '@/app/styles/reward.scss';
  3. import { useCallback, useEffect, useState } from 'react';
  4. import { fetchApi } from '@/lib/utils/client';
  5. import Loading from '@/app/component/Loading';
  6. import Pagination from '@/app/component/Pagination';
  7. import NavTabs from '../navTabs';
  8. import { ItemKind, ITEM_KIND_LABEL, ITEM_KIND_DESC, DIRECT_USE_KINDS } from '@/types/account/items';
  9. import type { MemberItemRow as ConsumableItemRow, MemberItemsResponse as ConsumableItemsResponse, UseItemResult } from '@/types/account/items';
  10. import { EquippedBadge, NicknameText } from '@/components/community/EquippedBadges';
  11. import {
  12. EQUIP_SLOT_ORDER,
  13. EQUIP_SLOT_LABEL,
  14. EQUIP_SLOT_DESC,
  15. ITEM_KIND_LABEL as STATUS_KIND_LABEL,
  16. parseEffectPayload
  17. } from '@/types/reward';
  18. import type {
  19. MemberItemRow as StatusItemRow,
  20. MemberItemsResponse as StatusItemsResponse,
  21. EquipResponse,
  22. EquipSlotValue,
  23. EquippedItem
  24. } from '@/types/reward';
  25. const PER_PAGE = 20;
  26. // StatusItemRow → EquippedItem 변환 (배지/닉네임 미리보기용)
  27. function toEquippedItem(row: StatusItemRow): EquippedItem {
  28. return {
  29. slot: row.equipSlot!,
  30. kind: row.kind,
  31. productName: row.productName,
  32. effectPayload: row.effectPayload,
  33. thumbnail: row.thumbnail
  34. };
  35. }
  36. function formatExpiry(expiresAt: string|null): string {
  37. if (!expiresAt) {
  38. return '기간 제한 없음';
  39. }
  40. const d = new Date(expiresAt);
  41. return `${d.getFullYear()}.${String(d.getMonth() + 1).padStart(2, '0')}.${String(d.getDate()).padStart(2, '0')} 만료`;
  42. }
  43. // ── 지위 아이템 (슬롯별 장착 관리) ──
  44. function StatusItemsSection()
  45. {
  46. const [items, setItems] = useState<StatusItemRow[]>([]);
  47. const [loading, setLoading] = useState(true);
  48. const [busyID, setBusyID] = useState<number|null>(null);
  49. const load = useCallback(async () => {
  50. setLoading(true);
  51. const res = await fetchApi<StatusItemsResponse>('/api/members/me/items?page=1&perPage=100', { silent: true });
  52. if (res.success && res.data) {
  53. // 지위 아이템(슬롯 보유)만 취급 — 소모품은 아래 별도 섹션
  54. setItems(res.data.list.filter(c => c.isStatusItem && c.equipSlot != null));
  55. }
  56. else {
  57. setItems([]);
  58. }
  59. setLoading(false);
  60. }, []);
  61. useEffect(() => {
  62. load();
  63. }, [load]);
  64. const toggleEquip = async (item: StatusItemRow) => {
  65. setBusyID(item.id);
  66. const action = item.isEquipped ? 'unequip' : 'equip';
  67. const res = await fetchApi<EquipResponse>(`/api/members/me/items/${item.id}/${action}`, { method: 'POST', silent: true });
  68. setBusyID(null);
  69. if (res.success) {
  70. await load();
  71. }
  72. else {
  73. alert(res.message || '장착 상태 변경에 실패했습니다.');
  74. }
  75. };
  76. if (loading) {
  77. return <Loading />;
  78. }
  79. if (items.length === 0) {
  80. return (
  81. <div className='text-center py-8 text-neutral-500 text-sm'>
  82. 보유한 지위 아이템이 없습니다. 상점에서 배지·닉네임 이펙트·댓글 이펙트를 구매할 수 있습니다.
  83. </div>
  84. );
  85. }
  86. // 슬롯별 그룹
  87. const bySlot = new Map<EquipSlotValue, StatusItemRow[]>();
  88. for (const slot of EQUIP_SLOT_ORDER) {
  89. bySlot.set(slot, []);
  90. }
  91. for (const item of items) {
  92. const arr = bySlot.get(item.equipSlot!) ?? [];
  93. arr.push(item);
  94. bySlot.set(item.equipSlot!, arr);
  95. }
  96. return (
  97. <div className='flex flex-col gap-6'>
  98. {EQUIP_SLOT_ORDER.map((slot) => {
  99. const slotItems = bySlot.get(slot) ?? [];
  100. if (slotItems.length === 0) {
  101. return null;
  102. }
  103. return (
  104. <div key={slot}>
  105. <div className='mb-2'>
  106. <h3 className='font-semibold'>{EQUIP_SLOT_LABEL[slot]}</h3>
  107. <p className='text-xs text-neutral-500'>{EQUIP_SLOT_DESC[slot]} 슬롯당 1개만 장착됩니다.</p>
  108. </div>
  109. <ul className='flex flex-col gap-2'>
  110. {slotItems.map((item) => {
  111. const equipped = toEquippedItem(item);
  112. return (
  113. <li
  114. key={item.id}
  115. className={`flex items-center gap-4 rounded-lg border p-4 ${
  116. item.isEquipped
  117. ? 'border-red-400 dark:border-red-600 bg-red-50/50 dark:bg-red-950/20'
  118. : 'border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900'
  119. }`}
  120. >
  121. <div className='size-14 shrink-0 rounded bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
  122. {item.thumbnail ? (
  123. // eslint-disable-next-line @next/next/no-img-element
  124. <img src={item.thumbnail} alt={item.productName} className='w-full h-full object-scale-down' />
  125. ) : (
  126. <span className='text-2xl'>🏅</span>
  127. )}
  128. </div>
  129. <div className='flex-1 min-w-0'>
  130. <div className='font-semibold truncate'>{item.productName}</div>
  131. <div className='text-xs text-neutral-500'>
  132. {STATUS_KIND_LABEL[item.kind]} · {formatExpiry(item.expiresAt)}
  133. {item.giftFromName && <span className='ml-1'>· 선물 (from {item.giftFromName})</span>}
  134. </div>
  135. {/* 미리보기 — 배지 / 닉네임 이펙트 실제 렌더 */}
  136. <div className='mt-1 text-sm'>
  137. {parseEffectPayload(item.effectPayload) || item.thumbnail ? (
  138. <span className='inline-flex items-center gap-1'>
  139. <NicknameText name='미리보기' items={[equipped]} />
  140. <EquippedBadge items={[equipped]} />
  141. </span>
  142. ) : null}
  143. </div>
  144. </div>
  145. <div className='shrink-0'>
  146. <button
  147. type='button'
  148. onClick={() => toggleEquip(item)}
  149. disabled={busyID === item.id}
  150. className={`px-4 py-2 rounded-md text-sm font-semibold disabled:opacity-50 ${
  151. item.isEquipped
  152. ? 'border border-neutral-300 dark:border-neutral-700 hover:bg-neutral-100 dark:hover:bg-neutral-800'
  153. : 'bg-red-600 text-white hover:bg-red-700'
  154. }`}
  155. >
  156. {busyID === item.id ? '처리 중...' : (item.isEquipped ? '해제' : '장착')}
  157. </button>
  158. </div>
  159. </li>
  160. );
  161. })}
  162. </ul>
  163. </div>
  164. );
  165. })}
  166. </div>
  167. );
  168. }
  169. // ── 소모품 아이템 (기존 — 사용/자동소모) ──
  170. function ConsumableItemsSection()
  171. {
  172. const [items, setItems] = useState<ConsumableItemRow[]>([]);
  173. const [total, setTotal] = useState(0);
  174. const [page, setPage] = useState(1);
  175. const [loading, setLoading] = useState(true);
  176. const [busyID, setBusyID] = useState<number|null>(null);
  177. const load = useCallback(async () => {
  178. setLoading(true);
  179. const res = await fetchApi<ConsumableItemsResponse>(`/api/member/me/items?page=${page}&perPage=${PER_PAGE}`, { silent: true });
  180. if (res.success && res.data) {
  181. setItems(res.data.list);
  182. setTotal(res.data.total);
  183. }
  184. else {
  185. setItems([]);
  186. setTotal(0);
  187. }
  188. setLoading(false);
  189. }, [page]);
  190. useEffect(() => {
  191. load();
  192. }, [load]);
  193. const useItem = async (item: ConsumableItemRow) => {
  194. if (item.kind === ItemKind.PostReset && !confirm('신고·댓글이 없는 예전 게시글이 모두 삭제됩니다. 계속하시겠어요?')) {
  195. return;
  196. }
  197. setBusyID(item.id);
  198. const res = await fetchApi<UseItemResult>(`/api/member/me/items/${item.id}/use`, { method: 'POST', silent: true });
  199. setBusyID(null);
  200. if (res.success && res.data) {
  201. alert(res.data.message || '아이템을 사용했습니다.');
  202. await load();
  203. }
  204. else {
  205. alert(res.message || '아이템 사용에 실패했습니다.');
  206. }
  207. };
  208. if (loading) {
  209. return <Loading />;
  210. }
  211. if (items.length === 0) {
  212. return <div className='text-center py-8 text-neutral-500 text-sm'>보유한 소모품이 없습니다.</div>;
  213. }
  214. return (
  215. <>
  216. <div className='text-sm text-neutral-500 mb-3'>미사용 {total.toLocaleString()}개</div>
  217. <ul className='flex flex-col gap-3'>
  218. {items.map((item) => {
  219. const directUse = DIRECT_USE_KINDS.includes(item.kind);
  220. return (
  221. <li
  222. key={item.id}
  223. className='flex items-center gap-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-4'
  224. >
  225. <div className='size-14 shrink-0 rounded bg-neutral-100 dark:bg-neutral-800 overflow-hidden flex items-center justify-center'>
  226. {item.thumbnail ? (
  227. // eslint-disable-next-line @next/next/no-img-element
  228. <img src={item.thumbnail} alt={item.productName} className='w-full h-full object-scale-down' />
  229. ) : (
  230. <span className='text-2xl'>🎁</span>
  231. )}
  232. </div>
  233. <div className='flex-1 min-w-0'>
  234. <div className='font-semibold truncate'>
  235. {ITEM_KIND_LABEL[item.kind] ?? item.productName}
  236. {item.kind === ItemKind.ExpBooster && item.durationDays > 0 && (
  237. <span className='ml-1 text-xs text-neutral-500'>({item.durationDays}일)</span>
  238. )}
  239. </div>
  240. <div className='text-xs text-neutral-500 line-clamp-2'>{ITEM_KIND_DESC[item.kind]}</div>
  241. </div>
  242. <div className='shrink-0'>
  243. {directUse ? (
  244. <button
  245. type='button'
  246. onClick={() => useItem(item)}
  247. disabled={busyID === item.id}
  248. className='px-4 py-2 rounded-md bg-red-600 text-white text-sm font-semibold hover:bg-red-700 disabled:opacity-50'
  249. >
  250. {busyID === item.id ? '처리 중...' : '사용하기'}
  251. </button>
  252. ) : (
  253. <span className='text-xs text-neutral-400'>기능 이용 시 사용</span>
  254. )}
  255. </div>
  256. </li>
  257. );
  258. })}
  259. </ul>
  260. <hr className='my-4 border-neutral-200 dark:border-neutral-800' />
  261. <Pagination total={total} page={page} perPage={PER_PAGE} onChange={setPage} />
  262. </>
  263. );
  264. }
  265. export default function MemberItemsPage()
  266. {
  267. const [tab, setTab] = useState<'status'|'consumable'>('status');
  268. return (
  269. <>
  270. <NavTabs />
  271. <div className='mx-auto px-4 sm:px-8 pb-8'>
  272. <h1 className='text-2xl font-bold mb-1'>아이템</h1>
  273. <p className='text-sm text-neutral-500 mb-4'>
  274. 지위 아이템(배지·닉네임/댓글 이펙트)은 슬롯별로 장착하고, 소모품은 사용하거나 해당 기능 이용 시 자동으로 사용됩니다.
  275. </p>
  276. <div className='mb-5 flex gap-1 border-b border-neutral-200 dark:border-neutral-800'>
  277. <button
  278. type='button'
  279. onClick={() => setTab('status')}
  280. className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
  281. tab === 'status'
  282. ? 'border-red-600 text-red-600 dark:text-red-400 dark:border-red-400'
  283. : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
  284. }`}
  285. >
  286. 지위 아이템
  287. </button>
  288. <button
  289. type='button'
  290. onClick={() => setTab('consumable')}
  291. className={`px-4 py-2 text-sm font-semibold border-b-2 -mb-px ${
  292. tab === 'consumable'
  293. ? 'border-red-600 text-red-600 dark:text-red-400 dark:border-red-400'
  294. : 'border-transparent text-neutral-500 hover:text-neutral-700 dark:hover:text-neutral-300'
  295. }`}
  296. >
  297. 소모품
  298. </button>
  299. </div>
  300. {tab === 'status' ? <StatusItemsSection /> : <ConsumableItemsSection />}
  301. </div>
  302. </>
  303. );
  304. }