Sfoglia il codice sorgente

feat(community): 종목토론·예측배지·트랙레코드

- components/community (예측/게시글예측 배지·티어배지·$태그·트렌딩종목·장착배지)
- 트랙레코드 리더보드(/ranking) + 프로필 적중률 탭
- $종목태그 렌더($tag)·댓글 UI + 관련 api 프록시(stock-boards/track-records/boards/posts/members)
- types/community, lib/api/community, community.scss

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
KIM-JINO5 2 settimane fa
parent
commit
ff722c826b

+ 1 - 1
app/(main)/(forum)/board/[code]/style.scss

@@ -2,7 +2,7 @@
 	position: relative;
 	position: relative;
 	padding: 25px 32px 32px 32px;
 	padding: 25px 32px 32px 32px;
 	min-width: 270px;
 	min-width: 270px;
-	max-width: 1920px;
+	// D6 §4 — WIDE fluid 원칙: 목록 페이지 max-width 캡 제거 (모니터 폭까지 확장)
 	margin: 0 auto;
 	margin: 0 auto;
 
 
 	h1 {
 	h1 {

+ 21 - 3
app/(main)/(forum)/comment/_component/Item.tsx

@@ -15,6 +15,9 @@ import useErrorAlert from '@/hooks/useErrorAlert';
 import { type CommentItem } from '@/types/forum/comment';
 import { type CommentItem } from '@/types/forum/comment';
 import { BoardResponse } from '@/types/response/forum/board';
 import { BoardResponse } from '@/types/response/forum/board';
 import { PostResponse } from '@/types/response/forum/post';
 import { PostResponse } from '@/types/response/forum/post';
+import { EquippedBadge, NicknameText } from '@/components/community/EquippedBadges';
+import { EquipSlot, parseEffectPayload } from '@/types/reward';
+import type { EquippedItem } from '@/types/reward';
 import EditForm from './EditForm';
 import EditForm from './EditForm';
 import Report from '@/app/(main)/(forum)/post/_component/Report';
 import Report from '@/app/(main)/(forum)/post/_component/Report';
 import { Reaction } from '@/constants/forum';
 import { Reaction } from '@/constants/forum';
@@ -23,6 +26,7 @@ type Props = {
 	_board: BoardResponse; // 게시판 정보
 	_board: BoardResponse; // 게시판 정보
     _post: PostResponse; // 게시글 정보
     _post: PostResponse; // 게시글 정보
 	_comment: CommentItem; // 댓글 정보
 	_comment: CommentItem; // 댓글 정보
+	equippedItems?: EquippedItem[]; // 작성자 장착 지위 아이템 (배지/닉네임/댓글 이펙트)
     isReplying?: boolean; // 답글 버튼 클릭 여부
     isReplying?: boolean; // 답글 버튼 클릭 여부
 	onReply: () => void;  // 답글 버튼 클릭 시
 	onReply: () => void;  // 답글 버튼 클릭 시
 	onDelete: (commentID: number, parentID?: number) => void; // 삭제 후
 	onDelete: (commentID: number, parentID?: number) => void; // 삭제 후
@@ -31,7 +35,7 @@ type Props = {
 	children?: React.ReactNode;
 	children?: React.ReactNode;
 }
 }
 
 
-export default function Item({ _board, _post, _comment, isReplying, onReply, onSuccess, onDelete, canWriteReply, children } : Props)
+export default function Item({ _board, _post, _comment, equippedItems, isReplying, onReply, onSuccess, onDelete, canWriteReply, children } : Props)
 {
 {
 	const { member, loginCheck } = useAuth();
 	const { member, loginCheck } = useAuth();
 	const { setError } = useErrorAlert();
 	const { setError } = useErrorAlert();
@@ -54,6 +58,19 @@ export default function Item({ _board, _post, _comment, isReplying, onReply, onS
 	const writerName = useMemo(() => _comment.writer.name || _comment.writer.sid, [_comment.writer.name, _comment.writer.sid]);
 	const writerName = useMemo(() => _comment.writer.name || _comment.writer.sid, [_comment.writer.name, _comment.writer.sid]);
 	const createdAt = useMemo(() => formatDate(_comment.createdAt), [_comment.createdAt]);
 	const createdAt = useMemo(() => formatDate(_comment.createdAt), [_comment.createdAt]);
 
 
+	// 댓글 이펙트(장착) — 배경/강조. 있으면 li 에 style 적용.
+	const commentEffectStyle = useMemo(() => {
+		const effect = equippedItems?.find(i => i.slot === EquipSlot.CommentEffect);
+		const payload = parseEffectPayload(effect?.effectPayload);
+		if (!payload) {
+			return undefined;
+		}
+		return {
+			background: payload.background,
+			borderColor: payload.color
+		} as React.CSSProperties;
+	}, [equippedItems]);
+
 	// 댓글 내 파일 다운로드 클릭 핸들러
 	// 댓글 내 파일 다운로드 클릭 핸들러
 	useEffect(() => {
 	useEffect(() => {
 		const el = contentRef.current;
 		const el = contentRef.current;
@@ -182,7 +199,7 @@ export default function Item({ _board, _post, _comment, isReplying, onReply, onS
 			{loading ? (
 			{loading ? (
 				<Loading type={2} />
 				<Loading type={2} />
 			) : (
 			) : (
-				<li className={_comment.isSecret ? 'is-secret' : ''}>
+				<li className={_comment.isSecret ? 'is-secret' : ''} style={commentEffectStyle}>
 					<Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} commentID={_comment.id} memberID={member?.id} />
 					<Report isEnable={true} open={report} onChange={setReport} onComplete={setHasReport} postID={_post.id} commentID={_comment.id} memberID={member?.id} />
 					{_board.boardMeta.comment.showMemberThumb && (
 					{_board.boardMeta.comment.showMemberThumb && (
 					<div>
 					<div>
@@ -196,7 +213,8 @@ export default function Item({ _board, _post, _comment, isReplying, onReply, onS
 									{_board.boardMeta.comment.showMemberIcon && writerIcon && (
 									{_board.boardMeta.comment.showMemberIcon && writerIcon && (
 										<Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
 										<Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
 									)}
 									)}
-									<span>{writerName}</span>
+									<NicknameText name={writerName} items={equippedItems} />
+									<EquippedBadge items={equippedItems} />
 								</li>
 								</li>
 								<li><small>{createdAt}</small></li>
 								<li><small>{createdAt}</small></li>
 							</ul>
 							</ul>

+ 18 - 1
app/(main)/(forum)/comment/_component/List.tsx

@@ -1,12 +1,13 @@
 'use client';
 'use client';
 
 
 import '../style.scss';
 import '../style.scss';
-import React from 'react';
+import React, { useMemo } from 'react';
 import Loading from '@/app/component/Loading';
 import Loading from '@/app/component/Loading';
 import { BoardResponse } from '@/types/response/forum/board';
 import { BoardResponse } from '@/types/response/forum/board';
 import { PostResponse } from '@/types/response/forum/post';
 import { PostResponse } from '@/types/response/forum/post';
 import { CommentListResponse } from '@/types/response/forum/comment';
 import { CommentListResponse } from '@/types/response/forum/comment';
 import { type CommentItem } from '@/types/forum/comment';
 import { type CommentItem } from '@/types/forum/comment';
+import useEquippedItems from '@/hooks/useEquippedItems';
 import WriteForm from './WriteForm';
 import WriteForm from './WriteForm';
 import Item from './Item';
 import Item from './Item';
 
 
@@ -24,6 +25,20 @@ type Props = {
 
 
 export default function List({ _board, _post, _data, loading, replyTargetID, onReply, onSuccess, onDelete, canWriteReply } : Props)
 export default function List({ _board, _post, _data, loading, replyTargetID, onReply, onSuccess, onDelete, canWriteReply } : Props)
 {
 {
+    // 댓글 작성자(대댓글 포함) ID 를 모아 장착 지위 아이템 배치 조회 (배지/닉네임 이펙트 렌더용)
+    const authorIDs = useMemo(() => {
+        const ids: number[] = [];
+        for (const c of _data.list) {
+            ids.push(c.memberID);
+            for (const ch of c.children ?? []) {
+                ids.push(ch.memberID);
+            }
+        }
+        return ids;
+    }, [_data.list]);
+
+    const equippedMap = useEquippedItems(authorIDs);
+
     return (
     return (
         <>
         <>
             {loading ? (
             {loading ? (
@@ -40,6 +55,7 @@ export default function List({ _board, _post, _data, loading, replyTargetID, onR
                                             _board={_board}
                                             _board={_board}
                                             _post={_post}
                                             _post={_post}
                                             _comment={row}
                                             _comment={row}
+                                            equippedItems={equippedMap[row.memberID]}
                                             isReplying={replyTargetID === row.id}
                                             isReplying={replyTargetID === row.id}
                                             onReply={() => onReply(row.id)}
                                             onReply={() => onReply(row.id)}
                                             onDelete={onDelete}
                                             onDelete={onDelete}
@@ -68,6 +84,7 @@ export default function List({ _board, _post, _data, loading, replyTargetID, onR
                                                             _board={_board}
                                                             _board={_board}
                                                             _post={_post}
                                                             _post={_post}
                                                             _comment={ch}
                                                             _comment={ch}
+                                                            equippedItems={equippedMap[ch.memberID]}
                                                             onReply={() => onReply(ch.id)}
                                                             onReply={() => onReply(ch.id)}
                                                             onDelete={onDelete}
                                                             onDelete={onDelete}
                                                             onSuccess={onSuccess}
                                                             onSuccess={onSuccess}

+ 1 - 1
app/(main)/(forum)/latest/style.scss

@@ -2,7 +2,7 @@
 	position: relative;
 	position: relative;
 	padding: 25px 32px 32px 32px;
 	padding: 25px 32px 32px 32px;
 	min-width: 270px;
 	min-width: 270px;
-	max-width: 1920px;
+	// D6 §4 — WIDE fluid 원칙: 목록 페이지 max-width 캡 제거
 	margin: 0 auto;
 	margin: 0 auto;
 
 
 	h1 {
 	h1 {

+ 14 - 1
app/(main)/(forum)/post/[id]/view.tsx

@@ -17,6 +17,10 @@ import LatestList from '@/app/(main)/(forum)/post/_component/LatestPosts';
 import { BoardResponse } from '@/types/response/forum/board';
 import { BoardResponse } from '@/types/response/forum/board';
 import { PostResponse } from '@/types/response/forum/post';
 import { PostResponse } from '@/types/response/forum/post';
 import Content from '../_component/Content';
 import Content from '../_component/Content';
+import Cheer from '../_component/Cheer';
+import PostPredictionBadge from '@/components/community/PostPredictionBadge';
+import { EquippedBadge, NicknameText } from '@/components/community/EquippedBadges';
+import useEquippedItems from '@/hooks/useEquippedItems';
 import QRCode from '../_component/QRCode';
 import QRCode from '../_component/QRCode';
 import Copied from '../_component/Copied';
 import Copied from '../_component/Copied';
 import SnsShare from '../_component/SnsShare';
 import SnsShare from '../_component/SnsShare';
@@ -59,6 +63,8 @@ export default function View({ _board, _post }: Props)
 	const writerThumb = useMemo(() => _post.writer?.thumb ?? '/resources/thumb.gif', [_post.writer?.thumb]);
 	const writerThumb = useMemo(() => _post.writer?.thumb ?? '/resources/thumb.gif', [_post.writer?.thumb]);
 	const writerIcon = useMemo(() => _post.writer?.icon ?? _post.writer?.gradeImage ?? null, [_post.writer?.icon, _post.writer?.gradeImage]);
 	const writerIcon = useMemo(() => _post.writer?.icon ?? _post.writer?.gradeImage ?? null, [_post.writer?.icon, _post.writer?.gradeImage]);
 	const writerName = useMemo(() => _post.writer?.name ?? _post.name ?? '-', [_post.writer?.name, _post.name]);
 	const writerName = useMemo(() => _post.writer?.name ?? _post.name ?? '-', [_post.writer?.name, _post.name]);
+	const equippedMap = useEquippedItems([_post.memberID]);
+	const writerEquipped = _post.memberID != null ? equippedMap[_post.memberID] : undefined;
 	const hasFunction = _board.boardMeta.view.allowLike
 	const hasFunction = _board.boardMeta.view.allowLike
 		|| _board.boardMeta.view.allowDislike
 		|| _board.boardMeta.view.allowDislike
 		|| _board.boardMeta.view.allowBookmark
 		|| _board.boardMeta.view.allowBookmark
@@ -226,7 +232,8 @@ export default function View({ _board, _post }: Props)
 										{_board.boardMeta.view.showMemberIcon && writerIcon && (
 										{_board.boardMeta.view.showMemberIcon && writerIcon && (
 											<Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
 											<Image src={writerIcon} alt="" width={16} height={16} className="inline-block mr-1 align-middle" />
 										)}
 										)}
-										<span>{writerName}</span>
+										<NicknameText name={writerName} items={writerEquipped} />
+										<EquippedBadge items={writerEquipped} />
 									</li>
 									</li>
 									{_board.boardMeta.view.showMemberRegDate && (
 									{_board.boardMeta.view.showMemberRegDate && (
 										<li>{formatDate(_post.writer.createdAt)} 가입</li>
 										<li>{formatDate(_post.writer.createdAt)} 가입</li>
@@ -290,6 +297,9 @@ export default function View({ _board, _post }: Props)
 
 
 			{/* 글 내용 */}
 			{/* 글 내용 */}
 			<section className='content'>
 			<section className='content'>
+				{/* 종목 예측글이면 예측 배지 노출 (예측 없으면 자동 미표시) */}
+				<PostPredictionBadge postID={_post.id} />
+
 				<Content boardMeta={_board.boardMeta} content={_post.content} boardManagers={_board.boardManager}></Content>
 				<Content boardMeta={_board.boardMeta} content={_post.content} boardManagers={_board.boardManager}></Content>
 
 
 				{_post.tagList.length > 0 && (
 				{_post.tagList.length > 0 && (
@@ -416,6 +426,9 @@ export default function View({ _board, _post }: Props)
 
 
 			<br/>
 			<br/>
 
 
+			{/* 응원 (Cheer) — 본인 글은 버튼 숨김, 목록은 익명 조회 */}
+			<Cheer postID={_post.id} authorMemberID={_post.memberID ?? null} />
+
 			{/* 댓글 */}
 			{/* 댓글 */}
 			<Comment _board={_board} _post={_post} />
 			<Comment _board={_board} _post={_post} />
 
 

+ 80 - 0
app/(main)/(forum)/post/_component/Content.tsx

@@ -1,6 +1,7 @@
 'use client';
 'use client';
 
 
 import './style.scss';
 import './style.scss';
+import '@/app/styles/community.scss';
 import { useEffect, useRef } from 'react';
 import { useEffect, useRef } from 'react';
 import BoardMeta from '@/types/forum/boardMeta';
 import BoardMeta from '@/types/forum/boardMeta';
 import BoardManager from '@/types/forum/boardManager';
 import BoardManager from '@/types/forum/boardManager';
@@ -17,6 +18,85 @@ export default function Content({ boardMeta, content, boardManagers }: Props)
 	const articleRef = useRef<HTMLElement>(null);
 	const articleRef = useRef<HTMLElement>(null);
 	const allowTargetBlank = boardMeta.view.allowContentLinkTargetBlank;
 	const allowTargetBlank = boardMeta.view.allowContentLinkTargetBlank;
 
 
+	// $종목 태그 렌더 — 본문 텍스트의 `$005930(삼성전자)` 토큰을 /stock 링크 배지로 치환
+	useEffect(() => {
+		const el = articleRef.current;
+		if (!el) {
+			return;
+		}
+
+		const pattern = /\$(\d{6})(?!\d)(?:\(([^)]{0,20})\))?/g;
+
+		const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT, {
+			acceptNode(node) {
+				if (!node.nodeValue || !node.nodeValue.includes('$')) {
+					return NodeFilter.FILTER_REJECT;
+				}
+
+				let parent = node.parentElement;
+				while (parent && parent !== el) {
+					if (parent.classList && parent.classList.contains('stock-tag')) {
+						return NodeFilter.FILTER_REJECT;
+					}
+					parent = parent.parentElement;
+				}
+
+				return NodeFilter.FILTER_ACCEPT;
+			}
+		});
+
+		const targets: Text[] = [];
+		let cursor = walker.nextNode();
+		while (cursor) {
+			targets.push(cursor as Text);
+			cursor = walker.nextNode();
+		}
+
+		for (const textNode of targets) {
+			const text = textNode.nodeValue ?? '';
+			pattern.lastIndex = 0;
+
+			if (!pattern.test(text)) {
+				continue;
+			}
+
+			pattern.lastIndex = 0;
+			const frag = document.createDocumentFragment();
+			let lastIndex = 0;
+			let match: RegExpExecArray|null;
+
+			while ((match = pattern.exec(text)) !== null) {
+				const [full, code, name] = match;
+
+				if (match.index > lastIndex) {
+					frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
+				}
+
+				const anchor = document.createElement('a');
+				anchor.href = `/stock/${code}`;
+				anchor.className = 'stock-tag';
+
+				const nameSpan = document.createElement('span');
+				nameSpan.textContent = name ? name : code;
+				anchor.appendChild(nameSpan);
+
+				const codeSpan = document.createElement('span');
+				codeSpan.className = 'stock-tag__code';
+				codeSpan.textContent = code;
+				anchor.appendChild(codeSpan);
+
+				frag.appendChild(anchor);
+				lastIndex = match.index + full.length;
+			}
+
+			if (lastIndex < text.length) {
+				frag.appendChild(document.createTextNode(text.slice(lastIndex)));
+			}
+
+			textNode.parentNode?.replaceChild(frag, textNode);
+		}
+	}, [content]);
+
 	useEffect(() => {
 	useEffect(() => {
 		const el = articleRef.current;
 		const el = articleRef.current;
 		if (!el) {
 		if (!el) {

+ 91 - 0
app/(main)/ranking/track-record/page.tsx

@@ -0,0 +1,91 @@
+import '@/app/styles/community.scss';
+import '@/app/styles/data-table.scss';
+import { Metadata } from 'next';
+import Link from 'next/link';
+import { fetchTrackRecordLeaderboard } from '@/lib/api/community';
+import TierBadge from '@/components/community/TierBadge';
+
+export const metadata: Metadata = {
+	title: '예측 적중률 리더보드 — 개미투자',
+	description: '종목 예측글 채점 기반 적중률 순위 (표본 10건 이상 · 전체 기간)'
+};
+
+const PAGE_SIZE = 50;
+
+type Props = {
+	searchParams: Promise<{
+		page?: string;
+	}>;
+};
+
+export default async function TrackRecordLeaderboardPage({ searchParams }: Props)
+{
+	const query = await searchParams;
+	const page = Math.max(Number(query.page) || 1, 1);
+
+	const res = await fetchTrackRecordLeaderboard({ page });
+	const data = res.success ? res.data : null;
+	const rows = data?.list ?? [];
+	const totalPages = data ? Math.max(Math.ceil(data.total / PAGE_SIZE), 1) : 1;
+
+	return (
+		<div className='track-leaderboard'>
+			<div className='track-leaderboard__head'>
+				<h1 className='track-leaderboard__title'>예측 적중률 리더보드</h1>
+				<span className='track-leaderboard__subtitle'>
+					종목 예측글이 채점된 회원 순위입니다 (표본 10건 이상 · 전체 기간). 순위는 명예·프로필 배지만 부여됩니다.
+				</span>
+			</div>
+
+			{!res.success ? (
+				<div className='track-leaderboard__error' role='alert'>리더보드를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.</div>
+			) : rows.length === 0 ? (
+				<div className='track-leaderboard__empty'>아직 채점된 예측이 있는 회원이 없습니다.</div>
+			) : (
+				<div className='track-leaderboard__table-wrap'>
+					<table className='data-table'>
+						<caption className='data-table__caption data-table__caption--sr'>
+							예측 적중률 리더보드 — 순위·닉네임·적중률·최고 스트릭·표본수·티어
+						</caption>
+						<thead>
+							<tr>
+								<th scope='col' data-align='center' data-priority='high'>순위</th>
+								<th scope='col' data-align='left' data-priority='high'>닉네임</th>
+								<th scope='col' data-align='right' data-priority='high'>적중률</th>
+								<th scope='col' data-align='right' data-priority='medium'>최고 스트릭</th>
+								<th scope='col' data-align='right' data-priority='medium'>표본수</th>
+								<th scope='col' data-align='center' data-priority='low'>티어</th>
+							</tr>
+						</thead>
+						<tbody>
+							{rows.map(row => (
+								<tr key={row.sid}>
+									<td data-align='center'><span className='track-leaderboard__rank'>{row.rank}</span></td>
+									<td data-align='left'>
+										<Link href={`/user/${row.sid}`} className='track-leaderboard__name'>{row.name}</Link>
+									</td>
+									<td data-align='right'><span className='track-leaderboard__hitrate'>{row.hitRate.toFixed(1)}%</span></td>
+									<td data-align='right'>{row.bestStreak.toLocaleString()}</td>
+									<td data-align='right'>{row.predictions.toLocaleString()}</td>
+									<td data-align='center'><TierBadge tierCode={row.tierCode} label={row.tier} /></td>
+								</tr>
+							))}
+						</tbody>
+					</table>
+				</div>
+			)}
+
+			{res.success && rows.length > 0 && data && data.total > PAGE_SIZE && (
+				<nav className='track-leaderboard__actions' aria-label='페이지'>
+					{page > 1 && (
+						<Link href={`/ranking/track-record?page=${page - 1}`} className='track-leaderboard__btn'>이전</Link>
+					)}
+					<span className='track-leaderboard__rank'>{page} / {totalPages}</span>
+					{page < totalPages && (
+						<Link href={`/ranking/track-record?page=${page + 1}`} className='track-leaderboard__btn'>다음</Link>
+					)}
+				</nav>
+			)}
+		</div>
+	);
+}

+ 67 - 0
app/(main)/user/[sid]/(tabs)/track-record/page.tsx

@@ -0,0 +1,67 @@
+import '@/app/styles/community.scss';
+import { Target, Flame, Trophy, ListChecks } from 'lucide-react';
+import { fetchTrackRecord } from '@/lib/api/community';
+import TierBadge from '@/components/community/TierBadge';
+
+type Props = {
+	params: Promise<{ sid: string }>;
+};
+
+// 프로필 "예측 적중률" 탭 (익명 허용 · SSR) — 티어 배지·적중률·스트릭·표본수
+export default async function UserProfileTrackRecordPage({ params }: Props)
+{
+	const { sid } = await params;
+	const res = await fetchTrackRecord(sid);
+	const record = res.success ? res.data : null;
+
+	if (!res.success) {
+		return <div className='track-leaderboard__error' role='alert'>예측 적중률을 불러오지 못했습니다.</div>;
+	}
+
+	if (!record || !record.hasRecord) {
+		return <div className='user-profile__empty'>아직 채점된 예측이 없습니다.</div>;
+	}
+
+	return (
+		<>
+			<div className='track-summary'>
+				<span className='track-summary__label'>등급</span>
+				<TierBadge tierCode={record.tierCode} label={record.tier} />
+			</div>
+
+			<section className='user-profile__stats' aria-label='예측 적중률'>
+				<div className='user-profile__stat'>
+					<Target size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+					<div className='user-profile__stat-body'>
+						<span className='user-profile__stat-label'>적중률</span>
+						<span className='user-profile__stat-value'>{record.hitRate.toFixed(1)}%</span>
+					</div>
+				</div>
+
+				<div className='user-profile__stat'>
+					<ListChecks size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+					<div className='user-profile__stat-body'>
+						<span className='user-profile__stat-label'>채점 예측 (적중/실패/무효)</span>
+						<span className='user-profile__stat-value'>{record.hits} / {record.misses} / {record.voids}</span>
+					</div>
+				</div>
+
+				<div className='user-profile__stat'>
+					<Flame size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+					<div className='user-profile__stat-body'>
+						<span className='user-profile__stat-label'>현재 연속 적중</span>
+						<span className='user-profile__stat-value'>{record.currentStreak.toLocaleString()}</span>
+					</div>
+				</div>
+
+				<div className='user-profile__stat'>
+					<Trophy size={20} className='user-profile__stat-icon' strokeWidth={1.75} />
+					<div className='user-profile__stat-body'>
+						<span className='user-profile__stat-label'>최고 연속 적중</span>
+						<span className='user-profile__stat-value'>{record.bestStreak.toLocaleString()}</span>
+					</div>
+				</div>
+			</section>
+		</>
+	);
+}

+ 2 - 1
app/(main)/user/[sid]/_component/UserProfileTabs.tsx

@@ -14,7 +14,8 @@ export default function UserProfileTabs({ memberSID }: Props) {
 		{ key: null, label: '게시글', href: `/user/${memberSID}` },
 		{ key: null, label: '게시글', href: `/user/${memberSID}` },
 		{ key: 'comments', label: '댓글', href: `/user/${memberSID}/comments` },
 		{ key: 'comments', label: '댓글', href: `/user/${memberSID}/comments` },
 		{ key: 'feed', label: '피드', href: `/user/${memberSID}/feed` },
 		{ key: 'feed', label: '피드', href: `/user/${memberSID}/feed` },
-		{ key: 'paper', label: '모의투자 성적', href: `/user/${memberSID}/paper` }
+		{ key: 'paper', label: '모의투자 성적', href: `/user/${memberSID}/paper` },
+		{ key: 'track-record', label: '예측 적중률', href: `/user/${memberSID}/track-record` }
 	];
 	];
 
 
 	return (
 	return (

+ 31 - 0
app/api/boards/[...path]/route.ts

@@ -0,0 +1,31 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+// 종목 가상보드 등 /api/boards/** (예: POST /api/boards/stock/posts)
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/boards/${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/boards/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+	const raw = await request.arrayBuffer();
+
+	if (raw.byteLength > 0) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: raw,
+			headers: contentType ? { 'Content-Type': contentType } : undefined
+		});
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST' });
+	return NextResponse.json(res);
+}

+ 33 - 0
app/api/members/[...path]/route.ts

@@ -0,0 +1,33 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+// 복수형 members 프록시 — /api/members/{sid}/track-record, /api/members/equipped-items 등
+// (기존 단수형 /api/member 프록시와 별개 경로)
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/members/${path.join('/')}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}
+
+// D3 지위 아이템 장착/해제 — POST /api/members/me/items/{id}/equip · /unequip (body 없음)
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/members/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+	const raw = await request.arrayBuffer();
+
+	if (raw.byteLength > 0) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: raw,
+			headers: contentType ? { 'Content-Type': contentType } : undefined
+		});
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST' });
+	return NextResponse.json(res);
+}

+ 32 - 0
app/api/posts/[...path]/route.ts

@@ -0,0 +1,32 @@
+import { NextRequest, NextResponse } from 'next/server';
+import { ResultDto } from '@/types/response/common';
+import { fetchJson } from '@/lib/utils/server';
+
+// /api/posts/{id}/prediction, /api/posts/{id}/cheers 등 (백엔드 posts 루트 — forum/posts 와 별개)
+export async function GET(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/posts/${path.join('/')}`;
+	const url = new URL(request.url);
+	const res: ResultDto = await fetchJson(`${endpoint}${url.search}`, { method: 'GET' });
+	return NextResponse.json(res);
+}
+
+// D3 응원 발신 — POST /api/posts/{postID}/cheers { amount, message? }
+export async function POST(request: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
+	const { path } = await params;
+	const endpoint = `/api/posts/${path.join('/')}`;
+	const contentType = request.headers.get('content-type') || '';
+	const raw = await request.arrayBuffer();
+
+	if (raw.byteLength > 0) {
+		const res: ResultDto = await fetchJson(endpoint, {
+			method: 'POST',
+			body: raw,
+			headers: contentType ? { 'Content-Type': contentType } : undefined
+		});
+		return NextResponse.json(res);
+	}
+
+	const res: ResultDto = await fetchJson(endpoint, { method: 'POST' });
+	return NextResponse.json(res);
+}

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

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

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

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

+ 550 - 0
app/styles/community.scss

@@ -0,0 +1,550 @@
+// ─────────────────────────────────────────────────────────────
+// D2 커뮤니티 — 예측 배지 / 트랙레코드 배지 / $종목 태그 / 종목 토론 / 리더보드 / 트렌딩
+// 토큰 변수만 사용 (색·간격·타이포). 등락엔 포인트 컬러(--az-accent) 미사용.
+// ─────────────────────────────────────────────────────────────
+
+// ── 예측 배지 (방향 + 기간 + 상태) ──
+.prediction-badge {
+	display: inline-flex;
+	align-items: center;
+	gap: var(--sp-2);
+	padding: 2px 8px;
+	border: 1px solid var(--border-strong);
+	border-radius: 999px;
+	font-size: var(--fs-2xs);
+	line-height: 1.6;
+	white-space: nowrap;
+	vertical-align: middle;
+
+	&__dir {
+		display: inline-flex;
+		align-items: center;
+		gap: 2px;
+		font-weight: 700;
+		font-variant-numeric: tabular-nums;
+
+		&--up {
+			color: var(--price-up);
+		}
+
+		&--down {
+			color: var(--price-down);
+		}
+	}
+
+	&__horizon {
+		color: var(--text-muted);
+	}
+
+	&__target {
+		color: var(--text-secondary);
+		font-variant-numeric: tabular-nums;
+	}
+
+	// 상태 점 + 라벨
+	&__status {
+		display: inline-flex;
+		align-items: center;
+		gap: 4px;
+		font-weight: 600;
+	}
+
+	&__dot {
+		width: 6px;
+		height: 6px;
+		border-radius: 50%;
+		background: var(--text-muted);
+	}
+
+	&--pending &__status { color: var(--text-muted); }
+	&--pending &__dot { background: var(--text-muted); }
+
+	&--hit &__status { color: var(--price-up); }
+	&--hit &__dot { background: var(--price-up); }
+
+	&--miss &__status { color: var(--price-down); }
+	&--miss &__dot { background: var(--price-down); }
+
+	&--void &__status { color: var(--text-muted); }
+	&--void &__dot { background: var(--text-muted); }
+
+	// 상세 뷰용 확장 배지
+	&--detail {
+		padding: var(--sp-3) var(--sp-4);
+		border-radius: 8px;
+		font-size: var(--fs-sm);
+		gap: var(--sp-4);
+	}
+}
+
+// 상세 뷰 예측 배지 래퍼
+.post-prediction {
+	margin-bottom: var(--sp-4);
+}
+
+// 작은 예측 표식 (목록 행에서 아이콘만)
+.prediction-tag {
+	display: inline-flex;
+	align-items: center;
+	gap: 3px;
+	font-size: var(--fs-2xs);
+	font-weight: 700;
+	vertical-align: middle;
+
+	&--up { color: var(--price-up); }
+	&--down { color: var(--price-down); }
+	&--flat { color: var(--text-muted); }
+}
+
+// ── 트랙레코드 등급 배지 ──
+.tier-badge {
+	display: inline-flex;
+	align-items: center;
+	gap: 4px;
+	padding: 1px 8px;
+	border-radius: 999px;
+	border: 1px solid var(--border-strong);
+	font-size: var(--fs-2xs);
+	font-weight: 700;
+	line-height: 1.6;
+	white-space: nowrap;
+	vertical-align: middle;
+
+	&__icon {
+		font-size: 0.9em;
+	}
+
+	&--none {
+		color: var(--text-muted);
+		border-color: var(--border-default);
+	}
+
+	&--bronze {
+		color: #a15c2f;
+		border-color: #a15c2f;
+	}
+
+	&--silver {
+		color: #7a7f87;
+		border-color: #9aa0a8;
+	}
+
+	&--gold {
+		color: #b8860b;
+		border-color: #d4a017;
+	}
+
+	&--goldant {
+		color: #fff;
+		background: linear-gradient(135deg, #d4a017, #b8860b);
+		border-color: #b8860b;
+	}
+}
+
+// ── $종목 태그 (본문 인라인 배지) ──
+.stock-tag {
+	display: inline-flex;
+	align-items: baseline;
+	gap: 3px;
+	padding: 0 5px;
+	border-radius: 4px;
+	background: var(--bg-subtle-hover);
+	border: 1px solid var(--border-light);
+	color: var(--text-primary);
+	font-size: 0.95em;
+	text-decoration: none;
+	white-space: nowrap;
+
+	&:hover {
+		border-color: var(--az-accent);
+		color: var(--az-accent);
+	}
+
+	&__code {
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+		font-size: 0.85em;
+	}
+
+	&--missing {
+		color: var(--text-secondary);
+	}
+}
+
+// ── 종목 토론 탭 (/stock/[code]?tab=discussion) ──
+.stock-discussion {
+	// 통계 헤더
+	&__stats {
+		display: flex;
+		flex-wrap: wrap;
+		align-items: center;
+		gap: var(--sp-3) var(--sp-6);
+		padding-bottom: var(--sp-4);
+		margin-bottom: var(--sp-4);
+		border-bottom: 1px solid var(--border-default);
+
+		span {
+			font-size: var(--fs-xs);
+			color: var(--text-muted);
+
+			strong {
+				color: var(--text-primary);
+				font-variant-numeric: tabular-nums;
+				margin-left: var(--sp-2);
+			}
+		}
+	}
+
+	&__head {
+		display: flex;
+		align-items: center;
+		justify-content: space-between;
+		gap: var(--sp-4);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__table {
+		width: 100%;
+		border-collapse: collapse;
+
+		thead th {
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			font-weight: 600;
+			color: var(--text-muted);
+			text-align: left;
+			border-bottom: 1px solid var(--border-strong);
+
+			&.stock-discussion__col--num,
+			&.stock-discussion__col--num-h { text-align: center; width: 48px; }
+		}
+
+		tbody td {
+			height: var(--row-h);
+			padding: 0 var(--cell-px);
+			font-size: var(--cell-fs);
+			color: var(--text-primary);
+			border-bottom: 1px solid var(--border-light);
+			vertical-align: middle;
+		}
+
+		tbody tr:hover {
+			background: var(--bg-subtle-hover);
+		}
+
+		&-num { text-align: center; color: var(--text-muted); font-variant-numeric: tabular-nums; }
+		&-meta { color: var(--text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right; }
+	}
+
+	&__subject {
+		display: inline-flex;
+		align-items: center;
+		gap: var(--sp-2);
+		color: var(--text-primary);
+
+		&:hover { color: var(--az-accent); }
+
+		em { font-style: normal; }
+		.stock-discussion__comments { color: var(--az-accent); font-size: 0.9em; }
+	}
+
+	&__empty,
+	&__error {
+		padding: var(--sp-7) var(--sp-5);
+		font-size: var(--fs-base);
+		color: var(--text-muted);
+		text-align: center;
+	}
+
+	// mobile drop
+	@media (max-width: 767.98px) {
+		&__col--author,
+		&__col--views { display: none; }
+	}
+}
+
+// ── 종목 글쓰기 폼 (토론 탭 내) ──
+.stock-write {
+	margin-top: var(--sp-5);
+	padding: var(--sp-5);
+	border: 1px solid var(--border-default);
+	border-radius: 8px;
+	background: var(--bg-elevated);
+
+	&__title {
+		font-size: var(--fs-base);
+		font-weight: 600;
+		color: var(--text-primary);
+		margin-bottom: var(--sp-4);
+	}
+
+	&__field {
+		margin-bottom: var(--sp-4);
+
+		label {
+			display: block;
+			font-size: var(--fs-xs);
+			color: var(--text-secondary);
+			margin-bottom: var(--sp-2);
+		}
+
+		input[type='text'],
+		input[type='number'],
+		textarea,
+		select {
+			width: 100%;
+			padding: var(--sp-3);
+			border: 1px solid var(--border-strong);
+			border-radius: 6px;
+			background: var(--bg-base, var(--bg-elevated));
+			color: var(--text-primary);
+			font-size: var(--fs-sm);
+
+			&:focus {
+				outline: none;
+				border-color: var(--az-accent);
+			}
+		}
+
+		textarea { min-height: 120px; resize: vertical; }
+	}
+
+	&__prediction {
+		margin-top: var(--sp-4);
+		padding: var(--sp-4);
+		border: 1px dashed var(--border-strong);
+		border-radius: 6px;
+	}
+
+	&__prediction-head {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-3);
+		margin-bottom: var(--sp-4);
+		font-size: var(--fs-sm);
+		color: var(--text-secondary);
+	}
+
+	&__prediction-grid {
+		display: grid;
+		grid-template-columns: repeat(3, 1fr);
+		gap: var(--sp-3);
+
+		@media (max-width: 640px) {
+			grid-template-columns: 1fr;
+		}
+	}
+
+	&__actions {
+		display: flex;
+		justify-content: flex-end;
+		gap: var(--sp-3);
+		margin-top: var(--sp-4);
+	}
+
+	&__btn {
+		padding: var(--sp-3) var(--sp-5);
+		border-radius: 6px;
+		font-size: var(--fs-sm);
+		font-weight: 600;
+		border: 1px solid var(--border-strong);
+		background: transparent;
+		color: var(--text-primary);
+		cursor: pointer;
+
+		&--primary {
+			background: var(--az-accent);
+			border-color: var(--az-accent);
+			color: #fff;
+		}
+
+		&:disabled { opacity: 0.5; cursor: not-allowed; }
+	}
+
+	&__hint {
+		margin-top: var(--sp-3);
+		font-size: var(--fs-2xs);
+		color: var(--text-muted);
+	}
+
+	&__error {
+		margin-top: var(--sp-3);
+		font-size: var(--fs-xs);
+		color: var(--color-danger);
+	}
+}
+
+.stock-write-toggle {
+	display: inline-flex;
+	align-items: center;
+	gap: var(--sp-2);
+	padding: var(--sp-2) var(--sp-4);
+	border-radius: 6px;
+	font-size: var(--fs-sm);
+	font-weight: 600;
+	background: var(--az-accent);
+	color: #fff;
+	border: none;
+	cursor: pointer;
+}
+
+.stock-write-login {
+	margin-top: var(--sp-4);
+	padding: var(--sp-4);
+	font-size: var(--fs-sm);
+	color: var(--text-muted);
+	text-align: center;
+	border: 1px dashed var(--border-default);
+	border-radius: 8px;
+
+	a { color: var(--az-accent); font-weight: 600; }
+}
+
+// ── 적중률 리더보드 (/ranking/track-record) ──
+.track-leaderboard {
+	padding: var(--sp-6) var(--sp-7);
+
+	@media (max-width: 767.98px) {
+		padding: var(--sp-5) var(--sp-4);
+	}
+
+	&__head {
+		margin-bottom: var(--sp-5);
+	}
+
+	&__title {
+		font-size: var(--fs-2xl);
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__subtitle {
+		display: block;
+		margin-top: var(--sp-2);
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+
+	&__rank {
+		font-variant-numeric: tabular-nums;
+		font-weight: 700;
+		color: var(--text-secondary);
+	}
+
+	&__name {
+		color: var(--text-primary);
+
+		&:hover { color: var(--az-accent); }
+	}
+
+	&__hitrate {
+		font-variant-numeric: tabular-nums;
+		font-weight: 700;
+		color: var(--text-primary);
+	}
+
+	&__error,
+	&__empty {
+		padding: var(--sp-7) var(--sp-5);
+		text-align: center;
+		color: var(--text-muted);
+	}
+
+	&__table-wrap {
+		overflow-x: auto;
+	}
+
+	&__actions {
+		display: flex;
+		align-items: center;
+		justify-content: center;
+		gap: var(--sp-3);
+		margin-top: var(--sp-5);
+	}
+
+	&__btn {
+		padding: var(--sp-2) var(--sp-5);
+		border: 1px solid var(--border-strong);
+		border-radius: 6px;
+		font-size: var(--fs-sm);
+		color: var(--text-primary);
+
+		&:hover { border-color: var(--az-accent); }
+	}
+}
+
+// ── 트렌딩 위젯 (지금 뜨는 종목) ──
+.trending-widget {
+	&__title {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-2);
+		font-size: var(--fs-sm);
+		font-weight: 700;
+		color: var(--text-primary);
+		margin-bottom: var(--sp-3);
+	}
+
+	&__list {
+		display: flex;
+		flex-direction: column;
+	}
+
+	&__item {
+		display: flex;
+		align-items: center;
+		gap: var(--sp-3);
+		padding: var(--sp-2) 0;
+		border-bottom: 1px solid var(--border-light);
+		font-size: var(--fs-xs);
+		color: var(--text-primary);
+
+		&:last-child { border-bottom: none; }
+		&:hover { color: var(--az-accent); }
+	}
+
+	&__rank {
+		width: 18px;
+		text-align: center;
+		font-weight: 700;
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__name {
+		flex: 1;
+		overflow: hidden;
+		text-overflow: ellipsis;
+		white-space: nowrap;
+	}
+
+	&__price {
+		font-variant-numeric: tabular-nums;
+		color: var(--text-secondary);
+	}
+
+	&__posts {
+		color: var(--text-muted);
+		font-variant-numeric: tabular-nums;
+	}
+
+	&__empty {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+		padding: var(--sp-3) 0;
+	}
+}
+
+// 프로필 트랙레코드 탭에서 상단 배지 줄
+.track-summary {
+	display: flex;
+	align-items: center;
+	gap: var(--sp-3);
+	margin-bottom: var(--sp-4);
+
+	&__label {
+		font-size: var(--fs-xs);
+		color: var(--text-muted);
+	}
+}

+ 89 - 0
components/community/EquippedBadges.tsx

@@ -0,0 +1,89 @@
+'use client';
+
+// D3 장착 지위 아이템 렌더 — 댓글/게시글 작성자·프로필 옆 배지 + 닉네임 이펙트.
+// EquippedItem[] 을 직접 받는 프레젠테이션 컴포넌트 (배치 조회는 상위에서 useEquippedItems 로).
+import '@/app/styles/reward.scss';
+import { EquipSlot, ItemKind, parseEffectPayload } from '@/types/reward';
+import type { EquippedItem } from '@/types/reward';
+
+interface BadgeProps {
+	items: EquippedItem[]|undefined|null;
+}
+
+// 배지 슬롯만 렌더 (닉네임 옆 아이콘/라벨). 닉네임 이펙트는 NicknameText 로 분리.
+export function EquippedBadge({ items }: BadgeProps)
+{
+	if (!items || items.length === 0) {
+		return null;
+	}
+
+	const badge = items.find(i => i.slot === EquipSlot.Badge || i.kind === ItemKind.Badge);
+
+	if (!badge) {
+		return null;
+	}
+
+	const payload = parseEffectPayload(badge.effectPayload);
+	const label = payload?.label ?? badge.productName;
+
+	return (
+		<span
+			className='equipped-badge'
+			title={badge.productName}
+			style={payload?.background ? { background: payload.background, color: payload.textColor ?? payload.color } : undefined}
+		>
+			{badge.thumbnail ? (
+				// eslint-disable-next-line @next/next/no-img-element
+				<img src={badge.thumbnail} alt='' className='equipped-badge__icon' />
+			) : payload?.icon ? (
+				<span className='equipped-badge__emoji' aria-hidden='true'>{payload.icon}</span>
+			) : null}
+			<span className='equipped-badge__label'>{label}</span>
+		</span>
+	);
+}
+
+interface NicknameProps {
+	name: string;
+	items: EquippedItem[]|undefined|null;
+	className?: string;
+}
+
+// 닉네임 이펙트 슬롯 적용 — 색상/강조. 이펙트 없으면 평범한 텍스트.
+export function NicknameText({ name, items, className }: NicknameProps)
+{
+	const effect = items?.find(i => i.slot === EquipSlot.NicknameEffect || i.kind === ItemKind.NicknameEffect);
+	const payload = parseEffectPayload(effect?.effectPayload);
+
+	if (!effect || !payload) {
+		return <span className={className}>{name}</span>;
+	}
+
+	const animCls = payload.animation ? ` nickname-effect--${payload.animation}` : '';
+
+	return (
+		<span
+			className={`nickname-effect${animCls}${className ? ` ${className}` : ''}`}
+			style={{ color: payload.color ?? payload.textColor }}
+		>
+			{name}
+		</span>
+	);
+}
+
+interface RowProps {
+	name: string;
+	items: EquippedItem[]|undefined|null;
+	className?: string;
+}
+
+// 닉네임 이펙트 + 배지를 한 줄로 (작성자 표시 공용).
+export function EquippedNickname({ name, items, className }: RowProps)
+{
+	return (
+		<span className={`inline-flex items-center gap-1 align-middle${className ? ` ${className}` : ''}`}>
+			<NicknameText name={name} items={items} />
+			<EquippedBadge items={items} />
+		</span>
+	);
+}

+ 47 - 0
components/community/PostPredictionBadge.tsx

@@ -0,0 +1,47 @@
+'use client';
+
+// 글 상세용 예측 배지 — /api/posts/{id}/prediction 을 클라이언트에서 조회해 렌더.
+// 예측이 없는 글(비종목글 포함)은 404 → 아무것도 렌더하지 않음(모든 게시판에서 안전).
+import '@/app/styles/community.scss';
+import { useEffect, useState } from 'react';
+import { fetchApi } from '@/lib/utils/client';
+import { PostPredictionResponse } from '@/types/community';
+import PredictionBadge from './PredictionBadge';
+
+interface Props {
+	postID: number;
+}
+
+export default function PostPredictionBadge({ postID }: Props)
+{
+	const [prediction, setPrediction] = useState<PostPredictionResponse|null>(null);
+
+	useEffect(() => {
+		let active = true;
+
+		(async () => {
+			try {
+				const res = await fetchApi<PostPredictionResponse>(`/api/posts/${postID}/prediction`, { silent: true });
+				if (active && res.success && res.data) {
+					setPrediction(res.data);
+				}
+			} catch {
+				// 예측 없음/조회 실패는 무시 (배지 미표시)
+			}
+		})();
+
+		return () => {
+			active = false;
+		};
+	}, [postID]);
+
+	if (!prediction) {
+		return null;
+	}
+
+	return (
+		<div className='post-prediction'>
+			<PredictionBadge prediction={prediction} variant='detail' />
+		</div>
+	);
+}

+ 37 - 0
components/community/PredictionBadge.tsx

@@ -0,0 +1,37 @@
+'use client';
+
+// D2 예측 배지 — 방향(▲▼) + 기간 + 상태(진행중/적중/실패/무효) 색상.
+// prediction prop 을 직접 받는 프레젠테이션 컴포넌트 (fetch 는 상위 서버 컴포넌트가 담당).
+import '@/app/styles/community.scss';
+import { PostPredictionResponse, DIRECTION_META, STATUS_META, HORIZON_LABEL, PredictionHorizonValue } from '@/types/community';
+
+interface Props {
+	prediction: PostPredictionResponse;
+	// detail: 상세 뷰용 큰 배지 / 기본: 목록용 작은 배지
+	variant?: 'detail'|'inline';
+	showTarget?: boolean;
+}
+
+export default function PredictionBadge({ prediction, variant = 'inline', showTarget = true }: Props)
+{
+	const dir = DIRECTION_META[prediction.direction] ?? { label: prediction.directionLabel, symbol: '—', dir: 'up' as const };
+	const status = STATUS_META[prediction.status] ?? { label: prediction.statusLabel, tone: 'pending' as const };
+	const horizonLabel = HORIZON_LABEL[prediction.horizonDays as PredictionHorizonValue] ?? `${prediction.horizonDays}영업일`;
+
+	return (
+		<span className={`prediction-badge prediction-badge--${status.tone}${variant === 'detail' ? ' prediction-badge--detail' : ''}`}>
+			<span className={`prediction-badge__dir prediction-badge__dir--${dir.dir}`}>
+				<span aria-hidden='true'>{dir.symbol}</span>
+				{dir.label}
+			</span>
+			<span className='prediction-badge__horizon'>{horizonLabel}</span>
+			{showTarget && prediction.targetPrice !== null && (
+				<span className='prediction-badge__target'>목표 {prediction.targetPrice.toLocaleString('ko-KR')}</span>
+			)}
+			<span className='prediction-badge__status'>
+				<span className='prediction-badge__dot' aria-hidden='true'></span>
+				{status.label}
+			</span>
+		</span>
+	);
+}

+ 107 - 0
components/community/StockTagContent.tsx

@@ -0,0 +1,107 @@
+'use client';
+
+// D2 $종목 태그 렌더러 — 서버가 내려준 본문(HTML) 안의 `$005930(삼성전자)` 토큰을
+// 클릭 가능한 종목 배지(/stock/005930 링크)로 치환한다.
+// 파싱은 서버가 저장 시 수행하고, 프론트는 표시 토큰을 렌더만 한다(본문 원문 불변).
+// dangerouslySetInnerHTML 로 삽입된 HTML 의 텍스트 노드만 순회해 치환(스크립트/속성 미변조).
+import '@/app/styles/community.scss';
+import { useEffect, useRef } from 'react';
+
+interface Props {
+	content: string;
+	className?: string;
+}
+
+// $ + 6자리 숫자 + (선택) (종목명). 6자리 뒤에 숫자가 이어지면 코드로 보지 않음(경계, 서버 파서와 동일).
+const TAG_PATTERN = /\$(\d{6})(?!\d)(?:\(([^)]{0,20})\))?/g;
+
+export default function StockTagContent({ content, className }: Props)
+{
+	const ref = useRef<HTMLDivElement>(null);
+
+	useEffect(() => {
+		const root = ref.current;
+		if (!root) {
+			return;
+		}
+
+		// 이미 배지로 치환된 노드는 건너뛰기 위해 앵커 내부 텍스트는 제외
+		const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
+			acceptNode(node) {
+				if (!node.nodeValue || !node.nodeValue.includes('$')) {
+					return NodeFilter.FILTER_REJECT;
+				}
+
+				// 이미 종목 태그 앵커 안이면 스킵
+				let parent = node.parentElement;
+				while (parent && parent !== root) {
+					if (parent.classList && parent.classList.contains('stock-tag')) {
+						return NodeFilter.FILTER_REJECT;
+					}
+					parent = parent.parentElement;
+				}
+
+				return NodeFilter.FILTER_ACCEPT;
+			}
+		});
+
+		const targets: Text[] = [];
+		let current = walker.nextNode();
+		while (current) {
+			targets.push(current as Text);
+			current = walker.nextNode();
+		}
+
+		for (const textNode of targets) {
+			const text = textNode.nodeValue ?? '';
+			TAG_PATTERN.lastIndex = 0;
+
+			if (!TAG_PATTERN.test(text)) {
+				continue;
+			}
+
+			TAG_PATTERN.lastIndex = 0;
+			const frag = document.createDocumentFragment();
+			let lastIndex = 0;
+			let match: RegExpExecArray|null;
+
+			while ((match = TAG_PATTERN.exec(text)) !== null) {
+				const [full, code, name] = match;
+
+				if (match.index > lastIndex) {
+					frag.appendChild(document.createTextNode(text.slice(lastIndex, match.index)));
+				}
+
+				const anchor = document.createElement('a');
+				anchor.href = `/stock/${code}`;
+				anchor.className = 'stock-tag';
+
+				const nameSpan = document.createElement('span');
+				nameSpan.textContent = name ? name : code;
+				anchor.appendChild(nameSpan);
+
+				const codeSpan = document.createElement('span');
+				codeSpan.className = 'stock-tag__code';
+				codeSpan.textContent = code;
+				anchor.appendChild(codeSpan);
+
+				frag.appendChild(anchor);
+				lastIndex = match.index + full.length;
+			}
+
+			if (lastIndex < text.length) {
+				frag.appendChild(document.createTextNode(text.slice(lastIndex)));
+			}
+
+			textNode.parentNode?.replaceChild(frag, textNode);
+		}
+	}, [content]);
+
+	return (
+		<div
+			ref={ref}
+			className={className}
+			dangerouslySetInnerHTML={{ __html: content }}
+		></div>
+	);
+}

+ 36 - 0
components/community/TierBadge.tsx

@@ -0,0 +1,36 @@
+// D2 트랙레코드 등급 배지 — 무등급/브론즈/실버/골드/황금개미.
+// 순수 프레젠테이션 (서버·클라이언트 공용, 'use client' 미선언 — 상태·이벤트 없음).
+import '@/app/styles/community.scss';
+import { TIER_META, TrackRecordTierValue } from '@/types/community';
+
+interface Props {
+	// 숫자 코드 우선. 문자열 라벨(tier)만 있을 때 fallback 표기.
+	tierCode: TrackRecordTierValue;
+	label?: string;
+}
+
+const TIER_ICON: Record<string, string> = {
+	none: '',
+	bronze: '🥉',
+	silver: '🥈',
+	gold: '🥇',
+	goldant: '🐜'
+};
+
+export default function TierBadge({ tierCode, label }: Props)
+{
+	const meta = TIER_META[tierCode];
+
+	if (!meta) {
+		return <span className='tier-badge tier-badge--none'>{label ?? '무등급'}</span>;
+	}
+
+	const icon = TIER_ICON[meta.tone];
+
+	return (
+		<span className={`tier-badge tier-badge--${meta.tone}`}>
+			{icon && <span className='tier-badge__icon' aria-hidden='true'>{icon}</span>}
+			{label ?? meta.label}
+		</span>
+	);
+}

+ 51 - 0
components/community/TrendingStocks.tsx

@@ -0,0 +1,51 @@
+// D2 트렌딩 위젯 — "지금 뜨는 종목" (글수·언급량 랭킹). SSR 프레젠테이션.
+// 서버 컴포넌트: rows prop 을 상위(page)에서 fetchTrendingStocks 로 주입.
+import '@/app/styles/community.scss';
+import Link from 'next/link';
+import { TrendingUp } from 'lucide-react';
+import { changeDirection, changeSymbol, formatNumber } from '@/lib/utils/stock';
+import { TrendingStockRow } from '@/types/community';
+
+interface Props {
+	rows: TrendingStockRow[];
+	limit?: number;
+}
+
+export default function TrendingStocks({ rows, limit = 10 }: Props)
+{
+	const list = rows.slice(0, limit);
+
+	return (
+		<div className='trending-widget'>
+			<div className='trending-widget__title'>
+				<TrendingUp size={16} strokeWidth={2} aria-hidden='true' />
+				지금 뜨는 종목
+			</div>
+
+			{list.length === 0 ? (
+				<p className='trending-widget__empty'>집계된 종목이 없습니다.</p>
+			) : (
+				<ul className='trending-widget__list'>
+					{list.map(row => {
+						const dir = changeDirection(row.changeRate);
+
+						return (
+							<li key={row.stockCode}>
+								<Link href={`/stock/${row.stockCode}?tab=discussion`} className='trending-widget__item'>
+									<span className='trending-widget__rank'>{row.rank}</span>
+									<span className='trending-widget__name'>{row.stockName ?? row.stockCode}</span>
+									{row.closePrice !== null && (
+										<span className={`trending-widget__price stock-change stock-change--${dir}`}>
+											<span aria-hidden='true'>{changeSymbol(dir)}</span> {formatNumber(row.closePrice)}
+										</span>
+									)}
+									<span className='trending-widget__posts'>글 {row.posts.toLocaleString()}</span>
+								</Link>
+							</li>
+						);
+					})}
+				</ul>
+			)}
+		</div>
+	);
+}

+ 96 - 0
lib/api/community.ts

@@ -0,0 +1,96 @@
+'use server';
+
+import { ResultDto } from '@/types/response/common';
+import {
+	StockBoardPostsResponse,
+	TrendingStocksResponse,
+	PostPredictionResponse,
+	TrackRecordResponse,
+	LeaderboardResponse,
+	CreateStockPostRequest,
+	CreateStockPostResponse,
+	EquippedItemsResponse
+} from '@/types/community';
+import { fetchJson } from '@/lib/utils/server';
+
+const JSON_HEADERS = { 'Accept': 'application/json' } as const;
+
+// 종목 가상보드 글 목록 (익명 · SSR) — GET /api/stock-boards/{code}/posts?page=
+export async function fetchStockBoardPosts(code: string, params: { page?: number; perPage?: number }): Promise<ResultDto<StockBoardPostsResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	queryParams.set('perPage', String(params.perPage ?? 20));
+
+	return await fetchJson<StockBoardPostsResponse>(`/api/stock-boards/${encodeURIComponent(code)}/posts?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 종목 트렌딩 랭킹 (익명 · SSR) — GET /api/stock-boards/trending?count=
+export async function fetchTrendingStocks(params: { count?: number } = {}): Promise<ResultDto<TrendingStocksResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('count', String(params.count ?? 20));
+
+	return await fetchJson<TrendingStocksResponse>(`/api/stock-boards/trending?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 글의 예측 메타 + 채점 상태 (익명 · SSR) — GET /api/posts/{id}/prediction
+export async function fetchPostPrediction(postID: number): Promise<ResultDto<PostPredictionResponse>>
+{
+	return await fetchJson<PostPredictionResponse>(`/api/posts/${postID}/prediction`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 회원 트랙레코드 (익명 · SSR · 회원 SID 기준) — GET /api/members/{sid}/track-record
+export async function fetchTrackRecord(sid: string): Promise<ResultDto<TrackRecordResponse>>
+{
+	return await fetchJson<TrackRecordResponse>(`/api/members/${encodeURIComponent(sid)}/track-record`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 적중률 리더보드 (익명 · SSR) — GET /api/track-records/leaderboard?page=
+export async function fetchTrackRecordLeaderboard(params: { page?: number; period?: string } = {}): Promise<ResultDto<LeaderboardResponse>>
+{
+	const queryParams = new URLSearchParams();
+
+	queryParams.set('page', String(params.page ?? 1));
+	if (params.period) queryParams.set('period', params.period);
+
+	return await fetchJson<LeaderboardResponse>(`/api/track-records/leaderboard?${queryParams.toString()}`, {
+		method: 'GET',
+		headers: JSON_HEADERS
+	});
+}
+
+// 종목 가상보드 글 작성 (JWT · SSR) — POST /api/boards/stock/posts
+export async function createStockBoardPost(body: CreateStockPostRequest): Promise<ResultDto<CreateStockPostResponse>>
+{
+	return await fetchJson<CreateStockPostResponse>('/api/boards/stock/posts', {
+		method: 'POST',
+		headers: { ...JSON_HEADERS, 'Content-Type': 'application/json' },
+		body: JSON.stringify(body)
+	});
+}
+
+// 작성자 지위 배지 배치 조회 (익명 · SSR) — 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
+	});
+}

+ 206 - 0
types/community.ts

@@ -0,0 +1,206 @@
+// D2 커뮤니티(종목게시판·예측·트랙레코드) 도메인 타입
+// Backend Application/Features/Api/Forum/StockBoard/*/Response.cs + TrackRecord/*/Response.cs 대응.
+// ⚠️ D2 enum 은 숫자로 직렬화됨 (JsonStringEnumConverter 미적용 — 확인 완료).
+//    응답 DTO 는 숫자 코드(direction/status/tierCode)와 라벨 문자열(directionLabel/statusLabel/tier)을 함께 내려준다.
+//    색상은 숫자 코드로, 표시 텍스트는 라벨 문자열을 사용한다. 라벨/색 맵은 이 파일 하단에 한 곳으로 모은다.
+
+// ── 예측 방향 (Domain PredictionDirection: 1 Up / 2 Down) ──
+export const PredictionDirection = {
+	Up: 1,
+	Down: 2
+} as const;
+export type PredictionDirectionValue = (typeof PredictionDirection)[keyof typeof PredictionDirection];
+
+// ── 예측 상태 (Domain PredictionStatus: 0 Pending / 1 Hit / 2 Miss / 3 Void) ──
+export const PredictionStatus = {
+	Pending: 0,
+	Hit: 1,
+	Miss: 2,
+	Void: 3
+} as const;
+export type PredictionStatusValue = (typeof PredictionStatus)[keyof typeof PredictionStatus];
+
+// ── 트랙레코드 배지 등급 (Domain TrackRecordTier: 0 None / 1 Bronze / 2 Silver / 3 Gold / 4 GoldAnt) ──
+export const TrackRecordTier = {
+	None: 0,
+	Bronze: 1,
+	Silver: 2,
+	Gold: 3,
+	GoldAnt: 4
+} as const;
+export type TrackRecordTierValue = (typeof TrackRecordTier)[keyof typeof TrackRecordTier];
+
+// ── 예측 기간 (5 또는 20 영업일만 허용, Backend CreatePost.AllowedHorizonDays) ──
+export const PredictionHorizon = {
+	Short: 5,
+	Long: 20
+} as const;
+export type PredictionHorizonValue = (typeof PredictionHorizon)[keyof typeof PredictionHorizon];
+
+// ── GET /api/stock-boards/{code}/posts ──
+export interface StockBoardStatsHeader {
+	stockCode: string;
+	stockName: string|null;
+	posts: number;
+	comments: number;
+	lastPostAt: string|null;
+}
+
+export interface StockBoardPostRow {
+	num: number;
+	id: number;
+	stockCode: string;
+	subject: string;
+	thumbnail: string|null;
+	name: string|null;
+	sid: string|null;
+	hasPrediction: boolean;
+	views: number;
+	likes: number;
+	comments: number;
+	images: number;
+	updatedAt: string|null;
+	createdAt: string;
+}
+
+export interface StockBoardPostsResponse {
+	total: number;
+	stats: StockBoardStatsHeader;
+	list: StockBoardPostRow[];
+}
+
+// ── GET /api/stock-boards/trending ──
+export interface TrendingStockRow {
+	rank: number;
+	stockCode: string;
+	stockName: string|null;
+	closePrice: number|null;
+	changeRate: number|null;
+	posts: number;
+	score: number;
+}
+
+export interface TrendingStocksResponse {
+	list: TrendingStockRow[];
+}
+
+// ── GET /api/posts/{id}/prediction ──
+export interface PostPredictionResponse {
+	postID: number;
+	memberID: number;
+	stockCode: string;
+	stockName: string|null;
+	direction: PredictionDirectionValue;
+	directionLabel: string;
+	basePrice: number;
+	targetPrice: number|null;
+	horizonDays: number;
+	dueDate: string;
+	status: PredictionStatusValue;
+	statusLabel: string;
+	settledPrice: number|null;
+	settledAt: string|null;
+	createdAt: string;
+}
+
+// ── GET /api/members/{sid}/track-record ──
+export interface TrackRecordResponse {
+	sid: string;
+	hasRecord: boolean;
+	tier: string;
+	tierCode: TrackRecordTierValue;
+	hitRate: number;
+	predictions: number;
+	hits: number;
+	misses: number;
+	voids: number;
+	currentStreak: number;
+	bestStreak: number;
+}
+
+// ── GET /api/track-records/leaderboard ──
+export interface LeaderboardRow {
+	rank: number;
+	sid: string;
+	name: string;
+	tier: string;
+	tierCode: TrackRecordTierValue;
+	hitRate: number;
+	predictions: number;
+	hits: number;
+	misses: number;
+	bestStreak: number;
+}
+
+export interface LeaderboardResponse {
+	total: number;
+	period: string;
+	list: LeaderboardRow[];
+}
+
+// ── POST /api/boards/stock/posts (body) ──
+export interface CreateStockPostPrediction {
+	direction: PredictionDirectionValue;
+	horizonDays: PredictionHorizonValue;
+	targetPrice?: number|null;
+}
+
+export interface CreateStockPostRequest {
+	stockCode: string;
+	subject: string;
+	content: string;
+	prediction?: CreateStockPostPrediction|null;
+}
+
+export interface CreateStockPostResponse {
+	id: number;
+}
+
+// ── GET /api/members/equipped-items?ids= (작성자 지위 배지) ──
+export interface EquippedItem {
+	slot: number;
+	kind: number;
+	productName: string;
+	effectPayload: string|null;
+	thumbnail: string|null;
+}
+
+export interface MemberEquip {
+	memberID: number;
+	items: EquippedItem[];
+}
+
+export interface EquippedItemsResponse {
+	members: MemberEquip[];
+}
+
+// ── 라벨·색 맵 (한 곳 집중) ──
+
+// 예측 방향 — 라벨/기호/방향키(색 클래스)
+export const DIRECTION_META: Record<PredictionDirectionValue, { label: string; symbol: string; dir: 'up'|'down' }> = {
+	[PredictionDirection.Up]: { label: '상승', symbol: '▲', dir: 'up' },
+	[PredictionDirection.Down]: { label: '하락', symbol: '▼', dir: 'down' }
+};
+
+// 예측 상태 — 라벨 + 상태 색 클래스 modifier (prediction-badge--{tone})
+export const STATUS_META: Record<PredictionStatusValue, { label: string; tone: 'pending'|'hit'|'miss'|'void' }> = {
+	[PredictionStatus.Pending]: { label: '진행중', tone: 'pending' },
+	[PredictionStatus.Hit]: { label: '적중', tone: 'hit' },
+	[PredictionStatus.Miss]: { label: '실패', tone: 'miss' },
+	[PredictionStatus.Void]: { label: '무효', tone: 'void' }
+};
+
+// 트랙레코드 등급 — 라벨 + 색 클래스 modifier (tier-badge--{tone})
+export const TIER_META: Record<TrackRecordTierValue, { label: string; tone: 'none'|'bronze'|'silver'|'gold'|'goldant' }> = {
+	[TrackRecordTier.None]: { label: '무등급', tone: 'none' },
+	[TrackRecordTier.Bronze]: { label: '브론즈', tone: 'bronze' },
+	[TrackRecordTier.Silver]: { label: '실버', tone: 'silver' },
+	[TrackRecordTier.Gold]: { label: '골드', tone: 'gold' },
+	[TrackRecordTier.GoldAnt]: { label: '황금개미', tone: 'goldant' }
+};
+
+// 예측 기간 라벨
+export const HORIZON_LABEL: Record<PredictionHorizonValue, string> = {
+	[PredictionHorizon.Short]: '5영업일',
+	[PredictionHorizon.Long]: '20영업일'
+};