page.tsx 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156
  1. 'use client';
  2. // D3 레벨/보상 위젯 — 현재 등급·Exp·다음 등급 진행 + 오늘 획득(액션별 캡 잔여).
  3. // GET /api/members/me/level + GET /api/rewards/me/today (둘 다 JWT).
  4. import '@/app/styles/reward.scss';
  5. import { useEffect, useState } from 'react';
  6. import Image from 'next/image';
  7. import { fetchApi } from '@/lib/utils/client';
  8. import Loading from '@/app/component/Loading';
  9. import NavTabs from '../navTabs';
  10. import type { MemberLevelResponse, RewardsTodayResponse } from '@/types/reward';
  11. function LevelCard({ level }: { level: MemberLevelResponse })
  12. {
  13. // 다음 등급 요구치 대비 현재 Exp 비율 (0~100). 현재등급 요구치는 응답에 없으므로 요구치 대비로 근사.
  14. const hasNext = level.nextGradeRequiredExp != null && level.expToNextGrade != null;
  15. const remaining = level.expToNextGrade ?? 0;
  16. const percent = hasNext && level.nextGradeRequiredExp! > 0
  17. ? Math.min(100, Math.max(0, Math.round((level.exp / level.nextGradeRequiredExp!) * 100)))
  18. : 100;
  19. return (
  20. <div className='level-card'>
  21. <div className='level-card__grade'>
  22. {level.currentGradeImage && (
  23. <Image src={level.currentGradeImage} alt='' width={40} height={40} className='level-card__grade-img' />
  24. )}
  25. <div>
  26. <div className='level-card__grade-name' style={level.currentGradeTextColor ? { color: level.currentGradeTextColor } : undefined}>
  27. {level.currentGradeName ?? '등급 없음'}
  28. </div>
  29. <div className='level-card__exp'>경험치 {level.exp.toLocaleString()} XP</div>
  30. </div>
  31. </div>
  32. {hasNext ? (
  33. <>
  34. <div className='level-card__bar'>
  35. <div className='level-card__bar-fill' style={{ width: `${percent}%` }} />
  36. </div>
  37. <div className='level-card__next'>
  38. 다음 등급 <strong>{level.nextGradeName}</strong>까지 {remaining.toLocaleString()} XP 남음
  39. </div>
  40. </>
  41. ) : (
  42. <div className='level-card__next'>최고 등급에 도달했습니다.</div>
  43. )}
  44. </div>
  45. );
  46. }
  47. function RewardsToday({ rewards }: { rewards: RewardsTodayResponse })
  48. {
  49. return (
  50. <div className='reward-today'>
  51. <div className='reward-today__header'>
  52. <span>오늘 획득 <span className='text-xs font-normal text-neutral-400'>({rewards.rewardDate})</span></span>
  53. <span className='reward-today__total'>+{rewards.totalExp.toLocaleString()} XP · +{rewards.totalPoint.toLocaleString()} 토큰</span>
  54. </div>
  55. {rewards.actions.map((a) => {
  56. // 캡 잔여: count/exp/point 중 정의된 것을 보여준다. 전부 null 이면 무제한.
  57. const parts: string[] = [];
  58. if (a.remainingCount != null) {
  59. parts.push(`횟수 ${a.remainingCount.toLocaleString()}회`);
  60. }
  61. if (a.remainingExp != null) {
  62. parts.push(`XP ${a.remainingExp.toLocaleString()}`);
  63. }
  64. if (a.remainingPoint != null) {
  65. parts.push(`토큰 ${a.remainingPoint.toLocaleString()}`);
  66. }
  67. const isFull = (a.remainingCount === 0) || (a.remainingExp === 0) || (a.remainingPoint === 0);
  68. const capText = parts.length > 0 ? `남은 캡: ${parts.join(' · ')}` : '캡 없음';
  69. return (
  70. <div className='reward-today__row' key={a.actionType}>
  71. <span className='reward-today__name'>{a.actionName}</span>
  72. <span className='reward-today__earned'>
  73. {a.count.toLocaleString()}회 · +{a.expEarned.toLocaleString()} XP{a.pointEarned > 0 ? ` · +${a.pointEarned.toLocaleString()} 토큰` : ''}
  74. </span>
  75. <span className={`reward-today__cap${isFull ? ' reward-today__cap--full' : ''}`}>
  76. {isFull ? '오늘 한도 도달' : capText}
  77. </span>
  78. </div>
  79. );
  80. })}
  81. </div>
  82. );
  83. }
  84. export default function LevelPage()
  85. {
  86. const [level, setLevel] = useState<MemberLevelResponse|null>(null);
  87. const [rewards, setRewards] = useState<RewardsTodayResponse|null>(null);
  88. const [loading, setLoading] = useState(true);
  89. useEffect(() => {
  90. let cancelled = false;
  91. (async () => {
  92. setLoading(true);
  93. const [levelRes, rewardsRes] = await Promise.all([
  94. fetchApi<MemberLevelResponse>('/api/members/me/level', { silent: true }),
  95. fetchApi<RewardsTodayResponse>('/api/rewards/me/today', { silent: true })
  96. ]);
  97. if (cancelled) {
  98. return;
  99. }
  100. if (levelRes.success && levelRes.data) {
  101. setLevel(levelRes.data);
  102. }
  103. if (rewardsRes.success && rewardsRes.data) {
  104. setRewards(rewardsRes.data);
  105. }
  106. setLoading(false);
  107. })();
  108. return () => {
  109. cancelled = true;
  110. };
  111. }, []);
  112. return (
  113. <>
  114. <NavTabs />
  115. <div className='mx-auto px-4 sm:px-8 pb-8'>
  116. <h1 className='text-2xl font-bold mb-1'>레벨 · 보상</h1>
  117. <p className='text-sm text-neutral-500 mb-4'>
  118. 활동(글·댓글·추천·채팅·출석)으로 경험치(XP)와 토큰을 얻어 등급이 오릅니다. 액션별로 하루 획득 한도가 있습니다.
  119. </p>
  120. {loading ? (
  121. <Loading />
  122. ) : (
  123. <div className='flex flex-col gap-6'>
  124. {level ? (
  125. <LevelCard level={level} />
  126. ) : (
  127. <div className='text-center py-8 text-neutral-500'>레벨 정보를 불러올 수 없습니다.</div>
  128. )}
  129. {rewards && <RewardsToday rewards={rewards} />}
  130. </div>
  131. )}
  132. </div>
  133. </>
  134. );
  135. }