| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156 |
- 'use client';
- // D3 레벨/보상 위젯 — 현재 등급·Exp·다음 등급 진행 + 오늘 획득(액션별 캡 잔여).
- // GET /api/members/me/level + GET /api/rewards/me/today (둘 다 JWT).
- import '@/app/styles/reward.scss';
- import { useEffect, useState } from 'react';
- import Image from 'next/image';
- import { fetchApi } from '@/lib/utils/client';
- import Loading from '@/app/component/Loading';
- import NavTabs from '../navTabs';
- import type { MemberLevelResponse, RewardsTodayResponse } from '@/types/reward';
- function LevelCard({ level }: { level: MemberLevelResponse })
- {
- // 다음 등급 요구치 대비 현재 Exp 비율 (0~100). 현재등급 요구치는 응답에 없으므로 요구치 대비로 근사.
- const hasNext = level.nextGradeRequiredExp != null && level.expToNextGrade != null;
- const remaining = level.expToNextGrade ?? 0;
- const percent = hasNext && level.nextGradeRequiredExp! > 0
- ? Math.min(100, Math.max(0, Math.round((level.exp / level.nextGradeRequiredExp!) * 100)))
- : 100;
- return (
- <div className='level-card'>
- <div className='level-card__grade'>
- {level.currentGradeImage && (
- <Image src={level.currentGradeImage} alt='' width={40} height={40} className='level-card__grade-img' />
- )}
- <div>
- <div className='level-card__grade-name' style={level.currentGradeTextColor ? { color: level.currentGradeTextColor } : undefined}>
- {level.currentGradeName ?? '등급 없음'}
- </div>
- <div className='level-card__exp'>경험치 {level.exp.toLocaleString()} XP</div>
- </div>
- </div>
- {hasNext ? (
- <>
- <div className='level-card__bar'>
- <div className='level-card__bar-fill' style={{ width: `${percent}%` }} />
- </div>
- <div className='level-card__next'>
- 다음 등급 <strong>{level.nextGradeName}</strong>까지 {remaining.toLocaleString()} XP 남음
- </div>
- </>
- ) : (
- <div className='level-card__next'>최고 등급에 도달했습니다.</div>
- )}
- </div>
- );
- }
- function RewardsToday({ rewards }: { rewards: RewardsTodayResponse })
- {
- return (
- <div className='reward-today'>
- <div className='reward-today__header'>
- <span>오늘 획득 <span className='text-xs font-normal text-neutral-400'>({rewards.rewardDate})</span></span>
- <span className='reward-today__total'>+{rewards.totalExp.toLocaleString()} XP · +{rewards.totalPoint.toLocaleString()} 토큰</span>
- </div>
- {rewards.actions.map((a) => {
- // 캡 잔여: count/exp/point 중 정의된 것을 보여준다. 전부 null 이면 무제한.
- const parts: string[] = [];
- if (a.remainingCount != null) {
- parts.push(`횟수 ${a.remainingCount.toLocaleString()}회`);
- }
- if (a.remainingExp != null) {
- parts.push(`XP ${a.remainingExp.toLocaleString()}`);
- }
- if (a.remainingPoint != null) {
- parts.push(`토큰 ${a.remainingPoint.toLocaleString()}`);
- }
- const isFull = (a.remainingCount === 0) || (a.remainingExp === 0) || (a.remainingPoint === 0);
- const capText = parts.length > 0 ? `남은 캡: ${parts.join(' · ')}` : '캡 없음';
- return (
- <div className='reward-today__row' key={a.actionType}>
- <span className='reward-today__name'>{a.actionName}</span>
- <span className='reward-today__earned'>
- {a.count.toLocaleString()}회 · +{a.expEarned.toLocaleString()} XP{a.pointEarned > 0 ? ` · +${a.pointEarned.toLocaleString()} 토큰` : ''}
- </span>
- <span className={`reward-today__cap${isFull ? ' reward-today__cap--full' : ''}`}>
- {isFull ? '오늘 한도 도달' : capText}
- </span>
- </div>
- );
- })}
- </div>
- );
- }
- export default function LevelPage()
- {
- const [level, setLevel] = useState<MemberLevelResponse|null>(null);
- const [rewards, setRewards] = useState<RewardsTodayResponse|null>(null);
- const [loading, setLoading] = useState(true);
- useEffect(() => {
- let cancelled = false;
- (async () => {
- setLoading(true);
- const [levelRes, rewardsRes] = await Promise.all([
- fetchApi<MemberLevelResponse>('/api/members/me/level', { silent: true }),
- fetchApi<RewardsTodayResponse>('/api/rewards/me/today', { silent: true })
- ]);
- if (cancelled) {
- return;
- }
- if (levelRes.success && levelRes.data) {
- setLevel(levelRes.data);
- }
- if (rewardsRes.success && rewardsRes.data) {
- setRewards(rewardsRes.data);
- }
- setLoading(false);
- })();
- return () => {
- cancelled = true;
- };
- }, []);
- 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'>
- 활동(글·댓글·추천·채팅·출석)으로 경험치(XP)와 토큰을 얻어 등급이 오릅니다. 액션별로 하루 획득 한도가 있습니다.
- </p>
- {loading ? (
- <Loading />
- ) : (
- <div className='flex flex-col gap-6'>
- {level ? (
- <LevelCard level={level} />
- ) : (
- <div className='text-center py-8 text-neutral-500'>레벨 정보를 불러올 수 없습니다.</div>
- )}
- {rewards && <RewardsToday rewards={rewards} />}
- </div>
- )}
- </div>
- </>
- );
- }
|