page.tsx 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. import '../style.scss';
  2. import { Metadata } from 'next';
  3. import Link from 'next/link';
  4. import PaperNav from '../_components/PaperNav';
  5. import { fetchPaperLeaderboard } from '@/lib/api/paper';
  6. import { changeDirection, changeSymbol } from '@/lib/utils/stock';
  7. import { formatBpPercent, formatBpAbs, bpDirection } from '@/lib/utils/paper';
  8. import type { PaperLeaderboardSort, PaperLeaderboardPeriod } from '@/types/paper';
  9. export const metadata: Metadata = {
  10. title: '모의투자 리더보드 — 개미투자',
  11. description: '수익률·승률·MDD 기준 모의투자 순위 (주간/월간/전체)'
  12. };
  13. const SORT_TABS: { value: PaperLeaderboardSort; label: string }[] = [
  14. { value: 'return', label: '수익률' },
  15. { value: 'winrate', label: '승률' },
  16. { value: 'mdd', label: 'MDD' }
  17. ];
  18. const PERIOD_TABS: { value: PaperLeaderboardPeriod; label: string }[] = [
  19. { value: 'week', label: '주간' },
  20. { value: 'month', label: '월간' },
  21. { value: 'all', label: '전체' }
  22. ];
  23. const PAGE_SIZE = 50;
  24. type Props = {
  25. searchParams: Promise<{
  26. sort?: string;
  27. period?: string;
  28. page?: string;
  29. }>;
  30. };
  31. export default async function PaperLeaderboardPage({ searchParams }: Props)
  32. {
  33. const query = await searchParams;
  34. const sort: PaperLeaderboardSort = SORT_TABS.some(c => c.value === query.sort) ? (query.sort as PaperLeaderboardSort) : 'return';
  35. const period: PaperLeaderboardPeriod = PERIOD_TABS.some(c => c.value === query.period) ? (query.period as PaperLeaderboardPeriod) : 'all';
  36. const page = Math.max(Number(query.page) || 1, 1);
  37. const res = await fetchPaperLeaderboard({ sort, period, page });
  38. const rows = res.success ? (res.data?.rows ?? []) : [];
  39. const buildHref = (next: { sort?: PaperLeaderboardSort; period?: PaperLeaderboardPeriod }) => {
  40. const params = new URLSearchParams();
  41. params.set('sort', next.sort ?? sort);
  42. params.set('period', next.period ?? period);
  43. return `/paper/leaderboard?${params.toString()}`;
  44. };
  45. return (
  46. <div className='paper'>
  47. <div className='paper__head'>
  48. <h1 className='paper__title'>리더보드</h1>
  49. <span className='paper__subtitle'>순위는 명예·프로필 배지만 부여됩니다 (별도 상금 없음)</span>
  50. </div>
  51. <PaperNav />
  52. <div className='paper__tab-group'>
  53. <span className='paper__tab-group-label'>정렬</span>
  54. {SORT_TABS.map(tab => (
  55. <Link
  56. key={tab.value}
  57. href={buildHref({ sort: tab.value })}
  58. className={`paper__tab${sort === tab.value ? ' paper__tab--active' : ''}`}
  59. {...(sort === tab.value ? { 'aria-current': 'page' as const } : {})}
  60. >
  61. {tab.label}
  62. </Link>
  63. ))}
  64. </div>
  65. <div className='paper__tab-group'>
  66. <span className='paper__tab-group-label'>기간</span>
  67. {PERIOD_TABS.map(tab => (
  68. <Link
  69. key={tab.value}
  70. href={buildHref({ period: tab.value })}
  71. className={`paper__tab${period === tab.value ? ' paper__tab--active' : ''}`}
  72. {...(period === tab.value ? { 'aria-current': 'page' as const } : {})}
  73. >
  74. {tab.label}
  75. </Link>
  76. ))}
  77. </div>
  78. {!res.success ? (
  79. <div className='paper__error' role='alert'>리더보드를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.</div>
  80. ) : rows.length === 0 ? (
  81. <div className='paper__empty'>아직 등재된 참여자가 없습니다.</div>
  82. ) : (
  83. <div className='paper__table-wrap'>
  84. <table className='paper__table'>
  85. <caption className='sr-only'>모의투자 리더보드 — 순위·닉네임·수익률·승률·MDD·체결수</caption>
  86. <thead>
  87. <tr>
  88. <th className='paper__th--center'>순위</th>
  89. <th className='paper__th--left'>닉네임</th>
  90. <th>수익률</th>
  91. <th>승률</th>
  92. <th>MDD</th>
  93. <th>체결수</th>
  94. </tr>
  95. </thead>
  96. <tbody>
  97. {rows.map(row => {
  98. const dir = bpDirection(row.returnBp);
  99. const symbol = changeSymbol(changeDirection(row.returnBp));
  100. return (
  101. <tr key={row.memberSID}>
  102. <td className='paper__td--center paper__rank'>{row.rank}</td>
  103. <td className='paper__td--left'>
  104. <span className='paper__nick'>
  105. {row.thumb ? (
  106. // eslint-disable-next-line @next/next/no-img-element
  107. <img src={row.thumb} alt='' className='paper__nick-thumb' />
  108. ) : null}
  109. <Link href={`/user/${row.memberSID}/paper`} className='paper__nick-name'>
  110. {row.nickname || '익명'}
  111. </Link>
  112. </span>
  113. </td>
  114. <td className={`paper__value--${dir}`}>{symbol} {formatBpPercent(row.returnBp)}</td>
  115. <td>{formatBpAbs(row.winRateBp)}</td>
  116. <td className='paper__value--down'>{formatBpAbs(row.mddBp)}</td>
  117. <td>{row.fillCount.toLocaleString()}</td>
  118. </tr>
  119. );
  120. })}
  121. </tbody>
  122. </table>
  123. </div>
  124. )}
  125. {res.success && rows.length > 0 && (
  126. <nav className='paper__actions' aria-label='페이지'>
  127. {page > 1 && (
  128. <Link href={`/paper/leaderboard?sort=${sort}&period=${period}&page=${page - 1}`} className='paper__btn paper__btn--sm'>이전</Link>
  129. )}
  130. {rows.length >= PAGE_SIZE && (
  131. <Link href={`/paper/leaderboard?sort=${sort}&period=${period}&page=${page + 1}`} className='paper__btn paper__btn--sm'>다음</Link>
  132. )}
  133. </nav>
  134. )}
  135. </div>
  136. );
  137. }