Skip to content

[FEAT] 같은 동아리 찾기 게임 배포 #383

Merged
ujinsim merged 53 commits intomainfrom
game
Mar 3, 2026
Merged

[FEAT] 같은 동아리 찾기 게임 배포 #383
ujinsim merged 53 commits intomainfrom
game

Conversation

@ujinsim
Copy link
Collaborator

@ujinsim ujinsim commented Mar 3, 2026

🔥 연관 이슈

🚀 작업 내용

  • 같은 동아리 찾기 게임 배포입니다

🤔 고민했던 내용

💬 리뷰 중점사항

Summary by CodeRabbit

새로운 기능

  • 동아리 카드 짝 맞추기 게임 추가

    • 여러 라운드로 진행되는 카드 매칭 게임
    • 모바일 및 데스크톱 맞춤형 인터페이스
    • 게임 완료 후 신청 폼 제출 기능 (파일 업로드 포함)
    • 현재까지의 참여자 수 표시
    • 게임 링크 공유 기능 추가
  • 사용자 경험 향상

    • 떠다니는 하트 애니메이션
    • 게임 결과에 따른 모달 표시
    • 실패 시 재시도 또는 다음 단계로 진행 옵션

ujinsim and others added 30 commits February 22, 2026 18:59
[FEAT] 같은 동아리 찾기 게임 api 연동 및 애니메이션 적용
@ujinsim ujinsim self-assigned this Mar 3, 2026
@vercel
Copy link

vercel bot commented Mar 3, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ddingdong-fe Ready Ready Preview, Comment Mar 3, 2026 9:28am

@coderabbitai
Copy link

coderabbitai bot commented Mar 3, 2026

동아리 카드 짝 맞추기 게임 기능 구현

Walkthrough

동아리 카드 짝 맞추기 게임의 완전한 기능을 구현합니다. API 뮤테이션/쿼리, 폼 관리, 라운드 진행, 단계별 네비게이션, 자산 프리로드 기능을 추가하고 컨텍스트 기반 상태 관리를 재구성합니다.

Changes

Cohort / File(s) Summary
API 레이어 및 타입 정의
src/app/_api/mutations/pair_game.ts, src/app/_api/queries/pair_game.ts, src/app/_api/types/pair_game.ts, src/types/schemas/pairGameSubmitSchema.ts
페어 게임 API 뮤테이션(createPairGameApplier), 쿼리(appliersAmount), 타입(PairGameMetadataItem, PairGameAppliersAmount, CreatePairGameApplierRequest), Zod 검증 스키마 추가
상태 관리 훅
src/app/pair_game/_hooks/useGameProgress.ts, src/app/pair_game/_hooks/useRoundPhase.ts, src/app/pair_game/_hooks/useCardState.ts, src/app/pair_game/_hooks/useDelayedAction.ts, src/app/pair_game/_hooks/usePairGameForm.ts, src/app/pair_game/_hooks/usePairGameSubmitAction.ts, src/app/pair_game/_hooks/useGameLayoutBg.ts
게임 진행 상태, 라운드 페이즈, 카드 상태 관리, 지연 액션, 폼 관리, 제출 액션, 레이아웃 배경 설정 훅 추가
제거된 훅
src/app/pair_game/_hooks/useGameTimer.ts, src/app/pair_game/_hooks/useTimer.ts, src/app/pair_game/_hooks/usePairGamePage.ts, src/app/pair_game/_hooks/useRoundResultModal.ts
구 타이머 및 페이지 레벨 훅 제거, 새로운 훅 기반 접근으로 전환
게임 상수
src/app/pair_game/_constants/gameImages.ts, src/app/pair_game/_constants/categoryStyles.ts, src/app/pair_game/_constants/clubs.ts, src/app/pair_game/_constants/heart.ts, src/app/pair_game/_constants/roundConfigs.ts
게임 이미지, 카테고리 스타일, 클럽 데이터, 플로팅 하트, 라운드 설정 상수 추가
게임 유틸리티
src/app/pair_game/_utils/cardUtils.ts, src/app/pair_game/_utils/cardStyles.ts, src/app/pair_game/_utils/clubImages.ts, src/app/pair_game/_utils/clubs.ts, src/app/pair_game/_utils/roundResultModal.ts, src/app/pair_game/_utils/timerDisplay.ts, src/app/pair_game/_utils/shareLink.ts, src/app/pair_game/_utils/validatePairGameSubmitData.ts, src/app/pair_game/_utils/preloadGameAssets.ts
카드 관리, 스타일 계산, 클럽 이미지, 라운드 결과 모달 콘텐츠, 타이머 표시, 링크 공유, 폼 검증, 자산 프리로드 유틸리티 추가/수정
컨텍스트 및 상태 제거
src/app/pair_game/_utils/gameConstants.ts, src/app/pair_game/_utils/gameImages.ts
게임 상수와 이미지 정의를 _constants로 이동하며 구 유틸리티 파일 제거
게임 스텝 컴포넌트
src/app/pair_game/_components/steps/IntroStep.tsx, src/app/pair_game/_components/steps/IntroStepDesktop.tsx, src/app/pair_game/_components/steps/IntroStepMobile.tsx, src/app/pair_game/_components/steps/PlayingStep.tsx, src/app/pair_game/_components/steps/SubmitStep.tsx, src/app/pair_game/_components/steps/CompletedStep.tsx
게임 단계별 UI 컴포넌트 리팩토링: props 기반 구조, 훅 통합, 모달 처리, 공유 기능 추가
UI 컴포넌트
src/app/pair_game/_components/ui/BellAnimation.tsx, src/app/pair_game/_components/ui/BridgeMaruMari.tsx, src/app/pair_game/_components/ui/EventCard.tsx, src/app/pair_game/_components/ui/GameStartModal.tsx, src/app/pair_game/_components/ui/RoundResultModal.tsx
애니메이션, 마루마리 캐릭터 컴포넌트, 이벤트 카드, 게임 시작 모달, 라운드 결과 모달 이미지 경로 및 속성 업데이트
게임 페이지 및 컨텍스트
src/app/pair_game/_contexts/PairGamePlayingContext.tsx, src/app/pair_game/page.tsx, src/app/pair_game/guide/page.tsx, src/app/pair_game/prize/page.tsx
페이지 레벨 라우팅, 펀널 기반 단계 관리, 동적 로딩, 모달 포탈 처리, 레이아웃 조정
레이아웃 및 스타일
src/components/layout/GameLayout.tsx, src/styles/globals.css
CSS 변수 기반 배경 처리, 마루 애니메이션, 플로팅 하트 애니메이션 추가
네비게이션
src/constants/navItems.ts
동아리박람회 이벤트 섹션에 페어 게임 네비게이션 아이템 추가
사용자 에이전트 유틸
src/utils/userAgent.ts
모바일 기기 감지 함수 추가

Sequence Diagram(s)

sequenceDiagram
    participant User as 사용자
    participant Browser as 브라우저
    participant GamePage as GamePage
    participant API as API Server
    participant DB as Database

    User->>Browser: 게임 시작
    Browser->>GamePage: IntroStep 렌더링
    GamePage->>GamePage: 자산 프리로드
    
    User->>Browser: 게임 시작 클릭
    Browser->>GamePage: onGameStart 호출
    GamePage->>GamePage: currentRound = 0, gameInstanceId++
    
    GamePage->>GamePage: PairGamePlayingProvider 마운트
    GamePage->>GamePage: useCardState 초기화 (카드 생성)
    GamePage->>GamePage: useRoundPhase 시작 (preview -> playing -> ended)
    
    User->>Browser: 카드 클릭
    Browser->>GamePage: selectCard(cardId)
    GamePage->>GamePage: 카드 플립, 매칭 체크
    GamePage->>GamePage: 성공 시: onRoundComplete 호출

    GamePage->>API: RoundResult 전달
    GamePage->>GamePage: PlayingStep → CompletedStep
    GamePage->>API: appliersAmount 쿼리
    API->>DB: SELECT appliers count
    DB-->>API: 참여자 수 반환
    API-->>GamePage: 참여자 수 업데이트
    
    User->>Browser: 폼 작성 및 제출
    Browser->>GamePage: SubmitStep handleSubmit
    GamePage->>API: createPairGameApplier mutation
    API->>DB: INSERT applier
    DB-->>API: 성공
    API-->>GamePage: 제출 완료
    GamePage->>GamePage: CompletedStep 전환
Loading
sequenceDiagram
    participant User as 사용자
    participant SubmitStep as SubmitStep<br/>컴포넌트
    participant usePairGameForm as usePairGameForm<br/>훅
    participant usePairGameSubmitAction as usePairGameSubmitAction<br/>훅
    participant validateFn as validatePairGame<br/>SubmitData
    participant API as API<br/>(createPairGameApplier)
    participant Toast as Toast<br/>알림

    User->>SubmitStep: 폼 입력 (이름, 학번, 학과, 번호)
    SubmitStep->>usePairGameForm: handleChange 호출
    usePairGameForm->>usePairGameForm: formData 상태 업데이트

    User->>SubmitStep: 영수증 파일 선택
    SubmitStep->>usePairGameForm: handleFileChange 호출
    usePairGameForm->>usePairGameForm: receiptFile 상태 저장

    User->>SubmitStep: 제출 버튼 클릭
    SubmitStep->>usePairGameForm: handleSubmit 호출
    usePairGameForm->>validateFn: validatePairGameSubmitData 호출
    validateFn->>validateFn: Zod 스키마 검증
    
    alt 검증 실패
        validateFn->>Toast: 에러 메시지 표시
        Toast-->>User: 검증 실패 알림
    else 검증 성공
        validateFn-->>usePairGameForm: true 반환
        usePairGameForm->>usePairGameSubmitAction: submit 호출
        usePairGameSubmitAction->>API: createPairGameApplier mutation
        API->>API: FormData 생성 (request + file)
        API->>API: POST /pair-game/appliers
        
        alt API 성공
            API-->>usePairGameSubmitAction: 성공 응답
            usePairGameSubmitAction->>usePairGameSubmitAction: sessionStorage 업데이트
            usePairGameSubmitAction->>Toast: 성공 알림
            Toast-->>User: 제출 완료 메시지
        else API 오류
            API-->>usePairGameSubmitAction: 오류 응답
            usePairGameSubmitAction->>Toast: 오류 메시지 표시
            Toast-->>User: 오류 알림
        end
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

⚛️ 프론트엔드, ✨ 기능, feat

Suggested reviewers

  • keemsebin
  • JangYEhoon00
🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning PR 설명이 최소한의 내용만 포함되어 있으며, 연관 이슈, 고민했던 내용, 리뷰 중점사항이 모두 비어있습니다. 연관 이슈 번호, 주요 구현 내용의 세부 사항, 고민했던 기술적 결정사항, 그리고 리뷰어가 중점적으로 확인해야 할 사항들을 추가로 기재해주세요.
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed 제목은 '[FEAT] 같은 동아리 찾기 게임 배포'로 PR의 주요 변경사항인 새로운 게임 기능 배포를 명확하게 설명하고 있습니다.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch game

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 21

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/app/pair_game/prize/page.tsx (1)

49-51: ⚠️ Potential issue | 🟡 Minor

오타 수정 필요: "네이버페 이" → "네이버페이"

Line 50에서 "네이버페이"가 "네이버페 이"로 잘못 띄어쓰기 되어 있습니다.

🔧 수정 제안
-              네이버페 이 1만원 권 (10명)
+              네이버페이 1만원권 (10명)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/prize/page.tsx` around lines 49 - 51, Fix the whitespace
typo in the Caption1 JSX text: replace "네이버페 이 1만원 권 (10명)" with "네이버페이 1만원 권
(10명)" in the page component where Caption1 is rendered so the brand name is
correctly joined.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/app/_api/mutations/pair_game.ts`:
- Around line 14-19: The POST call that sets headers: { 'Content-Type':
undefined } (in the function making instance.post('pair-game/appliers') in
src/app/_api/mutations/pair_game.ts) causes ky to send "Content-Type: undefined"
and breaks FormData; remove that header usage and instead add a
hooks.beforeRequest handler that deletes the Content-Type header from the
outgoing Request (request.headers.delete('Content-Type')) so ky can auto-set the
multipart boundary; apply the same fix to the uploadMemberExcel function in
src/app/_api/mutations/member.ts.

In `@src/app/pair_game/_components/steps/IntroStepMobile.tsx`:
- Around line 160-163: The BridgeMaruMari usage includes an invalid Tailwind
class "pt-15" in the className prop; update the className on the BridgeMaruMari
component to use a valid spacing utility (e.g., pt-16 or pt-14) or an arbitrary
value like pt-[60px], or add a matching spacing key to tailwind.config.ts if you
need a custom token; locate the BridgeMaruMari element (props: animateMaru,
driveDistance) and replace "pt-15" with the chosen valid spacing class.

In `@src/app/pair_game/_components/steps/PlayingStep.tsx`:
- Around line 117-122: The current className passed to ProgressBar in
PlayingStep.tsx uses internal child selectors (`[&>div]:!duration-100
[&>div]:!ease-linear`) which couples styling to ProgressBar's DOM; instead,
update the code so PlayingStep stops relying on internal selectors and either
(a) pass explicit styling props or named class props supported by ProgressBar
(e.g., an innerClassName/transitionDuration/transitionTiming prop) or (b) add
those props to the ProgressBar component itself (modify ProgressBar to accept
and apply innerClassName or transition props to its internal div). Locate
ProgressBar usage in PlayingStep.tsx and the ProgressBar component definition
and implement the chosen API so transitions are applied robustly without using
child selectors.

In `@src/app/pair_game/_components/ui/BridgeMaruMari.tsx`:
- Around line 18-23: maruClassName builds a class string from two always-present
string entries, so remove the unnecessary .filter(Boolean) call and join the
array directly; update the expression that constructs maruClassName (which
references animateMaru) to use .join(' ') on the array without filtering to
simplify the code.

In `@src/app/pair_game/_constants/roundConfigs.ts`:
- Around line 9-14: ROUND_CONFIGS currently contains a redundant manual
totalCards field that can drift from rows*cols; update the config creation so
totalCards is derived (rows * cols) instead of hard-coded. Locate the exported
ROUND_CONFIGS array and either remove the explicit totalCards entries and
compute totalCards on construction or map the array into a derived array where
each item sets totalCards = rows * cols (preserving previewTime and gameTime);
ensure any type uses (RoundConfig) still accept the derived totalCards value.

In `@src/app/pair_game/_contexts/PairGamePlayingContext.tsx`:
- Around line 76-87: The while loop in tryFlipNextCards can hang if flipCard
repeatedly returns false (e.g., all remaining pending cards are invalid) because
selectedCardIds won't increase; add a defensive iteration cap (e.g.,
maxIterations) inside tryFlipNextCards and increment it each loop, breaking when
the cap is reached so the loop always terminates; reference
pendingCardIds.current.shift(), flipCard(nextId), and selectedCardIds.current to
implement the check and log or handle the capped-break case appropriately.
- Around line 57-60: Add a defensive effect that resets the refs when the round
changes: when currentRound updates, set selectedCardIds.current = [],
pendingCardIds.current = [], isProcessingMatch.current = false, and
isRoundCompleted.current = false so the Provider can handle cases where it isn't
remounted; implement this in PairGamePlayingContext by adding a useEffect that
depends on currentRound and performs those assignments (referencing the existing
refs selectedCardIds, pendingCardIds, isProcessingMatch, isRoundCompleted).

In `@src/app/pair_game/_hooks/useCardState.ts`:
- Around line 36-48: The current checkMatch returns a single boolean that
conflates whether the two picked cards matched and whether the game is now fully
matched; update checkMatch to return an explicit result object (e.g. { isMatch,
isAllMatched }) so callers can distinguish "these two cards matched" from "all
cards are matched after this move." Inside checkMatch, call
processCardMatch(cardsRef.current, firstId, secondId) to get updatedCards and
isMatch, compute isAllMatched = areAllCardsMatched(updatedCards), update
cardsRef.current and setCards(updatedCards), then return the object containing
both isMatch and isAllMatched (or similarly named properties) so the meaning is
unambiguous.

In `@src/app/pair_game/_hooks/useDelayedAction.ts`:
- Around line 22-25: In cancelAll, the forEach callback is implicitly returning
clearTimeout’s return value; change the arrow callback to a block statement that
calls clearTimeout without returning (e.g., timeoutIds.current.forEach((id) => {
clearTimeout(id); });) so the callback returns undefined, then keep the
subsequent timeoutIds.current.clear() call; this removes the unintended implicit
return flagged by the linter.

In `@src/app/pair_game/_hooks/useGameProgress.ts`:
- Around line 25-27: completeRound currently calls preloadGameAssets and the
intro stage in src/app/pair_game/page.tsx also triggers a preload, causing
duplicate preloads; remove one call and centralize preload logic by moving the
single responsibility to a shared place (e.g., the hook useGameProgress) or a
dedicated function that both page.tsx and completeRound use: eliminate
preloadGameAssets() from completeRound (or from the page intro) and instead
expose and call a single public preload function from useGameProgress (or import
that shared preload helper) so only that function performs preloading and both
the intro stage and round completion invoke the same centralized preload path.

In `@src/app/pair_game/_hooks/usePairGameSubmitAction.ts`:
- Around line 17-33: The submit handler can fire createApplier multiple times on
quick clicks and weakens type-safety with "as File"; add a pending guard (e.g.,
a useRef or state like isSubmitting) in the hook and in submit: return early if
isSubmitting is true, set isSubmitting = true before calling createApplier and
reset it in finally to prevent duplicate requests; also remove the unsafe cast
by either changing the payload to pass file: receiptFile (allowing File | null)
or ensure validatePairGameSubmitData guarantees receiptFile is non-null and only
then pass receiptFile (no "as File"); update references to submit,
validatePairGameSubmitData, and createApplier accordingly.

In `@src/app/pair_game/_hooks/useRoundPhase.ts`:
- Around line 15-63: The two near-identical useEffect timer blocks (the preview
and playing effects) should be extracted into a reusable hook (e.g.,
usePhaseTimer) to remove duplication: create a hook that accepts activePhase,
currentPhase, duration (seconds) and onComplete, implements the performance.now
timing and setInterval logic, and returns the remaining time; then replace the
preview effect with a call to usePhaseTimer('preview', phase, PREVIEW_TIME, ()
=> setPhase('playing')) and wire its return to setPreviewTimer (or have the hook
expose the remaining value directly), and similarly replace the playing effect
with usePhaseTimer('playing', phase, GAME_TIME, () => setPhase('ended')) and
connect to setGameTimer; ensure the hook clears intervals on cleanup and depends
on currentPhase, activePhase, duration, and onComplete.

In `@src/app/pair_game/_utils/cardStyles.ts`:
- Around line 7-14: The function getCardSizeStyleForConfig can produce
Infinity/invalid vw values when config.cols or config.rows are 0 or negative;
add defensive validation at the start of getCardSizeStyleForConfig to
coerce/validate inputs (e.g., const safeCols = Math.max(1, config.cols); const
safeRows = Math.max(1, config.rows)); use safeCols/safeRows when building key
and computing vwW (replace config.cols with safeCols) and guard against
base.width === 0 by using Math.max(1, base.width) when computing vwH so vwW and
vwH are always finite; keep CARD_SIZES lookup using the safe key.

In `@src/app/pair_game/_utils/cardUtils.ts`:
- Around line 21-23: The createCards function must validate totalCards before
computing pairCount: ensure totalCards is a positive even integer and that
totalCards/2 does not exceed CLUBS.length (38); if validation fails, either
throw a clear Error or return a controlled fallback. In practice, in createCards
check Number.isInteger(totalCards) && totalCards > 0 && totalCards % 2 === 0,
compute pairCount = totalCards / 2, then assert pairCount <= CLUBS.length (or
clamp/raise) before using shuffle(CLUBS).slice(0, pairCount) so you never
request more pairs than available and never use a fractional pairCount.

In `@src/app/pair_game/_utils/clubImages.ts`:
- Around line 4-7: getClubImageSrc currently builds a path from any numeric
clubId and can cause 404s; add validation inside getClubImageSrc to check the id
against known CLUBS (or allowed id range) and return a safe fallback path like
`${PAIR_GAME_PATH}/clubs/default.webp` when the id is invalid or missing, and
ensure CLUB_IMAGE_SRCS continues to call getClubImageSrc(c.id) so the mapping
uses the new defensive logic; refer to getClubImageSrc, CLUB_IMAGE_SRCS, CLUBS,
and PAIR_GAME_PATH when making the change.

In `@src/app/pair_game/_utils/clubs.ts`:
- Line 5: The getClubById function currently omits an explicit return type
causing ambiguity; update its signature to declare the return type as Club |
undefined (e.g., export const getClubById = (id: number): Club | undefined =>
CLUB_MAP.get(id);), ensuring callers know Map.get can return undefined and
adjust any call sites if they assume a non-null Club; reference getClubById and
CLUB_MAP when making this change.

In `@src/app/pair_game/_utils/timerDisplay.ts`:
- Around line 12-13: The percent calculation (variable percent in
timerDisplay.ts using gameTime, displaySeconds and remaining) can exceed 100 and
must be clamped to the 0–100 range; update the percent assignment so after
computing (remaining / gameTime) * 100 it is clamped to Math.max(0,
Math.min(100, ...)) (or use an existing clamp utility) so percent never goes
below 0 or above 100.

In `@src/constants/navItems.ts`:
- Around line 43-49: The new nav item under '동아리박람회 이벤트' has id: 7 which breaks
the existing id sequence (1-5); update the id to 6 in src/constants/navItems.ts
for the object with href '/pair_game' and content '동아리 카드 짝 맞추기 게임' to restore
the sequential IDs (leave the href '/pair_game' unchanged as noted).

In `@src/styles/globals.css`:
- Around line 120-132: 애니메이션의 translateY에 하드코딩된 vh 단위가 iOS Safari에서 주소창 표시/숨김으로
인해 뷰포트 높이가 변동되며 동작이 달라질 수 있으므로 `@keyframes` float-up의 translateY(35vh) 및
translateY(-45vh)을 동적 뷰포트 단위로 전환하세요; 예를 들어 dvh를 우선 사용하고 구형 브라우저 대응을 위해 CSS
변수(--float-dvh) 또는 calc()와 함께 폴백(예: var(--float-dvh, 35vh))을 도입해 float-up 정의와
관련된 transform 값에 적용하여 예상한 애니메이션 동작을 확보하도록 수정하세요.

In `@src/types/schemas/pairGameSubmitSchema.ts`:
- Around line 9-11: The phoneNumber zod rule in pairGameSubmitSchema.ts
currently only accepts hyphenated input; update the phoneNumber schema to accept
both hyphenated and non-hyphenated formats by either (A) replacing the regex
with a flexible one such as /^010-?\d{4}-?\d{4}$/ (or
/^(?:010-?\d{4}-?\d{4}|010\d{8})$/ to be explicit) or (B) normalize input first
(e.g., use z.string().transform(s => s.replace(/-/g, '')) and then validate with
/^\d{10}$/ or /^010\d{8}$/), keeping the same error message text ('올바른 전화번호 형식이
아닙니다.') and updating the phoneNumber declaration accordingly.

---

Outside diff comments:
In `@src/app/pair_game/prize/page.tsx`:
- Around line 49-51: Fix the whitespace typo in the Caption1 JSX text: replace
"네이버페 이 1만원 권 (10명)" with "네이버페이 1만원 권 (10명)" in the page component where
Caption1 is rendered so the brand name is correctly joined.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e6a497e and f9e9739.

📒 Files selected for processing (52)
  • public/pair_game/background_heart.webp
  • src/app/_api/mutations/pair_game.ts
  • src/app/_api/queries/pair_game.ts
  • src/app/_api/types/pair_game.ts
  • src/app/pair_game/_components/steps/CompletedStep.tsx
  • src/app/pair_game/_components/steps/IntroStep.tsx
  • src/app/pair_game/_components/steps/IntroStepDesktop.tsx
  • src/app/pair_game/_components/steps/IntroStepMobile.tsx
  • src/app/pair_game/_components/steps/PlayingStep.tsx
  • src/app/pair_game/_components/steps/SubmitStep.tsx
  • src/app/pair_game/_components/ui/BellAnimation.tsx
  • src/app/pair_game/_components/ui/BridgeMaruMari.tsx
  • src/app/pair_game/_components/ui/EventCard.tsx
  • src/app/pair_game/_components/ui/GameStartModal.tsx
  • src/app/pair_game/_components/ui/RoundResultModal.tsx
  • src/app/pair_game/_constants/RoundResultModalContent.ts
  • src/app/pair_game/_constants/categoryStyles.ts
  • src/app/pair_game/_constants/clubs.ts
  • src/app/pair_game/_constants/gameImages.ts
  • src/app/pair_game/_constants/heart.ts
  • src/app/pair_game/_constants/roundConfigs.ts
  • src/app/pair_game/_contexts/PairGamePlayingContext.tsx
  • src/app/pair_game/_hooks/useCardState.ts
  • src/app/pair_game/_hooks/useDelayedAction.ts
  • src/app/pair_game/_hooks/useGameLayoutBg.ts
  • src/app/pair_game/_hooks/useGameProgress.ts
  • src/app/pair_game/_hooks/useGameTimer.ts
  • src/app/pair_game/_hooks/usePairGameForm.ts
  • src/app/pair_game/_hooks/usePairGamePage.ts
  • src/app/pair_game/_hooks/usePairGameSubmitAction.ts
  • src/app/pair_game/_hooks/useRoundPhase.ts
  • src/app/pair_game/_hooks/useRoundResultModal.ts
  • src/app/pair_game/_hooks/useTimer.ts
  • src/app/pair_game/_utils/cardStyles.ts
  • src/app/pair_game/_utils/cardUtils.ts
  • src/app/pair_game/_utils/clubImages.ts
  • src/app/pair_game/_utils/clubs.ts
  • src/app/pair_game/_utils/gameConstants.ts
  • src/app/pair_game/_utils/gameImages.ts
  • src/app/pair_game/_utils/preloadGameAssets.ts
  • src/app/pair_game/_utils/roundResultModal.ts
  • src/app/pair_game/_utils/shareLink.ts
  • src/app/pair_game/_utils/timerDisplay.ts
  • src/app/pair_game/_utils/validatePairGameSubmitData.ts
  • src/app/pair_game/guide/page.tsx
  • src/app/pair_game/page.tsx
  • src/app/pair_game/prize/page.tsx
  • src/components/layout/GameLayout.tsx
  • src/constants/navItems.ts
  • src/styles/globals.css
  • src/types/schemas/pairGameSubmitSchema.ts
  • src/utils/userAgent.ts
💤 Files with no reviewable changes (6)
  • src/app/pair_game/_hooks/usePairGamePage.ts
  • src/app/pair_game/_hooks/useTimer.ts
  • src/app/pair_game/_hooks/useGameTimer.ts
  • src/app/pair_game/_hooks/useRoundResultModal.ts
  • src/app/pair_game/_utils/gameConstants.ts
  • src/app/pair_game/_utils/gameImages.ts

Comment on lines +160 to +163
<BridgeMaruMari
className="relative bottom-0 mt-auto w-screen pt-15"
animateMaru={animateMaru}
driveDistance="53vw"
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# Find Tailwind config
fd 'tailwind.config\.(js|cjs|mjs|ts)$' --type f

Repository: COW-dev/ddingdong-fe

Length of output: 82


🏁 Script executed:

# Check if the file exists and view the relevant lines
cat -n src/app/pair_game/_components/steps/IntroStepMobile.tsx | sed -n '155,170p'

Repository: COW-dev/ddingdong-fe

Length of output: 408


🏁 Script executed:

# Search for pt-15 usage in the codebase
rg 'pt-15' --type tsx --type ts --type jsx --type js

Repository: COW-dev/ddingdong-fe

Length of output: 91


🏁 Script executed:

# Search for spacing configuration in tailwind config
rg -A 20 'theme.*spacing|extend.*spacing' --type js --type ts --type cjs

Repository: COW-dev/ddingdong-fe

Length of output: 91


🏁 Script executed:

cat -n tailwind.config.ts

Repository: COW-dev/ddingdong-fe

Length of output: 1535


🏁 Script executed:

# Search for pt-15 usage in the entire codebase
rg 'pt-15'

Repository: COW-dev/ddingdong-fe

Length of output: 180


🏁 Script executed:

# Check spacing configuration more broadly
rg 'spacing' tailwind.config.ts -A 5 -B 2

Repository: COW-dev/ddingdong-fe

Length of output: 46


pt-15 클래스는 유효하지 않으므로 수정이 필요합니다.

Line 161의 pt-15는 Tailwind의 기본 spacing 스케일에 없고 tailwind.config.ts에 확장 설정도 없어 패딩이 적용되지 않습니다. pt-16 (64px), pt-14 (56px) 또는 pt-[60px] 같은 유효한 값으로 변경하거나, 필요시 Tailwind 설정에 spacing을 확장하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/_components/steps/IntroStepMobile.tsx` around lines 160 -
163, The BridgeMaruMari usage includes an invalid Tailwind class "pt-15" in the
className prop; update the className on the BridgeMaruMari component to use a
valid spacing utility (e.g., pt-16 or pt-14) or an arbitrary value like
pt-[60px], or add a matching spacing key to tailwind.config.ts if you need a
custom token; locate the BridgeMaruMari element (props: animateMaru,
driveDistance) and replace "pt-15" with the chosen valid spacing class.

Comment on lines +73 to +78
const handleSubmit = () => {
onCloseModal();
window.sessionStorage.setItem('pairGameCanSubmit', '1');
setStep('submit');
window.history.replaceState(null, '', PAIR_GAME_PATH);
};
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "pairGameCanSubmit" --type=ts --type=tsx

Repository: COW-dev/ddingdong-fe

Length of output: 91


🏁 Script executed:

rg "pairGameCanSubmit" -n

Repository: COW-dev/ddingdong-fe

Length of output: 301


🏁 Script executed:

cat -n src/app/pair_game/_hooks/usePairGameSubmitAction.ts

Repository: COW-dev/ddingdong-fe

Length of output: 2101


🏁 Script executed:

cat -n src/app/pair_game/_components/steps/PlayingStep.tsx

Repository: COW-dev/ddingdong-fe

Length of output: 6049


🏁 Script executed:

rg "step.*submit|submit.*step" -n --context 3 | head -100

Repository: COW-dev/ddingdong-fe

Length of output: 46


🏁 Script executed:

find src/app/pair_game -name "*[Ss]ubmit*" -o -name "*[Ss]tep*" | grep -v node_modules

Repository: COW-dev/ddingdong-fe

Length of output: 526


🏁 Script executed:

cat -n src/app/pair_game/_components/steps/SubmitStep.tsx

Repository: COW-dev/ddingdong-fe

Length of output: 7078


🏁 Script executed:

find src/app/pair_game -name "*usePairGameForm*" -o -name "*PairGameForm*"

Repository: COW-dev/ddingdong-fe

Length of output: 107


🏁 Script executed:

cat -n src/app/pair_game/_hooks/usePairGameForm.ts

Repository: COW-dev/ddingdong-fe

Length of output: 1989


🏁 Script executed:

find src/app/pair_game -name "*Context*" -o -name "*context*"

Repository: COW-dev/ddingdong-fe

Length of output: 196


🏁 Script executed:

cat -n src/app/pair_game/_contexts/GameFunnelContext.tsx

Repository: COW-dev/ddingdong-fe

Length of output: 1557


🏁 Script executed:

find src/app/pair_game -maxdepth 2 -name "page.tsx" -o -name "layout.tsx"

Repository: COW-dev/ddingdong-fe

Length of output: 156


🏁 Script executed:

cat -n src/app/pair_game/page.tsx

Repository: COW-dev/ddingdong-fe

Length of output: 3601


sessionStorage 검증 로직 누락 및 상태 업데이트 순서 확인 필요

pairGameCanSubmit 플래그를 설정하지만 SubmitStep에서 이 값을 검증하지 않습니다. 폼 제출 전에 이 플래그 존재 여부를 확인하는 로직이 없으므로, 게임을 진행하지 않고 직접 제출 단계에 접근할 수 있습니다. 현재 코드에서 이 플래그는 설정만 되고 검증되지 않고 있습니다.

또한 setStep('submit')의 비동기 상태 업데이트와 window.history.replaceState() 간의 실행 순서 문제가 있습니다. history.replaceState는 동기적으로 즉시 실행되지만, setStep은 비동기이므로 URL 변경이 React 상태 업데이트 완료 전에 적용될 수 있습니다.

Comment on lines +117 to +122
<div className="min-w-0 flex-1 overflow-hidden">
<ProgressBar
color="pink"
percent={progressPercent}
className="w-full max-w-full [&>div]:!duration-100 [&>div]:!ease-linear"
/>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

ProgressBar에 전달되는 className 검증 필요

[&>div]:!duration-100 [&>div]:!ease-linear 선택자가 ProgressBar 내부 구조에 의존합니다. ProgressBar 컴포넌트의 DOM 구조가 변경되면 이 스타일이 적용되지 않을 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/_components/steps/PlayingStep.tsx` around lines 117 - 122,
The current className passed to ProgressBar in PlayingStep.tsx uses internal
child selectors (`[&>div]:!duration-100 [&>div]:!ease-linear`) which couples
styling to ProgressBar's DOM; instead, update the code so PlayingStep stops
relying on internal selectors and either (a) pass explicit styling props or
named class props supported by ProgressBar (e.g., an
innerClassName/transitionDuration/transitionTiming prop) or (b) add those props
to the ProgressBar component itself (modify ProgressBar to accept and apply
innerClassName or transition props to its internal div). Locate ProgressBar
usage in PlayingStep.tsx and the ProgressBar component definition and implement
the chosen API so transitions are applied robustly without using child
selectors.

Comment on lines +18 to +23
const maruClassName = [
'h-auto w-[28%] max-w-[140px] object-contain object-bottom',
animateMaru ? 'maru-slow-drive' : '-scale-x-100',
]
.filter(Boolean)
.join(' ');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

filter(Boolean) 불필요

배열의 두 요소가 항상 문자열이므로 filter(Boolean)이 필요하지 않습니다.

♻️ 간소화 제안
-  const maruClassName = [
-    'h-auto w-[28%] max-w-[140px] object-contain object-bottom',
-    animateMaru ? 'maru-slow-drive' : '-scale-x-100',
-  ]
-    .filter(Boolean)
-    .join(' ');
+  const maruClassName = `h-auto w-[28%] max-w-[140px] object-contain object-bottom ${animateMaru ? 'maru-slow-drive' : '-scale-x-100'}`;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const maruClassName = [
'h-auto w-[28%] max-w-[140px] object-contain object-bottom',
animateMaru ? 'maru-slow-drive' : '-scale-x-100',
]
.filter(Boolean)
.join(' ');
const maruClassName = `h-auto w-[28%] max-w-[140px] object-contain object-bottom ${animateMaru ? 'maru-slow-drive' : '-scale-x-100'}`;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/_components/ui/BridgeMaruMari.tsx` around lines 18 - 23,
maruClassName builds a class string from two always-present string entries, so
remove the unnecessary .filter(Boolean) call and join the array directly; update
the expression that constructs maruClassName (which references animateMaru) to
use .join(' ') on the array without filtering to simplify the code.

const safeCount = Math.min(count, CLUB_IDS.length);
return shuffled.slice(0, safeCount);
};
export const getClubById = (id: number) => CLUB_MAP.get(id);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

반환 타입 명시 권장

getClubById의 반환 타입이 명시되지 않았습니다. Map.getClub | undefined를 반환하므로, 호출자가 undefined 케이스를 처리해야 함을 명확히 하는 것이 좋습니다.

♻️ 타입 명시 제안
+import { CLUBS, type Club } from '../_constants/clubs';
-import { CLUBS } from '../_constants/clubs';

 const CLUB_MAP = new Map(CLUBS.map((c) => [c.id, c]));

-export const getClubById = (id: number) => CLUB_MAP.get(id);
+export const getClubById = (id: number): Club | undefined => CLUB_MAP.get(id);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/_utils/clubs.ts` at line 5, The getClubById function
currently omits an explicit return type causing ambiguity; update its signature
to declare the return type as Club | undefined (e.g., export const getClubById =
(id: number): Club | undefined => CLUB_MAP.get(id);), ensuring callers know
Map.get can return undefined and adjust any call sites if they assume a non-null
Club; reference getClubById and CLUB_MAP when making this change.

Comment on lines +12 to +13
const percent =
gameTime > 0 && displaySeconds > 0 ? (remaining / gameTime) * 100 : 0;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

진행률 상한 clamp가 없어 100% 초과 가능성이 있습니다.

Line 12-13 계산 결과가 100을 넘으면 progress UI가 깨질 수 있습니다. 0~100 범위로 고정해 주세요.

🔧 제안 수정안
-  const percent =
-    gameTime > 0 && displaySeconds > 0 ? (remaining / gameTime) * 100 : 0;
+  const percent =
+    gameTime > 0 && displaySeconds > 0
+      ? Math.min(100, (remaining / gameTime) * 100)
+      : 0;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const percent =
gameTime > 0 && displaySeconds > 0 ? (remaining / gameTime) * 100 : 0;
const percent =
gameTime > 0 && displaySeconds > 0
? Math.min(100, (remaining / gameTime) * 100)
: 0;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/pair_game/_utils/timerDisplay.ts` around lines 12 - 13, The percent
calculation (variable percent in timerDisplay.ts using gameTime, displaySeconds
and remaining) can exceed 100 and must be clamped to the 0–100 range; update the
percent assignment so after computing (remaining / gameTime) * 100 it is clamped
to Math.max(0, Math.min(100, ...)) (or use an existing clamp utility) so percent
never goes below 0 or above 100.

Comment on lines +43 to +49
'동아리박람회 이벤트': [
{
id: 7,
href: '/pair_game',
content: '동아리 카드 짝 맞추기 게임',
},
],
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, examine the navItems.ts file to understand the full context
cat -n src/constants/navItems.ts

Repository: COW-dev/ddingdong-fe

Length of output: 1193


🏁 Script executed:

# Search for all references to 'pair_game' in the codebase
rg "pair_game" -t ts -t tsx

Repository: COW-dev/ddingdong-fe

Length of output: 91


🏁 Script executed:

# Also search without type restriction to be thorough
rg -n "pair_game"

Repository: COW-dev/ddingdong-fe

Length of output: 889


ID 값 누락 확인 필요 (경로 네이밍은 기존 구현으로 진행)

  1. ID 누락: 기존 ID가 1-5까지 있는데, 새 항목의 ID가 6이 아닌 7입니다. 의도된 것인지 확인 부탁드립니다.

  2. 경로 네이밍: /pair_game은 기존 경로들(/notice, /documents, /faq, /feeds)의 소문자 패턴과 달라 네이밍 컨벤션 불일치가 있습니다. 다만, 이 경로는 이미 애플리케이션 전체에 걸쳐 구현되어 있으므로(src/app/pair_game/ 디렉토리, 관련 API, 타입 정의 등) 현 단계에서 변경하면 광범위한 리팩토링이 필요합니다. 네이밍 일관성보다 기능 안정성을 우선시하여 현재 경로로 진행하는 것을 권장합니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/constants/navItems.ts` around lines 43 - 49, The new nav item under
'동아리박람회 이벤트' has id: 7 which breaks the existing id sequence (1-5); update the
id to 6 in src/constants/navItems.ts for the object with href '/pair_game' and
content '동아리 카드 짝 맞추기 게임' to restore the sequential IDs (leave the href
'/pair_game' unchanged as noted).

Comment on lines +120 to +132
@keyframes float-up {
0% {
transform: translateY(35vh) scale(calc(0.85 * var(--float-size, 1)));
opacity: 0;
}
15% {
opacity: 0.7;
}
100% {
transform: translateY(-45vh) scale(var(--float-size, 1));
opacity: 0;
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

모바일에서 vh 단위 사용 시 주의

translateY(35vh)translateY(-45vh)는 iOS Safari에서 주소창 표시/숨김에 따라 뷰포트 높이가 변동되어 애니메이션이 예상과 다르게 보일 수 있습니다. dvh(dynamic viewport height) 사용을 고려해 보세요.

♻️ 수정 제안
 `@keyframes` float-up {
   0% {
-    transform: translateY(35vh) scale(calc(0.85 * var(--float-size, 1)));
+    transform: translateY(35dvh) scale(calc(0.85 * var(--float-size, 1)));
     opacity: 0;
   }
   15% {
     opacity: 0.7;
   }
   100% {
-    transform: translateY(-45vh) scale(var(--float-size, 1));
+    transform: translateY(-45dvh) scale(var(--float-size, 1));
     opacity: 0;
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@keyframes float-up {
0% {
transform: translateY(35vh) scale(calc(0.85 * var(--float-size, 1)));
opacity: 0;
}
15% {
opacity: 0.7;
}
100% {
transform: translateY(-45vh) scale(var(--float-size, 1));
opacity: 0;
}
}
`@keyframes` float-up {
0% {
transform: translateY(35dvh) scale(calc(0.85 * var(--float-size, 1)));
opacity: 0;
}
15% {
opacity: 0.7;
}
100% {
transform: translateY(-45dvh) scale(var(--float-size, 1));
opacity: 0;
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/styles/globals.css` around lines 120 - 132, 애니메이션의 translateY에 하드코딩된 vh
단위가 iOS Safari에서 주소창 표시/숨김으로 인해 뷰포트 높이가 변동되며 동작이 달라질 수 있으므로 `@keyframes` float-up의
translateY(35vh) 및 translateY(-45vh)을 동적 뷰포트 단위로 전환하세요; 예를 들어 dvh를 우선 사용하고 구형
브라우저 대응을 위해 CSS 변수(--float-dvh) 또는 calc()와 함께 폴백(예: var(--float-dvh, 35vh))을 도입해
float-up 정의와 관련된 transform 값에 적용하여 예상한 애니메이션 동작을 확보하도록 수정하세요.

Comment on lines +9 to +11
phoneNumber: z
.string()
.regex(/^010-\d{4}-\d{4}$/, '올바른 전화번호 형식이 아닙니다.'),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

전화번호 입력 유연성 고려

현재 정규식 /^010-\d{4}-\d{4}$/는 하이픈이 포함된 형식만 허용합니다. 사용자가 하이픈 없이 입력할 가능성이 있다면, 입력 필드에서 자동 포맷팅을 적용하거나 정규식을 더 유연하게 수정하는 것을 고려해 주세요.

♻️ 유연한 정규식 예시
   phoneNumber: z
     .string()
-    .regex(/^010-\d{4}-\d{4}$/, '올바른 전화번호 형식이 아닙니다.'),
+    .regex(/^010-?\d{4}-?\d{4}$/, '올바른 전화번호 형식이 아닙니다.')
+    .transform((val) => {
+      const digits = val.replace(/-/g, '');
+      return `${digits.slice(0, 3)}-${digits.slice(3, 7)}-${digits.slice(7)}`;
+    }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/types/schemas/pairGameSubmitSchema.ts` around lines 9 - 11, The
phoneNumber zod rule in pairGameSubmitSchema.ts currently only accepts
hyphenated input; update the phoneNumber schema to accept both hyphenated and
non-hyphenated formats by either (A) replacing the regex with a flexible one
such as /^010-?\d{4}-?\d{4}$/ (or /^(?:010-?\d{4}-?\d{4}|010\d{8})$/ to be
explicit) or (B) normalize input first (e.g., use z.string().transform(s =>
s.replace(/-/g, '')) and then validate with /^\d{10}$/ or /^010\d{8}$/), keeping
the same error message text ('올바른 전화번호 형식이 아닙니다.') and updating the phoneNumber
declaration accordingly.

@ujinsim ujinsim merged commit 666af06 into main Mar 3, 2026
4 checks passed
@ujinsim ujinsim deleted the game branch March 3, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants