| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352 |
- 'use client';
- import '@/app/styles/reward.scss';
- import { useCallback, useEffect, useState } from 'react';
- import { fetchApi } from '@/lib/utils/client';
- import Loading from '@/app/component/Loading';
- import Pagination from '@/app/component/Pagination';
- import NavTabs from '../navTabs';
- 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;
- // 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<StatusItemRow[]>([]);
- const [loading, setLoading] = useState(true);
- const [busyID, setBusyID] = useState<number|null>(null);
- const load = useCallback(async () => {
- setLoading(true);
- const res = await fetchApi<StatusItemsResponse>('/api/members/me/items?page=1&perPage=100', { silent: true });
- if (res.success && res.data) {
- // 지위 아이템(슬롯 보유)만 취급 — 소모품은 아래 별도 섹션
- setItems(res.data.list.filter(c => c.isStatusItem && c.equipSlot != null));
- }
- else {
- setItems([]);
- }
- setLoading(false);
- }, []);
- useEffect(() => {
- load();
- }, [load]);
- const toggleEquip = async (item: StatusItemRow) => {
- setBusyID(item.id);
- const action = item.isEquipped ? 'unequip' : 'equip';
- const res = await fetchApi<EquipResponse>(`/api/members/me/items/${item.id}/${action}`, { method: 'POST', silent: true });
- setBusyID(null);
- if (res.success) {
- 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>
- );
- }
- // 슬롯별 그룹
- 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);
- }
- 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 (
- <li
- key={item.id}
- 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'>
- {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.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='shrink-0'>
- <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>
- </li>
- );
- })}
- </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>
- <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>
- {tab === 'status' ? <StatusItemsSection /> : <ConsumableItemsSection />}
- </div>
- </>
- );
- }
|