Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
3a611a4
feat(game): game 타입, API, React Query 훅 추가
seongwon030 Apr 16, 2026
6c2345e
feat(game): /game 라우트 추가 및 MSW 핸들러 정리
seongwon030 Apr 16, 2026
1cd039a
feat(game): GamePage 레이아웃 구현 (타이틀 중앙, 순위 우측, 버튼 하단)
seongwon030 Apr 16, 2026
e37f748
feat(game): DotTextEffect 컴포넌트 구현 (dot 색상 ripple, 랜덤 색상)
seongwon030 Apr 16, 2026
9b4297b
feat(game): ClickButton, ClubNameInput, RankingBoard 컴포넌트 구현
seongwon030 Apr 16, 2026
765ddec
docs(game): GamePage 기능 문서 추가
seongwon030 Apr 16, 2026
cd420e4
fix: 예시 텍스트 변경
seongwon030 Apr 16, 2026
8473546
feat(game): ClubNameInput 동아리명 자동완성 및 유효성 검사 추가
seongwon030 Apr 16, 2026
f906c6a
refactor: 데탑, 모바일 배치 변경
seongwon030 Apr 16, 2026
bd8245f
refactor: dot글자부분 상단으로 올림
seongwon030 Apr 16, 2026
bb6f383
refactor(game): 모바일 UX 개선 - DotTextEffect 터치/성능/크기, ClubNameInput 반응형
seongwon030 Apr 16, 2026
63044fb
fix(game): handleResponse 비-null 단언 제거 및 charColors 상수화
seongwon030 Apr 17, 2026
28dcf2d
refactor(game): ClubNameInput 자동완성 race condition 수정
seongwon030 Apr 17, 2026
9990176
feat(game): ClubNameInput 자동완성 키보드 접근성 추가
seongwon030 Apr 17, 2026
3876310
fix(game): RankingBoard 로딩 중 잘못된 초기화 시간 노출 수정
seongwon030 Apr 17, 2026
8b38b64
fix(game): DotTextEffect canvas 접근성 개선 - role 및 aria-label 추가
seongwon030 Apr 17, 2026
0e882d3
fix(game): DotTextEffect 뷰포트 리사이즈 시 폰트 크기 미반영 버그 수정
seongwon030 Apr 17, 2026
510c63e
refactor(game): ClubNameInput 제출 검증을 React Query 캐시 경로로 통합
seongwon030 Apr 17, 2026
0dd4243
refactor(game): 클릭 카운트를 서버 응답 대신 클라이언트에서 직접 누적
seongwon030 Apr 17, 2026
8fcdd1f
fix(game): ClubNameInput 드롭다운 재오픈 및 DotTextEffect 리사이즈 scale 버그 수정
seongwon030 Apr 17, 2026
1aca543
feat: mutation에 에러 콘솔 추가
seongwon030 Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions frontend/docs/features/game/game-page-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# GamePage 레이아웃 및 DotTextEffect 인터랙션 개선

## 레이아웃 구조

`TopRow`를 3-column grid(`1fr auto 1fr`)로 구성하여 타이틀을 절대 중앙에 고정하고 순위표를 오른쪽 끝에 배치.
DotTextEffect는 전체 너비 가운데, 클릭 버튼은 하단(`marginTop: 40px`)에 위치.

```
┌─────────────────────────────────────────────┐
│ [빈 공간] 동아리 클릭 배틀 [실시간 순위] │
│ │
│ [ 개 발 팀 (DotText) ] │
│ │
│ [클릭! 버튼] │
└─────────────────────────────────────────────┘
```
Comment thread
seongwon030 marked this conversation as resolved.

## DotTextEffect 색상 Ripple

마우스 커서 주변 `colorRadius(= hoverRadius * 1.8)` 범위 내 dot들이 거리 비례로 색상이 물드는 효과.

- 파워 커브 `Math.pow(dist / colorRadius, 2.5)` 적용 → 중심만 진하고 바깥은 급격히 회색으로
- 각 dot에 `charColors` 중 랜덤 색상 미리 배정 (글자 단위 → dot 단위 랜덤)
- `hoverRadius: 18`, `dotR: 1.8` (겹침 방지)

## 관련 코드

- `src/pages/GamePage/GamePage.tsx` — 레이아웃 구조 (TopRow, DotTextEffect 중앙, 버튼 하단)
- `src/pages/GamePage/GamePage.styles.ts` — TopRow grid 스타일
- `src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx` — 색상 ripple 및 랜덤 색상 로직
- `src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts` — Header column 방향 변경
- `src/pages/GamePage/components/ClickButton/ClickButton.styles.ts` — ClubLabel 말줄임 처리
9 changes: 9 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
import LegacyClubDetailPage from './pages/ClubDetailPage/LegacyClubDetailPage';
import ErrorTestPage from './pages/ErrorTestPage/ErrorTestPage';
import IntroductionPage from './pages/FestivalPage/IntroductionPage/IntroductionPage';
import GamePage from './pages/GamePage/GamePage';
import PromotionDetailPage from './pages/PromotionPage/PromotionDetailPage';
import PromotionListPage from './pages/PromotionPage/PromotionListPage';

Expand Down Expand Up @@ -201,6 +202,14 @@ const App = () => {
</ContentErrorBoundary>
}
/>
<Route
path='/game'
element={
<ContentErrorBoundary>
<GamePage />
</ContentErrorBoundary>
}
/>
{/* 개발 환경에서만 사용 가능한 에러 테스트 페이지 */}
{import.meta.env.DEV && (
<Route path='/error-test' element={<ErrorTestPage />} />
Expand Down
27 changes: 27 additions & 0 deletions frontend/src/apis/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import API_BASE_URL from '@/constants/api';
import { GameClickResponse, GameRankingResponse } from '@/types/game';
import { handleResponse } from './utils/apiHelpers';

export const postGameClick = async (
clubName: string,
): Promise<GameClickResponse> => {
const response = await fetch(`${API_BASE_URL}/api/game/click`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clubName, ctAt: new Date().toISOString() }),
});
Comment thread
seongwon030 marked this conversation as resolved.
const data = await handleResponse<GameClickResponse>(
response,
'클릭 요청에 실패했습니다.',
);
return data!;
Comment thread
seongwon030 marked this conversation as resolved.
Outdated
};

export const getGameRanking = async (): Promise<GameRankingResponse> => {
const response = await fetch(`${API_BASE_URL}/api/game/ranking`);
const data = await handleResponse<GameRankingResponse>(
response,
'랭킹을 불러오는데 실패했습니다.',
);
return data!;
};
4 changes: 4 additions & 0 deletions frontend/src/constants/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,8 @@ export const queryKeys = {
list: (type: 'WEB' | 'APP_HOME' | 'WEB_MOBILE') =>
['banner', type] as const,
},
game: {
all: ['game'] as const,
ranking: () => ['game', 'ranking'] as const,
},
} as const;
18 changes: 18 additions & 0 deletions frontend/src/hooks/Queries/useGame.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { useMutation, useQuery } from '@tanstack/react-query';
import { getGameRanking, postGameClick } from '@/apis/game';
import { queryKeys } from '@/constants/queryKeys';

export const useGameRanking = () => {
return useQuery({
queryKey: queryKeys.game.ranking(),
queryFn: getGameRanking,
refetchInterval: 2000,
staleTime: 0,
});
};

export const useClickGame = () => {
return useMutation({
mutationFn: (clubName: string) => postGameClick(clubName),
});
};
5 changes: 1 addition & 4 deletions frontend/src/mocks/handlers/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
import { promotionHandlers } from './promotion';

// 모든 MSW 핸들러를 여기에 통합
export const handlers = [
...promotionHandlers,
// 다른 핸들러들을 여기에 추가
];
export const handlers = [...promotionHandlers];
89 changes: 89 additions & 0 deletions frontend/src/pages/GamePage/GamePage.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import styled from 'styled-components';
import { media } from '@/styles/mediaQuery';

export const PageContainer = styled.div`
position: relative;
overflow: hidden;
min-height: 100vh;
background: ${({ theme }) => theme.colors.gray[100]};
display: flex;
flex-direction: column;
align-items: center;
padding: 48px 20px 80px;

${media.mobile} {
padding: 32px 16px 60px;
}
`;

export const Blob = styled.div<{
$size: number;
$top: string;
$left: string;
$color: string;
}>`
position: absolute;
width: ${({ $size }) => $size}px;
height: ${({ $size }) => $size}px;
top: ${({ $top }) => $top};
left: ${({ $left }) => $left};
background: ${({ $color }) => $color};
border-radius: 50%;
filter: blur(60px);
pointer-events: none;
z-index: 0;
`;

export const Content = styled.div`
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
max-width: 1400px;
`;

export const PageTitle = styled.h1`
font-size: 1.75rem;
font-weight: 800;
color: ${({ theme }) => theme.colors.gray[900]};
margin-bottom: 4px;

${media.mobile} {
font-size: 1.4rem;
}
`;

export const PageDescription = styled.p`
font-size: 0.95rem;
color: ${({ theme }) => theme.colors.gray[600]};
margin-top: 4px;
`;

export const TopRow = styled.div`
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: start;
width: 100%;
margin-bottom: 48px;

/* 타이틀은 가운데 열, 순위는 오른쪽 열 끝 */
& > *:first-child {
grid-column: 2;
text-align: center;
}

& > *:last-child {
grid-column: 3;
justify-self: end;
}

${media.tablet} {
display: flex;
flex-direction: column;
align-items: center;
gap: 24px;
margin-bottom: 32px;
}
`;
175 changes: 175 additions & 0 deletions frontend/src/pages/GamePage/GamePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import { useState } from 'react';
import { motion } from 'framer-motion';
import { useClickGame, useGameRanking } from '@/hooks/Queries/useGame';
import ClickButton from './components/ClickButton/ClickButton';
import ClubNameInput from './components/ClubNameInput/ClubNameInput';
import DotTextEffect from './components/DotTextEffect/DotTextEffect';
import RankingBoard from './components/RankingBoard/RankingBoard';
import * as S from './GamePage.styles';

const STORAGE_KEY = 'game_club_name';

const BLOBS = [
{
size: 320,
top: '-80px',
left: '-100px',
color: 'rgba(255, 84, 20, 0.12)',
dy: 30,
duration: 7,
},
{
size: 260,
top: '20%',
left: '75%',
color: 'rgba(255, 157, 124, 0.15)',
dy: -40,
duration: 9,
},
{
size: 200,
top: '55%',
left: '-60px',
color: 'rgba(255, 212, 50, 0.12)',
dy: 25,
duration: 11,
},
{
size: 180,
top: '70%',
left: '80%',
color: 'rgba(95, 216, 192, 0.13)',
dy: -30,
duration: 8,
},
{
size: 140,
top: '40%',
left: '45%',
color: 'rgba(112, 148, 255, 0.1)',
dy: 20,
duration: 13,
},
];

const GamePage = () => {
const [clubName, setClubName] = useState<string>(
() => sessionStorage.getItem(STORAGE_KEY) ?? '',
);
const [myClickCount, setMyClickCount] = useState(0);

const { data: rankingData } = useGameRanking();
const { mutate: clickGame } = useClickGame();

const top1Club = rankingData?.clubs[0];
Comment thread
seongwon030 marked this conversation as resolved.

const handleStart = (name: string) => {
sessionStorage.setItem(STORAGE_KEY, name);
setClubName(name);
};

const handleClick = () => {
clickGame(clubName, {
onSuccess: (data) => setMyClickCount(data.clickCount),
});
};
Comment thread
seongwon030 marked this conversation as resolved.

return (
<S.PageContainer>
{BLOBS.map((blob, i) => (
<motion.div
key={i}
style={{ position: 'absolute', zIndex: 0 }}
animate={{ y: [0, blob.dy, 0] }}
transition={{
duration: blob.duration,
repeat: Infinity,
ease: 'easeInOut',
}}
>
<S.Blob
$size={blob.size}
$top={blob.top}
$left={blob.left}
$color={blob.color}
/>
</motion.div>
))}

<S.Content>
{/* 상단: 타이틀(좌) + 실시간 순위(우) */}
<S.TopRow>
<motion.div
initial={{ opacity: 0, y: -16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<S.PageTitle>동아리 클릭 배틀</S.PageTitle>
<S.PageDescription>
우리 동아리를 응원해주세요! 클릭할수록 순위가 올라가요.
</S.PageDescription>
</motion.div>

<motion.div
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
>
<RankingBoard
ranking={rankingData?.clubs ?? []}
resetAt={rankingData?.resetAt ?? new Date().toISOString()}
Comment thread
seongwon030 marked this conversation as resolved.
Outdated
myClubName={clubName}
/>
</motion.div>
</S.TopRow>

{/* 중앙: 도트 글자 */}
{top1Club && (
<motion.div
initial={{ opacity: 0, scale: 0.92 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, delay: 0.15 }}
style={{ display: 'flex', justifyContent: 'center', width: '100%' }}
>
<DotTextEffect
text={top1Club.clubName}
fontSize={100}
spacing={4}
dotR={1.8}
hoverRadius={18}
charColors={[
'#FF5414',
'#FFB300',
'#5FD8C0',
'#7094FF',
'#D4537E',
'#EF9F27',
'#FF9D7C',
]}
/>
Comment thread
seongwon030 marked this conversation as resolved.
</motion.div>
)}

{/* 하단: 클릭 버튼 */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.4, delay: 0.25 }}
style={{ marginTop: '40px' }}
>
{!clubName ? (
<ClubNameInput onStart={handleStart} />
) : (
<ClickButton
clubName={clubName}
clickCount={myClickCount}
onClickGame={handleClick}
/>
)}
</motion.div>
</S.Content>
</S.PageContainer>
);
};

export default GamePage;
Loading
Loading