Kaynağa Gözat

feat(store): P3 소모품 상점 프론트 — 아이템 보관함·사용 + 프로필/쪽지 UI (Wave 3A)

- (account)/items 신규: 소모품 보관함 + 사용(밴해제·글초기화·부스터), GET/POST /api/member/me/items
- navTabs 아이템 탭 + types/account/items(ItemKind 라벨/설명)
- 프로필 변경 UI: change-name/change-thumb 쿨다운·변경권 안내 + config.account.changeThumbDay
- 쪽지 무작위 발송 토글(쪽지권) — NoteComposeModal + NoteSendRequest.sendToRandom
- 빌드 성공 (npm run build)

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

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

@@ -87,7 +87,7 @@ export default function ChangeName()
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						개성을 담아 별명을 지어보세요!<br />
-						{config.account.changeNameDay}일 간격으로 별명을 변경할 수 있습니다.
+						{config.account.changeNameDay}일 간격으로 별명을 변경할 수 있습니다. 변경권 보유 시 즉시 변경됩니다.
 					</caption>
 					<colgroup>
 						<col width="30%"/>

+ 3 - 0
app/(main)/(account)/change-thumb/page.tsx

@@ -4,6 +4,7 @@ import './style.scss';
 import Link from 'next/link';
 import Image from 'next/image';
 import { useState } from 'react';
+import { useConfigContext } from '@/contexts/configProvider';
 import { useMemberContext } from '@/contexts/memberProvider';
 import useErrorAlert from '@/hooks/useErrorAlert';
 import { fetchApi } from '@/lib/utils/client';
@@ -11,6 +12,7 @@ import Loading from '@/app/component/Loading';
 
 export default function ChangeThumb()
 {
+	const config = useConfigContext();
 	const { member, setMember } = useMemberContext();
 	const { setError } = useErrorAlert();
 	const [loading, setLoading] = useState<boolean>(false);
@@ -112,6 +114,7 @@ export default function ChangeThumb()
 				<table className="table-auto max-xl:w-full lg:w-[600px]">
 					<caption>
 						커뮤니티 프로필에 대표 사진을 설정할 수 있습니다.
+							{(config.account.changeThumbDay ?? 0) > 0 && <><br />{config.account.changeThumbDay}일마다 무료로 변경할 수 있으며, 변경권 보유 시 즉시 변경됩니다.</>}
 					</caption>
 					<colgroup>
 						<col width="140px"/>

+ 135 - 0
app/(main)/(account)/items/page.tsx

@@ -0,0 +1,135 @@
+'use client';
+
+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 { ITEM_KIND_LABEL, ITEM_KIND_DESC, DIRECT_USE_KINDS } from '@/types/account/items';
+import type { MemberItemRow, MemberItemsResponse, UseItemResult } from '@/types/account/items';
+
+const PER_PAGE = 20;
+
+export default function MemberItemsPage()
+{
+	const [items, setItems] = useState<MemberItemRow[]>([]);
+	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<MemberItemsResponse>(`/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: MemberItemRow) => {
+		if (item.kind === '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 || '아이템 사용에 실패했습니다.');
+		}
+	};
+
+	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='text-sm text-neutral-500 mb-3'>미사용 {total.toLocaleString()}개</div>
+
+				{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 (
+									<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 === '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} />
+					</>
+				)}
+			</div>
+		</>
+	);
+}

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

@@ -33,6 +33,7 @@ export default function NavTabs()
 				{[
 					{ href: "/profile", label: "내 정보" },
 					{ href: "/inventory", label: "보관함" },
+					{ href: "/items", label: "아이템" },
 					{ href: "/orders", label: "주문 목록" },
 					{ href: "/charge-logs", label: "캐시 내역" },
 					{ href: "/my-posts", label: "작성 게시글" },

+ 23 - 6
app/(main)/note/_components/NoteComposeModal.tsx

@@ -29,6 +29,7 @@ export default function NoteComposeModal({ open, onClose, initialReceiver, initi
 
 	const [title, setTitle] = useState('');
 	const [content, setContent] = useState('');
+	const [sendToRandom, setSendToRandom] = useState(false);
 	const [sending, setSending] = useState(false);
 	const [error, setError] = useState<string|null>(null);
 
@@ -43,6 +44,7 @@ export default function NoteComposeModal({ open, onClose, initialReceiver, initi
 			setSearchHasMore(false);
 			setSearchOffset(0);
 			setError(null);
+			setSendToRandom(false);
 		}
 	}, [open, initialReceiver, initialTitle]);
 
@@ -113,7 +115,7 @@ export default function NoteComposeModal({ open, onClose, initialReceiver, initi
 	const handleSend = async () => {
 		setError(null);
 
-		if (!receiver) {
+		if (!sendToRandom && !receiver) {
 			setError('받는 사람을 선택해주세요.');
 			return;
 		}
@@ -128,11 +130,17 @@ export default function NoteComposeModal({ open, onClose, initialReceiver, initi
 		try {
 			const res = await fetchApi('/api/note/send', {
 				method: 'POST',
-				body: {
-					receiverMemberID: receiver.memberID,
-					title: title.trim(),
-					content: content.trim()
-				}
+				body: sendToRandom
+					? {
+						title: title.trim(),
+						content: content.trim(),
+						sendToRandom: true
+					}
+					: {
+						receiverMemberID: receiver!.memberID,
+						title: title.trim(),
+						content: content.trim()
+					}
 			});
 
 			if (res.success) {
@@ -158,6 +166,15 @@ export default function NoteComposeModal({ open, onClose, initialReceiver, initi
 				</DialogHeader>
 
 				<div className="flex flex-col gap-3 mt-2">
+					{/* 무작위 발송 (쪽지권) */}
+					<label className="flex items-center gap-2 text-sm">
+						<input type="checkbox" checked={sendToRandom} onChange={(e) => setSendToRandom(e.target.checked)} />
+						<span>무작위 회원에게 발송 (쪽지권 1개 소모)</span>
+					</label>
+					{sendToRandom && (
+						<div className="text-xs text-gray-500">받는 사람은 무작위로 선택되며, 쪽지권 1개가 사용됩니다.</div>
+					)}
+
 					{/* 받는 사람 */}
 					<div>
 						<label className="text-sm font-medium mb-1 block">받는 사람</label>

+ 53 - 0
types/account/items.ts

@@ -0,0 +1,53 @@
+// 상점 소모품 아이템 (백엔드 ItemKind enum — JSON 은 문자열로 직렬화)
+export type ItemKind =
+	'ChatBanLift'
+	| 'PostReset'
+	| 'NotePass'
+	| 'ExpBooster'
+	| 'ProfileImageChange'
+	| 'TaglineChange'
+	| 'NicknameChange';
+
+export interface MemberItemRow {
+	id: number;
+	productID: number;
+	productName: string;
+	thumbnail: string|null;
+	kind: ItemKind;
+	durationDays: number;
+	acquiredAt: string;
+	usedAt: string|null;
+}
+
+export interface MemberItemsResponse {
+	total: number;
+	list: MemberItemRow[];
+}
+
+export interface UseItemResult {
+	kind: ItemKind;
+	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: '변경 주기 제한 없이 닉네임을 즉시 변경할 때 자동으로 사용됩니다.'
+};
+
+// 보관함에서 직접 "사용하기" 가능한 종류 (나머지는 해당 기능 이용 시 자동 소모)
+export const DIRECT_USE_KINDS: ItemKind[] = ['ChatBanLift', 'PostReset', 'ExpBooster'];

+ 1 - 0
types/config.ts

@@ -84,6 +84,7 @@ export interface AccountForm {
 	changeNameDay: number|null;
 	changeSummaryDay: number|null;
 	changeIntroDay: number|null;
+	changeThumbDay: number|null;
 	changePasswordDay: number|null;
 	isLoginEmailVerifiedOnly: boolean;
 	maxLoginTryCount: number|null;

+ 2 - 1
types/request/note/send.ts

@@ -1,5 +1,6 @@
 export type NoteSendRequest = {
-	receiverMemberID: number;
+	receiverMemberID?: number;
 	title: string;
 	content: string;
+	sendToRandom?: boolean;
 };