Skip to content
Merged
Show file tree
Hide file tree
Changes from 19 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 말줄임 처리
27 changes: 27 additions & 0 deletions frontend/docs/features/hooks/useClubSuggestions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# useClubSuggestions — 자동완성 race condition 방지

자동완성 입력에서 debounce 후 클럽 목록을 조회하는 훅. React Query의 쿼리 키 단위 상태 관리를 활용해 이전 요청 응답이 늦게 돌아와도 현재 입력 기준 결과만 렌더링에 반영된다.

## 배경

기존 `ClubNameInput`은 `debounceRef` + `getClubList` 직접 호출 방식으로, 응답 순서 검증 없이 `setSuggestions`를 수행했다. 네트워크 지연 시 이전 요청 결과가 최신 입력을 덮어쓰는 race condition이 발생할 수 있었다.

## 해결 방식

컴포넌트에서 `debouncedKeyword` state를 별도로 관리하고, `useClubSuggestions(debouncedKeyword)`에 전달한다. React Query는 쿼리 키가 바뀌는 순간 이전 쿼리 결과를 무시하므로 race condition이 원천 차단된다.

```typescript
// useEffect로 300ms debounce
useEffect(() => {
const timer = setTimeout(() => setDebouncedKeyword(value.trim()), 300);
return () => clearTimeout(timer);
}, [value]);

const { data: suggestions = [] } = useClubSuggestions(debouncedKeyword);
```

## 관련 코드

- `src/hooks/Queries/useClub.ts` — `useClubSuggestions` 훅 정의 (enabled: !!keyword.trim(), staleTime: 30s)
- `src/constants/queryKeys.ts` — `queryKeys.club.suggestions(keyword)` 키 추가
- `src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx` — debounceRef 제거, useClubSuggestions 적용
30 changes: 30 additions & 0 deletions frontend/docs/features/hooks/useValidateClubName.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# useValidateClubName — 제출 검증 React Query 캐시 통합

`ClubNameInput`의 submit 경로에서 동아리 이름 존재 여부를 검증하는 훅. `useClubSuggestions`와 동일한 캐시 키를 공유해 캐시 히트 시 네트워크 요청 없이 검증한다.

## 배경

기존 `handleSubmit`은 `getClubList(trimmed)`를 직접 호출했다. 자동완성 경로(`useClubSuggestions`)가 `queryKeys.club.suggestions(keyword)` 캐시를 사용하는 반면, 제출 검증 경로는 동일 키워드에 대해 별도 요청을 발생시키고 있었다.

## 해결 방식

`queryClient.ensureQueryData`로 동일한 캐시 키를 사용한다. 캐시가 유효하면 재요청 없이 반환하고, 없으면 fetch 후 캐시에 저장한다.

```typescript
export const useValidateClubName = () => {
const queryClient = useQueryClient();
return async (name: string) => {
const { clubs } = await queryClient.ensureQueryData({
queryKey: queryKeys.club.suggestions(name),
queryFn: () => getClubList(name),
staleTime: 30 * 1000,
});
return clubs.some((c) => c.name === name);
};
};
```

## 관련 코드

- `src/hooks/Queries/useClub.ts` — `useValidateClubName` 훅 정의
- `src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx` — `handleSubmit`에서 `useValidateClubName` 사용
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
22 changes: 22 additions & 0 deletions frontend/src/apis/game.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import API_BASE_URL from '@/constants/api';
import { GameRankingResponse } from '@/types/game';
import { handleResponse } from './utils/apiHelpers';

export const postGameClick = async (clubName: string): Promise<void> => {
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.
if (!response.ok) throw new Error('클릭 요청에 실패했습니다.');
};

export const getGameRanking = async (): Promise<GameRankingResponse> => {
const response = await fetch(`${API_BASE_URL}/api/game/ranking`);
const data = await handleResponse<GameRankingResponse>(
response,
'랭킹을 불러오는데 실패했습니다.',
);
if (!data) throw new Error('랭킹을 불러오는데 실패했습니다.');
return data;
};
6 changes: 6 additions & 0 deletions frontend/src/constants/queryKeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ export const queryKeys = {
category: string,
division: string,
) => ['clubs', keyword, recruitmentStatus, category, division] as const,
suggestions: (keyword: string) =>
['clubs', 'suggestions', keyword] as const,
},
promotion: {
all: ['promotions'] as const,
Expand All @@ -36,4 +38,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;
22 changes: 22 additions & 0 deletions frontend/src/hooks/Queries/useClub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,28 @@ export const useGetCardList = ({
});
};

export const useValidateClubName = () => {
const queryClient = useQueryClient();
return async (name: string) => {
const { clubs } = await queryClient.ensureQueryData({
queryKey: queryKeys.club.suggestions(name),
queryFn: () => getClubList(name),
staleTime: 30 * 1000,
});
return clubs.some((c) => c.name === name);
};
};

export const useClubSuggestions = (keyword: string) => {
return useQuery({
queryKey: queryKeys.club.suggestions(keyword),
queryFn: () => getClubList(keyword),
enabled: !!keyword.trim(),
staleTime: 30 * 1000,
select: (data) => data.clubs.map((c) => c.name),
});
};

export const useUpdateClubDescription = () => {
const queryClient = useQueryClient();

Expand Down
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];
98 changes: 98 additions & 0 deletions frontend/src/pages/GamePage/GamePage.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
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 DesktopOnly = styled.div`
position: absolute;
right: 0;
top: 0;

${media.tablet} {
display: none;
}
`;

export const MobileOnly = styled.div`
display: none;
width: 100%;
margin-top: 32px;

${media.tablet} {
display: block;
}
`;

export const TopRow = styled.div`
position: relative;
display: flex;
justify-content: center;
width: 100%;
margin-bottom: 16px;

& > *:first-child {
text-align: center;
}

${media.tablet} {
margin-bottom: 32px;
}
`;
Loading
Loading