diff --git a/frontend/docs/features/game/game-effects-darkmode.md b/frontend/docs/features/game/game-effects-darkmode.md new file mode 100644 index 000000000..daa1a3885 --- /dev/null +++ b/frontend/docs/features/game/game-effects-darkmode.md @@ -0,0 +1,50 @@ +# GamePage 폭죽 이펙트 및 다크모드 토글 + +## 반응형 레이아웃 수정 + +순위표(`RankingBoard`)는 데스크탑에서 `position: absolute`로 우측 상단에 떠 있다. +전환 기준이 `media.tablet`(≤700px)이던 탓에 700~1280px 구간에서 중앙 정렬된 타이틀과 겹쳤다. + +- `DesktopOnly`/`MobileOnly` 전환 기준을 `media.laptop`(≤1280px)로 변경 → 태블릿·랩탑 폭에서는 순위표가 하단으로 내려감 +- `RankingBoard.Wrapper`에 `margin: 0 auto`를 추가해 좁은 폭에서 중앙 정렬 +- 배경 blob 그라데이션(`BLOBS`, `Blob`)은 제거해 배경을 단순화 + +## 클릭 폭죽 이펙트 + +클릭 버튼을 누를 때마다 버튼 중심에서 파티클이 방사형으로 터진다. + +- 매 클릭마다 `bursts` state에 id를 추가하고 일정 시간 후 자동 제거 +- 파티클은 원형 빛 입자 + 회전하는 색종이(confetti) 혼합, `box-shadow`로 글로우 +- 포물선 궤적(중력) + `rotate`로 떨어지는 느낌 + +## 100회 단위 배경 폭죽 + +랭킹의 **어떤 동아리든** 누적 클릭수가 100단위를 넘기면 화면 전체에 대형 폭죽을 발사한다. + +- 2초 폴링마다 직전 카운트와 비교: `floor(cur/100) > floor(prev/100)`이면 트리거 +- 첫 로드 시에는 베이스라인만 기록(기존 누적값으로 오발 방지) +- `BackgroundFirework`는 `position: fixed`로 화면 전체를 덮고 `zIndex: 0`으로 콘텐츠 뒤에 깔림 +- 폴링 간격 사이의 다중/동시 돌파는 1회 발사로 합쳐짐(의도된 단순화) + +## 다크모드 토글 + +GamePage 한정 다크모드. 전역 테마 오버라이드 대신 `$dark` prop을 내려 색을 반전한다. + +- 토글 상태는 `sessionStorage`(`game_dark_mode`)에 영속화 +- 배경 위에 직접 올라가는 요소를 반전: 배경, 타이틀, 설명, dot(`dotColor` prop), 랭킹 제목/EmptyMessage, 클럽 라벨/카운트 라벨 +- `ClubNameInput`(제목/입력창/드롭다운)과 `RankingBoard` 순위 카드도 다크 서피스로 전환 — 내 동아리 카드만 주황 틴트+보더 유지 +- 토글 UI는 인라인 SVG 해/달 아이콘 + 슬라이딩 knob 스위치(`translateX`) + +### 라이트모드 리더보드 카드 구분 + +페이지 배경과 일반 순위 카드 배경이 둘 다 `gray[100]`(#F5F5F5)로 동일해 카드가 배경에 묻혔다. +→ 일반 카드를 흰색(`#FFFFFF`) + `gray[300]` 보더 + 옅은 그림자로 분리. 내 동아리 카드는 기존 핑크+주황 보더 유지. + +## 관련 코드 + +- `src/pages/GamePage/GamePage.tsx` — 다크모드 state·토글, SVG 아이콘, 100회 배경 폭죽 트리거, blob 제거 +- `src/pages/GamePage/GamePage.styles.ts` — `$dark` 스타일, 토글 스위치, 반응형 기준 변경, blob 제거 +- `src/pages/GamePage/components/BackgroundFirework/BackgroundFirework.tsx` — 화면 전체 대형 폭죽 (신규) +- `src/pages/GamePage/components/ClickButton/` — 클릭 폭죽 파티클, `isDark` prop +- `src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx` — `dotColor` prop화 +- `src/pages/GamePage/components/RankingBoard/` — `margin: 0 auto`, `$dark` prop diff --git a/frontend/docs/features/game/game-tracking.md b/frontend/docs/features/game/game-tracking.md new file mode 100644 index 000000000..43369eae8 --- /dev/null +++ b/frontend/docs/features/game/game-tracking.md @@ -0,0 +1,26 @@ +# GamePage 트래킹 추가 + +릴리즈 전 점검 결과 GamePage에는 페이지뷰를 포함해 트래킹이 전혀 없었다. 다른 페이지와 동일한 패턴으로 최소한의 트래킹을 추가했다. + +## 페이지뷰 + +다른 페이지들(`PromotionListPage`, `BuskingPage` 등)과 동일하게 `useTrackPageView`로 진입·체류시간을 추적한다. + +- `PAGE_VIEW.GAME_PAGE` 추가 +- `GamePage.tsx`에서 `useTrackPageView(PAGE_VIEW.GAME_PAGE)` 호출 + +## 게임 시작 버튼 + +`ClubNameInput`에서 동아리명 검증(`validateClubName`)이 **성공한 경우에만** 이벤트를 보낸다. 존재하지 않는 동아리를 입력한 실패 케이스는 노이즈라 제외했다. + +- `USER_EVENT.GAME_START_BUTTON_CLICKED` 추가, `clubName`을 함께 전송해 어떤 동아리로 유입됐는지 추적 + +## 제외한 항목 + +- 클릭 버튼(`ClickButton`)과 추천 동아리 목록 선택(`handleSelect`)은 의도적으로 트래킹하지 않음 (클릭 버튼은 과도한 이벤트 발생 우려로 제외 요청) + +## 관련 코드 + +- `src/constants/eventName.ts` — `PAGE_VIEW.GAME_PAGE`, `USER_EVENT.GAME_START_BUTTON_CLICKED` +- `src/pages/GamePage/GamePage.tsx` — 페이지뷰 트래킹 +- `src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx` — 게임 시작 버튼 트래킹 diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index 2992e4562..e0bcf6d79 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -72,6 +72,9 @@ export const USER_EVENT = { PROMOTION_CLUB_CTA_CLICKED: 'Promotion Club CTA Clicked', WEBVIEW_SUBSCRIBE_TOGGLED: 'Webview Subscribe Toggled', + + // 클릭배틀 게임 + GAME_START_BUTTON_CLICKED: 'Game Start Button Clicked', } as const; export const WEBVIEW_LINK_TARGET = { @@ -135,6 +138,7 @@ export const PAGE_VIEW = { DAEDONG2026_BUSKING_PAGE: '2026 대동제 버스킹 시간표 페이지', PROMOTION_LIST_PAGE: '홍보 목록 페이지', PROMOTION_DETAIL_PAGE: '홍보 상세 페이지', + GAME_PAGE: 'GamePage', WEBVIEW_MAIN_PAGE: 'WebviewMainPage', diff --git a/frontend/src/pages/GamePage/GamePage.styles.ts b/frontend/src/pages/GamePage/GamePage.styles.ts index 0ecd59142..e68275238 100644 --- a/frontend/src/pages/GamePage/GamePage.styles.ts +++ b/frontend/src/pages/GamePage/GamePage.styles.ts @@ -1,37 +1,65 @@ import styled from 'styled-components'; import { media } from '@/styles/mediaQuery'; -export const PageContainer = styled.div` +export const PageContainer = styled.div<{ $dark: boolean }>` position: relative; overflow: hidden; min-height: 100vh; - background: ${({ theme }) => theme.colors.gray[100]}; + background: ${({ $dark, theme }) => + $dark ? '#111111' : theme.colors.gray[100]}; display: flex; flex-direction: column; align-items: center; padding: 48px 20px 80px; + transition: background 0.3s; ${media.mobile} { padding: 32px 16px 60px; } `; -export const Blob = styled.div<{ - $size: number; - $top: string; - $left: string; - $color: string; -}>` +export const ToggleBar = styled.div` + display: flex; + justify-content: flex-end; + width: 100%; + margin-bottom: 12px; +`; + +export const ToggleSwitch = styled.button<{ $dark: boolean }>` + position: relative; + width: 60px; + height: 32px; + flex-shrink: 0; + padding: 0; + border-radius: 999px; + border: 1px solid + ${({ $dark, theme }) => ($dark ? '#3A3A4A' : theme.colors.gray[300])}; + background: ${({ $dark, theme }) => + $dark ? '#262633' : theme.colors.gray[200]}; + cursor: pointer; + transition: + background 0.25s, + border-color 0.25s; +`; + +export const ToggleKnob = styled.span<{ $dark: boolean }>` position: absolute; - width: ${({ $size }) => $size}px; - height: ${({ $size }) => $size}px; - top: ${({ $top }) => $top}; - left: ${({ $left }) => $left}; - background: ${({ $color }) => $color}; + top: 3px; + left: 3px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; border-radius: 50%; - filter: blur(60px); - pointer-events: none; - z-index: 0; + background: ${({ $dark }) => ($dark ? '#111111' : '#FFFFFF')}; + color: ${({ $dark }) => ($dark ? '#FFD432' : '#FF9500')}; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25); + transform: translateX(${({ $dark }) => ($dark ? '28px' : '0')}); + transition: + transform 0.25s ease, + background 0.25s, + color 0.25s; `; export const Content = styled.div` @@ -44,10 +72,10 @@ export const Content = styled.div` max-width: 1400px; `; -export const PageTitle = styled.h1` +export const PageTitle = styled.h1<{ $dark: boolean }>` font-size: 1.75rem; font-weight: 800; - color: ${({ theme }) => theme.colors.gray[900]}; + color: ${({ $dark, theme }) => ($dark ? '#FFFFFF' : theme.colors.gray[900])}; margin-bottom: 4px; ${media.mobile} { @@ -55,9 +83,10 @@ export const PageTitle = styled.h1` } `; -export const PageDescription = styled.p` +export const PageDescription = styled.p<{ $dark: boolean }>` font-size: 0.95rem; - color: ${({ theme }) => theme.colors.gray[600]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[500] : theme.colors.gray[600]}; margin-top: 4px; `; @@ -66,7 +95,7 @@ export const DesktopOnly = styled.div` right: 0; top: 0; - ${media.tablet} { + ${media.laptop} { display: none; } `; @@ -74,9 +103,9 @@ export const DesktopOnly = styled.div` export const MobileOnly = styled.div` display: none; width: 100%; - margin-top: 32px; + margin-top: 64px; - ${media.tablet} { + ${media.laptop} { display: block; } `; diff --git a/frontend/src/pages/GamePage/GamePage.tsx b/frontend/src/pages/GamePage/GamePage.tsx index c350bd90a..2b9bd0ca0 100644 --- a/frontend/src/pages/GamePage/GamePage.tsx +++ b/frontend/src/pages/GamePage/GamePage.tsx @@ -1,211 +1,221 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { motion } from 'framer-motion'; -import { useClickGame, useGameRanking } from '@/hooks/Queries/useGame'; +import WebviewTopBar from '@/components/common/WebviewTopBar/WebviewTopBar'; +import { PAGE_VIEW } from '@/constants/eventName'; +import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView'; +import { useGameRanking } from '@/hooks/Queries/useGame'; +import isInAppWebView from '@/utils/isInAppWebView'; +import BackgroundFirework from './components/BackgroundFirework/BackgroundFirework'; 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'; +import { useBatchedClick } from './hooks/useBatchedClick'; 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 DARK_KEY = 'game_dark_mode'; +const MILESTONE_UNIT = 100; + +const SunIcon = () => ( + + + + + + + + + + + +); + +const MoonIcon = () => ( + + + +); const GamePage = () => { const [clubName, setClubName] = useState( () => sessionStorage.getItem(STORAGE_KEY) ?? '', ); - const pendingRef = useRef(0); - const timerRef = useRef | null>(null); + const [bgBursts, setBgBursts] = useState([]); + const [isDark, setIsDark] = useState( + () => sessionStorage.getItem(DARK_KEY) === 'true', + ); const { data: rankingData } = useGameRanking(); - const { mutate: clickGame } = useClickGame(); + const handleClick = useBatchedClick(clubName); + + useTrackPageView(PAGE_VIEW.GAME_PAGE); const top1Club = rankingData?.clubs[0]; - const flush = useCallback( - (name: string) => { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = null; - const count = pendingRef.current; - if (count === 0) return; - pendingRef.current = 0; - clickGame({ clubName: name, count }); - }, - [clickGame], - ); + // 직전 카운트와 비교해 어떤 동아리든 100단위를 넘기면 배경 폭죽 발사 + const prevCountsRef = useRef>(new Map()); + const initializedRef = useRef(false); + const burstIdRef = useRef(0); - const flushRef = useRef(flush); - const clubNameRef = useRef(clubName); - useEffect(() => { - flushRef.current = flush; - }, [flush]); useEffect(() => { - clubNameRef.current = clubName; - }, [clubName]); + const clubs = rankingData?.clubs; + if (!clubs) return; - useEffect(() => { - return () => { - flushRef.current(clubNameRef.current); - }; - }, []); + if (!initializedRef.current) { + clubs.forEach((c) => prevCountsRef.current.set(c.clubName, c.clickCount)); + initializedRef.current = true; + return; + } + + const crossed = clubs.some((c) => { + const prev = prevCountsRef.current.get(c.clubName) ?? 0; + return ( + Math.floor(c.clickCount / MILESTONE_UNIT) > + Math.floor(prev / MILESTONE_UNIT) + ); + }); + + clubs.forEach((c) => prevCountsRef.current.set(c.clubName, c.clickCount)); + + if (crossed) { + const id = burstIdRef.current++; + setBgBursts((prev) => [...prev, id]); + setTimeout(() => { + setBgBursts((prev) => prev.filter((b) => b !== id)); + }, 2200); + } + }, [rankingData]); const handleStart = (name: string) => { sessionStorage.setItem(STORAGE_KEY, name); setClubName(name); }; - const handleClick = useCallback(() => { - pendingRef.current += 1; - - if (pendingRef.current >= 5) { - flush(clubName); - } else { - if (timerRef.current) clearTimeout(timerRef.current); - timerRef.current = setTimeout(() => flush(clubName), 500); - } - }, [clubName, flush]); + const toggleDark = () => { + setIsDark((prev) => { + const next = !prev; + sessionStorage.setItem(DARK_KEY, String(next)); + return next; + }); + }; return ( - - {BLOBS.map((blob, i) => ( - - - - ))} - - - {/* 상단: 타이틀(좌) + 실시간 순위(우) */} - + <> + {isInAppWebView() && } + + {bgBursts.map((id) => ( + + ))} + + + + + + {isDark ? : } + + + + + {/* 상단: 타이틀(좌) + 실시간 순위(우) */} + + + 동아리 클릭 배틀 + + 우리 동아리를 응원해주세요! 클릭할수록 순위가 올라가요. + + + + + + + + + + + {/* 중앙: 도트 글자 */} + {top1Club && ( + + + + )} + + {/* 하단: 클릭 버튼 */} - 동아리 클릭 배틀 - - 우리 동아리를 응원해주세요! 클릭할수록 순위가 올라가요. - + {!clubName ? ( + + ) : ( + + )} - + {/* 모바일 전용: 순위표 최하단 */} + - - - - {/* 중앙: 도트 글자 */} - {top1Club && ( - - - - )} - - {/* 하단: 클릭 버튼 */} - - {!clubName ? ( - - ) : ( - - )} - - - {/* 모바일 전용: 순위표 최하단 */} - - - - - - - + + + + ); }; diff --git a/frontend/src/pages/GamePage/components/BackgroundFirework/BackgroundFirework.tsx b/frontend/src/pages/GamePage/components/BackgroundFirework/BackgroundFirework.tsx new file mode 100644 index 000000000..24b816a71 --- /dev/null +++ b/frontend/src/pages/GamePage/components/BackgroundFirework/BackgroundFirework.tsx @@ -0,0 +1,95 @@ +import { memo, useMemo } from 'react'; +import { motion } from 'framer-motion'; + +const PARTICLE_COUNT = 70; +const PARTICLE_COLORS = [ + '#FF5414', + '#FFD432', + '#FF9D7C', + '#5FD8C0', + '#7094FF', + '#FF5FA2', + '#A06BFF', +]; + +const BackgroundFirework = () => { + const particles = useMemo( + () => + Array.from({ length: PARTICLE_COUNT }, (_, i) => { + const angle = + (i / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.3; + const distance = 280 + Math.random() * 380; + const size = 8 + Math.random() * 16; + const isConfetti = Math.random() > 0.45; + return { + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + color: + PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)], + size, + isConfetti, + spin: (Math.random() - 0.5) * 1080, + duration: 1.3 + Math.random() * 0.6, + }; + }), + [], + ); + + return ( +
+ {/* 중앙 플래시 링 */} + + {particles.map((p, i) => ( + + ))} +
+ ); +}; + +export default memo(BackgroundFirework); diff --git a/frontend/src/pages/GamePage/components/ClickButton/ClickButton.styles.ts b/frontend/src/pages/GamePage/components/ClickButton/ClickButton.styles.ts index dce5c7a96..6f94c98f2 100644 --- a/frontend/src/pages/GamePage/components/ClickButton/ClickButton.styles.ts +++ b/frontend/src/pages/GamePage/components/ClickButton/ClickButton.styles.ts @@ -13,10 +13,18 @@ export const Wrapper = styled.div` gap: 16px; `; -export const ClubLabel = styled.p` +export const ButtonArea = styled.div` + position: relative; + display: flex; + align-items: center; + justify-content: center; +`; + +export const ClubLabel = styled.p<{ $dark: boolean }>` font-size: 1rem; font-weight: 600; - color: ${({ theme }) => theme.colors.gray[700]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[400] : theme.colors.gray[700]}; max-width: 180px; overflow: hidden; text-overflow: ellipsis; @@ -71,9 +79,10 @@ export const Count = styled.p` color: ${({ theme }) => theme.colors.primary[900]}; `; -export const CountLabel = styled.span` +export const CountLabel = styled.span<{ $dark: boolean }>` font-size: 1rem; font-weight: 500; - color: ${({ theme }) => theme.colors.gray[600]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[400] : theme.colors.gray[600]}; margin-left: 4px; `; diff --git a/frontend/src/pages/GamePage/components/ClickButton/ClickButton.tsx b/frontend/src/pages/GamePage/components/ClickButton/ClickButton.tsx index a77457cad..6a81af039 100644 --- a/frontend/src/pages/GamePage/components/ClickButton/ClickButton.tsx +++ b/frontend/src/pages/GamePage/components/ClickButton/ClickButton.tsx @@ -1,44 +1,133 @@ -import { memo, useState } from 'react'; +import { memo, useEffect, useMemo, useRef, useState } from 'react'; import { AnimatePresence, motion } from 'framer-motion'; import * as S from './ClickButton.styles'; interface ClickButtonProps { clubName: string; onClickGame: () => void; + isDark?: boolean; } -const ClickButton = ({ clubName, onClickGame }: ClickButtonProps) => { +const PARTICLE_COUNT = 36; +const PARTICLE_COLORS = [ + '#FF5414', + '#FFD432', + '#FF9D7C', + '#FFFFFF', + '#5FD8C0', + '#7094FF', + '#FF5FA2', +]; + +const Firework = () => { + const particles = useMemo( + () => + Array.from({ length: PARTICLE_COUNT }, (_, i) => { + const angle = + (i / PARTICLE_COUNT) * Math.PI * 2 + (Math.random() - 0.5) * 0.4; + const distance = 160 + Math.random() * 130; + const size = 6 + Math.random() * 11; + const isConfetti = Math.random() > 0.5; + return { + x: Math.cos(angle) * distance, + y: Math.sin(angle) * distance, + color: + PARTICLE_COLORS[Math.floor(Math.random() * PARTICLE_COLORS.length)], + size, + isConfetti, + spin: (Math.random() - 0.5) * 720, + duration: 0.7 + Math.random() * 0.4, + }; + }), + [], + ); + + return ( + <> + {particles.map((p, i) => ( + + ))} + + ); +}; + +const ClickButton = ({ + clubName, + onClickGame, + isDark = false, +}: ClickButtonProps) => { const [clickCount, setClickCount] = useState(0); + const [bursts, setBursts] = useState([]); + const burstIdRef = useRef(0); + const timersRef = useRef[]>([]); + const lastClickRef = useRef(0); + + useEffect(() => { + return () => { + timersRef.current.forEach(clearTimeout); + timersRef.current = []; + }; + }, []); const handleClick = () => { + const now = Date.now(); + if (now - lastClickRef.current < 100) return; + lastClickRef.current = now; + setClickCount((prev) => prev + 1); onClickGame(); + + const id = burstIdRef.current++; + setBursts((prev) => [...prev, id]); + const timer = setTimeout(() => { + setBursts((prev) => prev.filter((b) => b !== id)); + }, 1200); + timersRef.current.push(timer); }; return ( - {clubName} - - 클릭! - + {clubName} + + {bursts.map((id) => ( + + ))} + + 클릭! + + @@ -53,7 +142,7 @@ const ClickButton = ({ clubName, onClickGame }: ClickButtonProps) => { {clickCount.toLocaleString()} - + ); diff --git a/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.styles.ts b/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.styles.ts index 300135064..3c3124cf6 100644 --- a/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.styles.ts +++ b/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.styles.ts @@ -10,10 +10,10 @@ export const Wrapper = styled.div` max-width: 360px; `; -export const Title = styled.h2` +export const Title = styled.h2<{ $dark: boolean }>` font-size: 1.25rem; font-weight: 700; - color: ${({ theme }) => theme.colors.gray[900]}; + color: ${({ $dark, theme }) => ($dark ? '#FFFFFF' : theme.colors.gray[900])}; `; export const InputContainer = styled.div` @@ -27,16 +27,26 @@ export const InputRow = styled.div` width: 100%; `; -export const Input = styled.input<{ $hasError: boolean }>` +export const Input = styled.input<{ $hasError: boolean; $dark: boolean }>` flex: 1; padding: 12px 16px; font-size: 1rem; + background: ${({ $dark }) => ($dark ? '#1E1E2A' : '#FFFFFF')}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[100] : theme.colors.gray[900]}; border: 2px solid - ${({ theme, $hasError }) => - $hasError ? theme.colors.primary[900] : theme.colors.gray[300]}; + ${({ theme, $hasError, $dark }) => + $hasError + ? theme.colors.primary[900] + : $dark + ? '#3A3A4A' + : theme.colors.gray[300]}; border-radius: 10px; outline: none; - transition: border-color 0.2s; + transition: + border-color 0.2s, + background 0.2s, + color 0.2s; min-width: 0; &:focus { @@ -44,7 +54,8 @@ export const Input = styled.input<{ $hasError: boolean }>` } &::placeholder { - color: ${({ theme }) => theme.colors.gray[500]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[600] : theme.colors.gray[500]}; } ${media.mobile} { @@ -80,13 +91,14 @@ export const StartButton = styled.button` } `; -export const Dropdown = styled.ul` +export const Dropdown = styled.ul<{ $dark: boolean }>` position: absolute; top: calc(100% + 4px); left: 0; right: 0; - background: #fff; - border: 1.5px solid ${({ theme }) => theme.colors.gray[200]}; + background: ${({ $dark }) => ($dark ? '#1E1E2A' : '#fff')}; + border: 1.5px solid + ${({ $dark, theme }) => ($dark ? '#3A3A4A' : theme.colors.gray[200])}; border-radius: 10px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08); list-style: none; @@ -95,19 +107,22 @@ export const Dropdown = styled.ul` z-index: 10; `; -export const DropdownItem = styled.li` +export const DropdownItem = styled.li<{ $dark: boolean }>` padding: 10px 16px; font-size: 0.95rem; - color: ${({ theme }) => theme.colors.gray[800]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[200] : theme.colors.gray[800]}; cursor: pointer; transition: background 0.15s; &:hover { - background: ${({ theme }) => theme.colors.gray[100]}; + background: ${({ $dark, theme }) => + $dark ? '#2A2A38' : theme.colors.gray[100]}; } & + & { - border-top: 1px solid ${({ theme }) => theme.colors.gray[100]}; + border-top: 1px solid + ${({ $dark, theme }) => ($dark ? '#3A3A4A' : theme.colors.gray[100])}; } `; diff --git a/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx b/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx index ca65ef950..7785c8360 100644 --- a/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx +++ b/frontend/src/pages/GamePage/components/ClubNameInput/ClubNameInput.tsx @@ -1,4 +1,6 @@ import { useEffect, useId, useRef, useState } from 'react'; +import { USER_EVENT } from '@/constants/eventName'; +import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; import { useClubSuggestions, useValidateClubName, @@ -7,10 +9,12 @@ import * as S from './ClubNameInput.styles'; interface ClubNameInputProps { onStart: (clubName: string) => void; + isDark?: boolean; } -const ClubNameInput = ({ onStart }: ClubNameInputProps) => { +const ClubNameInput = ({ onStart, isDark = false }: ClubNameInputProps) => { const validateClubName = useValidateClubName(); + const trackEvent = useMixpanelTrack(); const [value, setValue] = useState(''); const [debouncedKeyword, setDebouncedKeyword] = useState(''); const [error, setError] = useState(''); @@ -62,6 +66,7 @@ const ClubNameInput = ({ onStart }: ClubNameInputProps) => { setError('존재하지 않는 동아리입니다.'); return; } + trackEvent(USER_EVENT.GAME_START_BUTTON_CLICKED, { clubName: trimmed }); onStart(trimmed); } catch { setError('동아리 확인 중 오류가 발생했습니다.'); @@ -99,7 +104,7 @@ const ClubNameInput = ({ onStart }: ClubNameInputProps) => { return ( - 동아리명을 입력해주세요 + 동아리명을 입력해주세요 { maxLength={30} autoFocus $hasError={!!error} + $dark={isDark} role='combobox' aria-autocomplete='list' aria-expanded={isOpen} @@ -125,7 +131,7 @@ const ClubNameInput = ({ onStart }: ClubNameInputProps) => { {isOpen && ( - + {suggestions.map((name, index) => ( { aria-selected={index === highlightedIndex} onMouseDown={(e) => e.preventDefault()} onClick={() => handleSelect(name)} + $dark={isDark} > {name} diff --git a/frontend/src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx b/frontend/src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx index 444958d53..32cc7d894 100644 --- a/frontend/src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx +++ b/frontend/src/pages/GamePage/components/DotTextEffect/DotTextEffect.tsx @@ -21,10 +21,9 @@ interface DotTextEffectProps { charGap?: number; hoverRadius?: number; sweepSpeed?: number; + dotColor?: string; } -const DOT_COLOR = '#000000'; - function buildDots( text: string, fontSize: number, @@ -81,6 +80,7 @@ const DotTextEffect = ({ charGap = 14, hoverRadius = 28, sweepSpeed = 0.12, + dotColor = '#000000', }: DotTextEffectProps) => { const [isMobile, setIsMobile] = useState(mobileQuery.matches); const isMobileRef = useRef(mobileQuery.matches); @@ -157,7 +157,7 @@ const DotTextEffect = ({ // 인터랙션 없을 때: 전체 dot을 단일 path로 배치 렌더 (성능 최적화) if (inactive && ds.every((d) => !d.swept)) { ctx.beginPath(); - ctx.fillStyle = DOT_COLOR; + ctx.fillStyle = dotColor; for (const d of ds) { ctx.moveTo(d.ox + dotR, d.oy); ctx.arc(d.ox, d.oy, dotR, 0, Math.PI * 2); @@ -216,7 +216,7 @@ const DotTextEffect = ({ // non-swept dots: 단일 path 배치 렌더 ctx.beginPath(); - ctx.fillStyle = DOT_COLOR; + ctx.fillStyle = dotColor; for (const d of ds) { if (!d.swept) { ctx.moveTo(d.x + dotR, d.y); @@ -229,7 +229,7 @@ const DotTextEffect = ({ for (const { d, renderT } of sweptDots) { ctx.beginPath(); ctx.arc(d.x, d.y, dotR * (1 + (1 - renderT) * 0.8), 0, Math.PI * 2); - ctx.fillStyle = DOT_COLOR; + ctx.fillStyle = dotColor; ctx.fill(); } @@ -262,6 +262,7 @@ const DotTextEffect = ({ charGap, hoverRadius, sweepSpeed, + dotColor, isMobile, ]); diff --git a/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts b/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts index bea904909..89288bcea 100644 --- a/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts +++ b/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.styles.ts @@ -3,6 +3,7 @@ import styled from 'styled-components'; export const Wrapper = styled.div` width: 100%; max-width: 480px; + margin: 0 auto; `; export const Header = styled.div` @@ -12,15 +13,10 @@ export const Header = styled.div` margin-bottom: 12px; `; -export const Title = styled.h3` +export const Title = styled.h3<{ $dark: boolean }>` font-size: 1.125rem; font-weight: 700; - color: ${({ theme }) => theme.colors.gray[900]}; -`; - -export const ResetInfo = styled.p` - font-size: 0.75rem; - color: ${({ theme }) => theme.colors.gray[600]}; + color: ${({ $dark, theme }) => ($dark ? '#FFFFFF' : theme.colors.gray[900])}; `; export const List = styled.ol` @@ -30,16 +26,28 @@ export const List = styled.ol` gap: 8px; `; -export const Item = styled.div<{ $isMe: boolean; $rank: number }>` +export const Item = styled.div<{ + $isMe: boolean; + $rank: number; + $dark: boolean; +}>` display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 10px; - background: ${({ $isMe, theme }) => - $isMe ? theme.colors.primary[500] : theme.colors.gray[100]}; - border: ${({ $isMe, theme }) => - $isMe ? `2px solid ${theme.colors.primary[900]}` : '2px solid transparent'}; + background: ${({ $isMe, $dark, theme }) => { + if ($dark) return $isMe ? 'rgba(255, 84, 20, 0.18)' : '#22222E'; + return $isMe ? theme.colors.primary[500] : '#FFFFFF'; + }}; + border: ${({ $isMe, $dark, theme }) => { + if ($isMe) return `2px solid ${theme.colors.primary[900]}`; + return $dark + ? '2px solid transparent' + : `2px solid ${theme.colors.gray[300]}`; + }}; + box-shadow: ${({ $isMe, $dark }) => + !$isMe && !$dark ? '0 1px 3px rgba(0, 0, 0, 0.06)' : 'none'}; transition: background 0.3s; `; @@ -56,11 +64,12 @@ export const Rank = styled.span<{ $rank: number }>` }}; `; -export const ClubName = styled.span` +export const ClubName = styled.span<{ $dark: boolean }>` flex: 1; font-size: 0.95rem; font-weight: 600; - color: ${({ theme }) => theme.colors.gray[900]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[100] : theme.colors.gray[900]}; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -72,9 +81,10 @@ export const ClickCount = styled.span` color: ${({ theme }) => theme.colors.primary[900]}; `; -export const EmptyMessage = styled.p` +export const EmptyMessage = styled.p<{ $dark: boolean }>` text-align: center; padding: 40px 0; - color: ${({ theme }) => theme.colors.gray[500]}; + color: ${({ $dark, theme }) => + $dark ? theme.colors.gray[400] : theme.colors.gray[500]}; font-size: 0.95rem; `; diff --git a/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.tsx b/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.tsx index 9aeeca54b..d10457412 100644 --- a/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.tsx +++ b/frontend/src/pages/GamePage/components/RankingBoard/RankingBoard.tsx @@ -4,28 +4,24 @@ import * as S from './RankingBoard.styles'; interface RankingBoardProps { ranking: GameRankingEntry[]; - resetAt?: string; myClubName?: string; + isDark?: boolean; } const MEDAL = ['🥇', '🥈', '🥉']; -const RankingBoard = ({ ranking, resetAt, myClubName }: RankingBoardProps) => { - const resetTime = resetAt - ? new Date(resetAt).toLocaleTimeString('ko-KR', { - hour: '2-digit', - minute: '2-digit', - }) - : null; - +const RankingBoard = ({ + ranking, + myClubName, + isDark = false, +}: RankingBoardProps) => { return ( - 🏆 Top 20 실시간 순위 - {resetTime && 매일 {resetTime} 초기화} + 🏆 Top 20 실시간 순위 {ranking.length === 0 ? ( - + 아직 참여한 동아리가 없어요. 첫 번째로 클릭해보세요! ) : ( @@ -44,11 +40,12 @@ const RankingBoard = ({ ranking, resetAt, myClubName }: RankingBoardProps) => { {MEDAL[entry.rank - 1] ?? entry.rank} - {entry.clubName} + {entry.clubName} {entry.clickCount.toLocaleString()}회 diff --git a/frontend/src/pages/GamePage/hooks/useBatchedClick.ts b/frontend/src/pages/GamePage/hooks/useBatchedClick.ts new file mode 100644 index 000000000..d033e0cbc --- /dev/null +++ b/frontend/src/pages/GamePage/hooks/useBatchedClick.ts @@ -0,0 +1,66 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useClickGame } from '@/hooks/Queries/useGame'; + +const FLUSH_THRESHOLD = 5; +const FLUSH_DELAY = 500; + +/** + * 클릭을 모아 일정 개수(5)나 디바운스(500ms) 시점에 한 번에 전송한다. + * 언마운트 시 미전송 클릭을 보존하기 위해 flush/clubName을 ref로 미러링한다. + */ +export const useBatchedClick = (clubName: string) => { + const pendingRef = useRef(0); + const timerRef = useRef | null>(null); + const { mutate: clickGame } = useClickGame(); + + const flush = useCallback( + (name: string) => { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = null; + const count = pendingRef.current; + if (count === 0) return; + pendingRef.current = 0; + clickGame( + { clubName: name, count }, + { + onError: () => { + pendingRef.current += count; + if (!timerRef.current) { + timerRef.current = setTimeout(() => flush(name), FLUSH_DELAY); + } + }, + }, + ); + }, + [clickGame], + ); + + // 언마운트 시 최신 flush/clubName으로 미전송분을 보내기 위한 ref 미러 + const flushRef = useRef(flush); + const clubNameRef = useRef(clubName); + useEffect(() => { + flushRef.current = flush; + }, [flush]); + useEffect(() => { + clubNameRef.current = clubName; + }, [clubName]); + + useEffect(() => { + return () => { + flushRef.current(clubNameRef.current); + }; + }, []); + + const handleClick = useCallback(() => { + pendingRef.current += 1; + + if (pendingRef.current >= FLUSH_THRESHOLD) { + flush(clubName); + } else { + if (timerRef.current) clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => flush(clubName), FLUSH_DELAY); + } + }, [clubName, flush]); + + return handleClick; +};