diff --git a/package.json b/package.json index ff92ca8..72fc7f9 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@types/node": "^20", - "@types/react": "^19.1.13", - "@types/react-dom": "^19.1.9", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.1", "autoprefixer": "^10.4.21", "eslint": "^9", "eslint-config-next": "15.3.5", diff --git a/public/favicon.ico b/public/favicon.ico index 718d6fe..327e2fd 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/src/app/reaction-test/page.tsx b/src/app/reaction-test/page.tsx index 50696f8..373eb53 100644 --- a/src/app/reaction-test/page.tsx +++ b/src/app/reaction-test/page.tsx @@ -1,29 +1,67 @@ 'use client'; import { useEffect, useMemo, useRef, useState } from 'react'; +import { + getTop10Rankings, + getMyRank, + saveReactionTimeRecord, +} from '@/libs/api/reactionRanking'; type Phase = 'idle' | 'ready' | 'go' | 'tooSoon' | 'result'; // 단일 모드: 0.5s ~ 5s const DELAY_RANGE: [number, number] = [500, 5000]; +// TOP 10 랭킹 항목 타입 +interface RankEntry { + user_id: number; + username: string; + user_best_time: string; + rank: string; +} + +// 내 랭킹 정보 타입 +interface MyRankInfo { + user_id: number; + username: string; + user_best_time: string; + best_rank: string; +} + export default function Page() { const [phase, setPhase] = useState('idle'); const [records, setRecords] = useState([]); const [current, setCurrent] = useState(null); - const [best, setBest] = useState(null); + + const [topRankings, setTopRankings] = useState([]); + const [myRankInfo, setMyRankInfo] = useState(null); const timerRef = useRef(null); const startTsRef = useRef(null); - // 최고 기록 로컬 저장 + // 랭킹 데이터를 불러오는 함수 (두 API를 동시에 호출) + const fetchAllRankings = async () => { + try { + const [top10Res, myRankRes] = await Promise.all([ + getTop10Rankings(), + getMyRank(), + ]); + + if (top10Res.success) { + setTopRankings(top10Res.data.topRankings); + } + if (myRankRes.success) { + setMyRankInfo(myRankRes.rank); + } + } catch (error) { + console.error('랭킹 정보를 불러오는데 실패했습니다:', error); + } + }; + + // 페이지가 처음 로드될 때 랭킹 정보를 불러옵니다. useEffect(() => { - const saved = localStorage.getItem('reaction-best-ms'); - if (saved) setBest(Number(saved)); + fetchAllRankings(); }, []); - useEffect(() => { - if (best != null) localStorage.setItem('reaction-best-ms', String(best)); - }, [best]); // 평균 const average = useMemo(() => { @@ -33,10 +71,7 @@ export default function Page() { }, [records]); const resetWaitingTimer = () => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } + if (timerRef.current) clearTimeout(timerRef.current); }; const handleStart = () => { @@ -54,7 +89,7 @@ export default function Page() { }, delay); }; - const handleCircleClick = () => { + const handleCircleClick = async () => { if (phase === 'ready') { // 너무 빨리 클릭 resetWaitingTimer(); @@ -66,9 +101,23 @@ export default function Page() { const rt = Math.round(performance.now() - startTsRef.current); setCurrent(rt); setRecords((prev) => [...prev, rt]); - setBest((prev) => (prev == null ? rt : Math.min(prev, rt))); setPhase('result'); startTsRef.current = null; + + // 측정 완료 후 서버에 기록 저장 및 랭킹 갱신 + try { + const saveResult = await saveReactionTimeRecord(rt); + if (saveResult.success) { + console.log('기록이 저장되었습니다.'); + if (saveResult.isNewBest) { + alert('🎉 새로운 최고 기록입니다!'); + } + // 기록 저장 후 최신 랭킹 정보를 다시 불러오기 + fetchAllRankings(); + } + } catch (error) { + console.error('기록 저장에 실패했습니다:', error); + } } }; @@ -198,25 +247,73 @@ export default function Page() { + {/* '내 순위' 카드 */}

- 🏆 최고 기록 + 📊 내 순위

-
-
- ⓘ + {myRankInfo ? ( +
+
+
+

+ 최고 기록 +

+

+ {Math.round(parseFloat(myRankInfo.user_best_time))} ms +

+
+
+

+ 전체 순위 +

+

+ #{myRankInfo.best_rank} +

+
+
+
+ {myRankInfo.username} 님 +
-
-

- {best != null ? `${best} ms` : '기록 없음'} -

-

- {best != null - ? '세션/로컬 최저 기록' - : '게임을 시작해보세요!'} -

+ ) : ( +
+ 아직 측정 기록이 없습니다.
-
+ )} +
+ + {/*'TOP 10' 랭킹 보드*/} +
+

+ 🏆 TOP 10 +

+ {topRankings.length > 0 ? ( +
    + {topRankings.map((user) => ( +
  • +
    + + {user.rank} + + + {user.username} + +
    + + {Math.round(parseFloat(user.user_best_time))} ms + +
  • + ))} +
+ ) : ( +

+ 랭킹 정보가 없습니다. +

+ )}
diff --git a/src/components/ClientHeader.tsx b/src/components/ClientHeader.tsx index c141296..f5468d4 100644 --- a/src/components/ClientHeader.tsx +++ b/src/components/ClientHeader.tsx @@ -6,6 +6,7 @@ import LoginModal from './auth/LoginModal'; import SignupModal from './auth/SignupModal'; import ConfirmModal from '@/components/ui/ConfirmModal'; import { usePathname, useRouter } from 'next/navigation'; +import { AuthUtils } from '@/libs/auth'; export default function ClientHeader() { const router = useRouter(); @@ -27,11 +28,10 @@ export default function ClientHeader() { // 새로고침 시에도 로그인 유지 useEffect(() => { - const at = localStorage.getItem('accessToken'); - const name = localStorage.getItem('userName') || undefined; - if (at) { + // AuthUtils를 사용해서 토큰 유무 확인 + if (AuthUtils.hasToken()) { setIsAuthed(true); - setUserName(name); + setUserName(localStorage.getItem('userName') || undefined); } // 탭 간 동기화: 다른 탭에서 로그아웃 시 반영 @@ -55,18 +55,17 @@ export default function ClientHeader() { password: string; }) { try { - const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/login`, - { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), - }, - ); + const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/auth/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); const data = await res.json(); if (!res.ok) throw new Error(data.error || '로그인 실패'); - localStorage.setItem('accessToken', data.data.accessToken); + // AuthUtils를 사용하여 토큰 저장 + AuthUtils.setToken(data.data.accessToken); + localStorage.setItem('refreshToken', data.data.refreshToken); if (data?.data?.user?.username) { localStorage.setItem('userName', data.data.user.username); @@ -92,7 +91,7 @@ export default function ClientHeader() { }) { try { const res = await fetch( - `${process.env.NEXT_PUBLIC_API_BASE}/api/auth/register`, + `${process.env.NEXT_PUBLIC_API_URL}/auth/register`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -113,9 +112,9 @@ export default function ClientHeader() { // 로그아웃 function handleLogout() { - localStorage.removeItem('accessToken'); - localStorage.removeItem('refreshToken'); - localStorage.removeItem('userName'); + // AuthUtils를 사용하여 한번에 토큰 삭제 + AuthUtils.removeToken(); + setIsAuthed(false); setUserName(undefined); setConfirmOpen(false); diff --git a/src/components/FaqSection.tsx b/src/components/FaqSection.tsx index 4c0d440..7de7c42 100644 --- a/src/components/FaqSection.tsx +++ b/src/components/FaqSection.tsx @@ -26,7 +26,7 @@ const faqData = [ id: 4, question: '알림 기능은 어떠한 원리로 작동하나요?', answer: - '체크타임의 알림 기능은 url 서버 시간에 rtt(왕복 시간)과 사용자 반응속도 기록을 반영하여 최적의 타이밍에 알림을 제공합니다.', + '체크타임의 알림 기능은 url 서버 시간에 rtt(왕복 시간)과 인간의 시각적 반응 한계(0.1 ms)를 반영하여, 최적의 타이밍에 알림을 제공합니다.', }, ]; diff --git a/src/libs/api/reactionRanking.ts b/src/libs/api/reactionRanking.ts new file mode 100644 index 0000000..97bb6b8 --- /dev/null +++ b/src/libs/api/reactionRanking.ts @@ -0,0 +1,67 @@ +import { AuthUtils } from '@/libs/auth'; + +const API_BASE_URL = + process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001/api'; + +// API 요청을 위한 기본 fetch 함수 +async function fetchApi(path: string, options: RequestInit = {}) { + const headers = new Headers({ + ...AuthUtils.getAuthHeaders(), + ...options.headers, + }); + + const response = await fetch(`${API_BASE_URL}${path}`, { + ...options, + headers, + }); + + // 204 No Content 상태 코드일 경우, JSON 파싱을 시도하지 않고 null을 반환합니다. + if (response.status === 204) { + return null; + } + + if (!response.ok) { + throw new Error(`API call failed: ${response.statusText}`); + } + return response.json(); +} + +/** + * 반응속도 기록을 서버에 저장합니다. + * @param refreshTime 측정된 반응속도 (ms) + */ +export const saveReactionTimeRecord = (refreshTime: number) => { + return fetchApi('/refresh-records', { + method: 'POST', + body: JSON.stringify({ refreshTime }), + }); +}; + +/** + * 내 순위 정보를 가져옵니다. + */ +export const getMyRank = () => { + return fetchApi('/refresh-records/my-rank', { + method: 'GET', + }); +}; + +/** + * 내 주변 순위 정보를 가져옵니다. + * @param range 조회할 순위 범위 + */ +export const getNearbyRankings = () => { + // 템플릿 리터럴을 사용해 range 쿼리 파라미터를 추가합니다. + return fetchApi(`/refresh-records/nearby`, { + method: 'GET', + }); +}; + +/** + * TOP 10 랭킹 정보를 가져옵니다. + */ +export const getTop10Rankings = () => { + return fetchApi('/refresh-records/stats', { + method: 'GET', + }); +};