Skip to content

[콜밴] 생성/참여 제한 사용자 모달 노출#1238

Merged
ff1451 merged 5 commits intodevelopfrom
feat/#1237/callvan-restriction-modal
Apr 28, 2026
Merged

[콜밴] 생성/참여 제한 사용자 모달 노출#1238
ff1451 merged 5 commits intodevelopfrom
feat/#1237/callvan-restriction-modal

Conversation

@ff1451
Copy link
Copy Markdown
Contributor

@ff1451 ff1451 commented Apr 27, 2026

What is this PR? 🔍

Changes 📝

  • /callvan/restriction 조회 API와 query option을 추가했습니다.
  • 제한 상태에 따라 14일 이용 정지 / 영구 정지 카피를 분기하는 콜밴 전용 모달을 구현했습니다.
  • 게시글 생성/참여 mutation에서 FORBIDDEN_CALLVAN_RESTRICTED_USER 발생 시 제한 상태를 조회한 뒤 모달을 띄우도록 연결했습니다.

ScreenShot 📷

Test CheckList ✅

  • yarn tsc --noEmit --pretty false
  • yarn eslint src/api/callvan/queries.ts src/components/Callvan/hooks/useCallvanRestrictionModal.tsx src/components/Callvan/hooks/useCreateCallvan.ts src/components/Callvan/hooks/useJoinCallvan.ts
  • 실제 403 제한 응답 시나리오 수동 확인

Precaution

  • 제한 모달은 FORBIDDEN_CALLVAN_RESTRICTED_USER 응답에만 반응합니다.
  • 제한 상세는 direct API 호출이 아니라 callvanQueries.restriction + queryClient.fetchQuery로 컨벤션을 맞췄습니다.

✔️ Please check if the PR fulfills these requirements

  • It's submitted to the correct branch, not the develop branch unconditionally?
  • If on a hotfix branch, ensure it targets main?
  • There are no warning message when you run yarn lint

Summary by CodeRabbit

  • New Features

    • 캘반 생성/참여 실패 시 계정 제한 정보를 자동 조회해 제한 안내 모달을 표시
    • 임시/영구 제한 유형과 제한 종료일을 사용자 친화적으로 노출
  • Improvements

    • 제한 모달에 닫기 버튼·배경 클릭·Esc키로 닫기 가능하고 본문 스크롤을 잠금
    • 제한 안내 문구 및 날짜 포맷(한국/서울 기준) 개선
    • 알림 캐시/동기화 키 사용 방식 정렬로 알림 동작 일관성 향상

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 27, 2026

Walkthrough

콜밴 이용 제한 상태 조회 API, 타입, 제한 모달 UI 및 스타일, 모달 포털 훅, 제한 판별/카피 유틸리티를 추가하고 생성/참여 훅의 403 에러 처리 흐름을 모달 연동으로 변경했습니다.

Changes

Cohort / File(s) Summary
API Layer
src/api/callvan/APIDetail.ts, src/api/callvan/entity.ts, src/api/callvan/index.ts, src/api/callvan/queries.ts
GetCallvanRestriction 요청 클래스 추가, CallvanRestrictionType/CallvanRestrictionResponse 타입 도입, React Query용 callvanQueries.restriction(token) 및 토큰 스코프 쿼리키 변경.
Modal Component
src/components/Callvan/components/CallvanRestrictionModal/index.tsx, src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss
제한 상태를 표시하는 CallvanRestrictionModal 컴포넌트 및 관련 SCSS 모듈 추가(오버레이, 닫기 핸들링, 스타일링).
Modal Hook
src/components/Callvan/hooks/useCallvanRestrictionModal.tsx
포털으로 모달을 열고, 에러를 판별해 제한 API를 조회한 뒤 모달을 표시하는 useCallvanRestrictionModal(token) 훅 추가.
Hook Integrations
src/components/Callvan/hooks/useCreateCallvan.ts, src/components/Callvan/hooks/useJoinCallvan.ts
생성/참여 훅의 onError를 async로 변경해 openFromError로 제한 모달 우선 처리하도록 수정(모달 오픈 시 기존 에러 처리 조기 종료).
Utils
src/components/Callvan/utils/callvanRestriction.ts
isCallvanRestrictedError(403 검사) 및 getCallvanRestrictionModalCopy(제한 타입별 모달 카피 생성) 추가; 날짜 포맷/폴백 처리 포함.
Mutations / SSR keys
src/api/callvan/mutations.ts, src/pages/callvan/...
알림 쿼리 키 사용/무효화 로직과 SSR에서의 빈 토큰 케이스(key) 사용을 토큰 인자형 쿼리키와 정합되게 조정.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Client Hook<br/>(useCreateCallvan / useJoinCallvan)
    participant ModalHook as useCallvanRestrictionModal
    participant APIClient as API Client
    participant Server as Server
    participant UI as CallvanRestrictionModal

    User->>Client: 게시글 생성/참여 요청
    Client->>Server: POST /callvan/... (API 호출)
    Server-->>Client: 403 FORBIDDEN_CALLVAN_RESTRICTED_USER
    Client->>ModalHook: openFromError(error)
    ModalHook->>ModalHook: isCallvanRestrictedError 검사
    alt 제한 에러
        ModalHook->>APIClient: GET /callvan/restriction
        APIClient->>Server: GET /callvan/restriction
        Server-->>APIClient: CallvanRestrictionResponse
        APIClient-->>ModalHook: restriction 데이터
        ModalHook->>UI: 모달 오픈 (restriction 전달)
        UI->>User: 제한 모달 표시
        User->>UI: 모달 닫기
        UI->>ModalHook: onClose
    else 비제한/다른 에러
        ModalHook-->>Client: false 반환
        Client->>Client: 기존 에러 토스트 처리
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested labels

✨ Feature

Suggested reviewers

  • JeongWon-CHO
  • ParkSungju01
  • hyejun0228
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
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 (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed PR 제목 '[콜밴] 생성/참여 제한 사용자 모달 노출'은 제한된 사용자를 위한 모달 노출이라는 핵심 변경을 명확하게 요약하며, 변경 사항의 주요 목적을 직관적으로 전달합니다.
Linked Issues check ✅ Passed PR은 세 가지 요구사항을 모두 충족합니다: (1) /callvan/restriction API 및 쿼리 옵션 구현 [#1237], (2) 제한 상태에 따른 모달 UI와 카피 분기 구현 [#1237], (3) 403 에러 발생 시 제한 상태 조회 후 모달 노출 연결 [#1237].
Out of Scope Changes check ✅ Passed 모든 변경사항이 #1237의 요구사항 범위 내입니다. 추가된 코드는 제한 모달, API 클라이언트, 쿼리 옵션, 훅 통합에 집중되어 있으며, 불필요한 외부 변경이 없습니다.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/#1237/callvan-restriction-modal

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
Copy Markdown

@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: 3

🧹 Nitpick comments (10)
src/api/callvan/queries.ts (1)

53-57: (선택) 에러 플로우 전용 쿼리: 항상 최신 상태 보장

해당 쿼리는 403 발생 시 queryClient.fetchQuery로만 호출될 가능성이 큰데, 사용자의 제재 상태는 시점에 따라 달라질 수 있으므로 캐시된 값으로 모달 카피가 어긋나지 않게 staleTime: 0 또는 gcTime을 짧게 잡아두면 안전합니다. 현재 기본값으로도 동작에는 문제 없으니 선택 사항입니다.

   restriction: (token: string) =>
     queryOptions({
       queryKey: callvanQueryKeys.restriction,
       queryFn: () => getCallvanRestriction(token),
+      staleTime: 0,
     }),

@tanstack/react-query 사용 시 queryKey 컨벤션과 에러/로딩 상태 처리를 확인해주세요라는 코딩 가이드라인에 비추어, 현 queryKey 설계 자체는 컨벤션에 맞습니다.

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

In `@src/api/callvan/queries.ts` around lines 53 - 57, The restriction query (the
restriction function using queryOptions with queryKey
callvanQueryKeys.restriction and queryFn getCallvanRestriction) should avoid
serving stale cached restraint state because callers will often invoke it via
queryClient.fetchQuery on 403 flows; update the queryOptions call to set
staleTime: 0 (and optionally a shorter cacheTime/gcTime) so the value is always
refetched/fresh for modal copy and error flows.
src/api/callvan/entity.ts (1)

77-83: (선택) 판별 유니언으로 타입 안정성 강화 고려

is_restricted: false일 때는 restriction_type/restricted_untilnull이고, true일 때는 값이 있다는 도메인 규칙을 타입으로 강제할 수 있으면, 호출부에서 null 가드가 줄어들고 getCallvanRestrictionModalCopyrestriction.restriction_type 분기도 더 안전해집니다. 다만 백엔드 응답 형태를 그대로 매핑하는 현재 방식도 충분히 합리적이라 선택 사항입니다.

♻️ 제안 예시
-export interface CallvanRestrictionResponse {
-  is_restricted: boolean;
-  restriction_type: CallvanRestrictionType;
-  restricted_until: string | null;
-}
+export type CallvanRestrictionResponse =
+  | { is_restricted: false; restriction_type: null; restricted_until: null }
+  | {
+      is_restricted: true;
+      restriction_type: Exclude<CallvanRestrictionType, null>;
+      restricted_until: string | null;
+    };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/callvan/entity.ts` around lines 77 - 83, Convert the flat
CallvanRestrictionResponse into a discriminated union so TypeScript enforces
domain rules: create two interfaces (e.g., UnrestrictedCallvanResponse with
is_restricted: false and restriction_type/restricted_until as null, and
RestrictedCallvanResponse with is_restricted: true and restriction_type:
CallvanRestrictionType (non-null) and restricted_until: string) and then make
CallvanRestrictionResponse a union of those two; update CallvanRestrictionType
if needed to remove null and update call sites such as
getCallvanRestrictionModalCopy to rely on the discriminant is_restricted for
safe narrowing.
src/components/Callvan/hooks/useCreateCallvan.ts (1)

16-25: (권장) openFromError 실패 시 토스트 누락 위험 — try/catch로 폴백 보장

openFromError(e) 내부에서 queryClient.fetchQuery(callvanQueries.restriction(token))를 await할 텐데, 이 호출이 네트워크 오류 등으로 reject되면 await가 throw되어 아래의 isKoinError/sendClientError 분기가 통째로 건너뛰어지고 사용자에게 어떤 피드백도 가지 않습니다. 또한 openFromError는 모든 에러에 대해 호출되므로 영향 범위가 작지 않습니다. try/catch로 감싸 실패 시 기존 토스트 경로로 폴백하도록 가드해 두는 것을 권장합니다.

♻️ 제안
-    onError: async (e) => {
-      if (await openFromError(e)) return;
-
-      if (isKoinError(e)) {
+    onError: async (e) => {
+      try {
+        if (await openFromError(e)) return;
+      } catch (openError) {
+        sendClientError(openError);
+      }
+
+      if (isKoinError(e)) {
         showToast('error', e.message || '게시글 작성에 실패했습니다.');
       } else {
         sendClientError(e);
         showToast('error', '게시글 작성에 실패했습니다.');
       }
     },

동일한 패턴이 src/components/Callvan/hooks/useJoinCallvan.ts에도 있어 함께 적용 부탁드립니다(중복 의견 생략).

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

In `@src/components/Callvan/hooks/useCreateCallvan.ts` around lines 16 - 25, Wrap
the call to openFromError(e) in a try/catch so that if openFromError throws
(e.g., network failure inside queryClient.fetchQuery) we still fall back to the
existing toast/error handling; specifically, in useCreateCallvan.ts change the
onError handler to try { if (await openFromError(e)) return; } catch { /*
proceed to fallback */ } and then run the existing isKoinError(e) /
sendClientError(e) / showToast(...) logic; apply the same safe try/catch pattern
to the identical onError handling in useJoinCallvan.ts so both hooks guarantee a
toast fallback when openFromError fails.
src/components/Callvan/utils/callvanRestriction.ts (2)

12-16: (권장) 날짜 포맷에 연도 누락 — 연말 경계에서 모호해질 수 있음

{ month: 'long', day: 'numeric' }만 지정해 “1월 15일까지”처럼 연도가 빠집니다. 14일 정지가 연도 경계를 넘으면(예: 12/28에 시작) 사용자가 어느 해의 1월인지 헷갈릴 수 있습니다. 디자인이 허용한다면 year: 'numeric'을 추가하거나, “2026년 1월 15일까지”처럼 풀네임 포맷을 권장합니다.

 const restrictionDateFormatter = new Intl.DateTimeFormat('ko-KR', {
+  year: 'numeric',
   month: 'long',
   day: 'numeric',
   timeZone: 'Asia/Seoul',
 });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/utils/callvanRestriction.ts` around lines 12 - 16, The
date formatter restrictionDateFormatter currently omits the year which can cause
ambiguity across year boundaries; update its Intl.DateTimeFormat options (the
object passed when constructing restrictionDateFormatter) to include year:
'numeric' (or use a full localized format including year, month and day) so
formatted strings show the year (e.g., "2026년 1월 15일") and remove ambiguity at
year boundaries.

33-61: (선택) 알 수 없는 restriction_type에 대한 폴백 카피 검토

PERMANENT_RESTRICTION과 폴백(“이용 제한”)의 description이 동일하고 title만 달라집니다. 향후 백엔드가 새로운 제재 타입을 추가했을 때 사용자에게 “이용 제한”이라는 모호한 문구로 노출되는데, 디자인/카피 정책상 의도된 동작인지 한 번 확인 부탁드립니다. 또한 is_restricted: true인데 restriction_typenull로 내려오는 케이스(현재는 폴백으로 처리)는 데이터 이상이므로 모니터링 로깅을 고려해도 좋겠습니다.

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

In `@src/components/Callvan/utils/callvanRestriction.ts` around lines 33 - 61,
getCallvanRestrictionModalCopy currently maps known restriction types but falls
back to a generic "이용 제한" copy for any unknown or null restriction_type, which
can be ambiguous; update getCallvanRestrictionModalCopy to (1) explicitly handle
unknown/null restriction_type by returning a clear fallback message (e.g.,
include a neutral line like "제재 유형이 확인되지 않습니다. 고객센터에 문의해주세요." or otherwise
confirm desired copy) instead of silently reusing the same PERMANENT-like
description, and (2) add monitoring/logging when restriction_type is null or
unrecognized (e.g., call your app logger/metrics in the unknown branch) so data
anomalies (is_restricted: true but null type) are tracked; locate and modify the
getCallvanRestrictionModalCopy function (and its callsite if needed) to
implement these changes.
src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss (1)

50-93: (선택) 컬러/타이포 토큰화 권장

#b611f5(60번, 85번 라인), Pretendard, sans-serif(53/68/88번 라인) 등 디자인 토큰성 값이 반복됩니다. 프로젝트에 SCSS 변수/믹스인이 있다면 그쪽으로 이관하면 디자인 시스템 변경에 유연해집니다(예: $color-primary, $font-family-base). 동일 컴포넌트 내 반복은 작은 규모지만, 다른 콜밴 모달과의 일관성도 같이 유지할 수 있습니다.

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

In
`@src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss`
around lines 50 - 93, The styles repeat design tokens (hex color `#b611f5` and
font-family Pretendard, sans-serif) inside the CallvanRestrictionModal BEM
blocks (&__accent, &__button, &__title/&__description), so replace the
hard-coded values with SCSS variables (e.g. $color-primary, $font-family-base)
and import/consume your project's token file or a local variables partial at the
top of CallvanRestrictionModal.module.scss; update occurrences in selectors
&__accent, &__button and the font declarations in &__title, &__description to
use those variables to ensure consistency and easier future changes.
src/components/Callvan/components/CallvanRestrictionModal/index.tsx (3)

29-30: 오버레이 닫기 버튼의 보조 기술 라벨 중복

modal__dim 버튼과 본문의 “닫기” 버튼 모두 동일하게 aria-label="닫기"/닫기 텍스트로 노출되어, 스크린리더 사용자에게 같은 이름의 버튼이 두 개 안내됩니다. 오버레이 버튼 라벨을 구분되는 문구(예: aria-label="배경 클릭으로 닫기")로 변경하거나, 보조 기술에서는 숨기는(aria-hidden) 방향을 검토해 주세요. 다만 aria-hidden된 인터랙티브 요소는 키보드 사용자에게 노출되므로, 키보드로는 포커스되지 않도록 tabIndex={-1}도 함께 고려해 주세요.

♻️ 제안 변경
-      <button type="button" className={styles.modal__dim} onClick={onClose} aria-label="닫기" />
+      <button
+        type="button"
+        className={styles.modal__dim}
+        onClick={onClose}
+        aria-label="배경 클릭으로 닫기"
+        tabIndex={-1}
+      />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 29 - 30, The overlay close button (the element with className "modal__dim"
that calls onClose) currently uses the same aria-label/text as the in-modal
close control causing duplicate names for screen reader users; update that
overlay button to either use a distinct accessible name (e.g., aria-label="배경
클릭으로 닫기") or hide it from assistive technology and prevent keyboard focus by
adding aria-hidden="true" together with tabIndex={-1} while keeping the onClose
behavior for pointer users—adjust the element with className modal__dim and its
use of onClose accordingly.

38-42: key로 라인 텍스트 사용 시 중복 위험

descriptionLines의 항목 텍스트가 향후 동일 문구를 포함하게 되면 React 키 중복 경고가 발생하고 리렌더 동작이 어긋날 수 있습니다. 현재 카피상으로는 충돌이 없을 가능성이 높지만, 카피 변경에 안전하도록 인덱스를 보조 키로 결합하거나 유틸 단계에서 안정적인 id를 부여하는 편이 안전합니다.

♻️ 간단한 보강
-              {descriptionLines.map((line) => (
-                <p key={line}>{line}</p>
-              ))}
+              {descriptionLines.map((line, index) => (
+                <p key={`${index}-${line}`}>{line}</p>
+              ))}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 38 - 42, The map over descriptionLines in the CallvanRestrictionModal
renders <p> elements using the raw line string as the key, which can produce
duplicate React keys if identical lines appear; update the mapping inside the
component to produce stable, unique keys (e.g., combine the line with its index
or an upstream stable id) when rendering inside the styles.modal__description
block — locate the descriptionLines.map in CallvanRestrictionModal and change
the key generation to something like a composite key (line + index) or use a
precomputed id array so each <p> has a deterministic unique key.

14-26: 모달 열림 상태에서 본문 스크롤 잠금 추가 권장

현재 모달이 열려있어도 배경 페이지가 스크롤됩니다. 프로젝트의 다른 모달 컴포넌트들(ImageModal, 식단 상세 모달, 분실물 관련 모달 등)에서 일관되게 사용하는 useBodyScrollLock() 훅을 활용하여 UX를 개선해주세요.

const { useBodyScrollLock } = 'utils/hooks/ui/useBodyScrollLock';
// ...
useBodyScrollLock();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 14 - 26, The modal currently only handles Escape via the handleKeyDown
effect (in CallvanRestrictionModal) but doesn't lock body scrolling; import and
invoke the existing useBodyScrollLock hook (from
utils/hooks/ui/useBodyScrollLock) inside the CallvanRestrictionModal component
when the modal is mounted/open to prevent background scroll—i.e., add the import
for useBodyScrollLock and call useBodyScrollLock() alongside the existing
useEffect that references onClose so the body scroll is locked while the modal
is active and automatically released on unmount.
src/components/Callvan/hooks/useCallvanRestrictionModal.tsx (1)

33-39: is_restricted=false 케이스에서 불필요한 모달 노출 재검토

서버가 FORBIDDEN_CALLVAN_RESTRICTED_USER 에러를 반환한 상황에서 후속 API 요청이 is_restricted=false를 돌려주는 경우, 현재는 여전히 모달을 띄우면서 getCallvanRestrictionModalCopy(null)의 기본 카피("이용 제한")를 보여줍니다. 이는 에러 메시지와 실제 제한 상태가 모순되어 사용자에게 혼란을 줄 수 있습니다.

대신 is_restricted=false인 경우 모달을 띄우지 않고 false를 반환하여 원래의 토스트 에러 흐름으로 폴백하는 것이 더 논리적이고 안전합니다.

권장 수정안
       try {
         const restriction = await queryClient.fetchQuery(callvanQueries.restriction(token));
-        open(restriction.is_restricted ? restriction : null);
+        if (!restriction.is_restricted) return true;
+        open(restriction);
       } catch (restrictionError) {
         sendClientError(restrictionError);
         open(null);
       }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/hooks/useCallvanRestrictionModal.tsx` around lines 33
- 39, The current logic opens the modal when restriction.is_restricted is falsy
by passing null to open; instead, only open the modal when
restriction.is_restricted === true and return false when it's false so the
original toast/error flow continues. Update the block that calls
queryClient.fetchQuery(callvanQueries.restriction(token)) to: after fetching, if
restriction.is_restricted === true call open(restriction) and return true; if
restriction.is_restricted === false do not call open and return false; keep the
catch handling (sendClientError(restrictionError)) and return false there as
well. Ensure you change usages around open, queryClient.fetchQuery,
callvanQueries.restriction, and sendClientError accordingly.
🤖 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/components/Callvan/components/CallvanRestrictionModal/index.tsx`:
- Around line 28-50: CallvanRestrictionModal currently sets role="dialog" but
doesn't manage focus: ensure when the component mounts focus moves into the
modal, trap Tab focus inside the modal while open, and restore focus to the
previously active element on unmount; specifically set initial focus to the
visible close button (the button with class modal__button) instead of the
background dim button (modal__dim), and implement a focus trap (use a library
like react-focus-lock around the modal__sheet or implement keyboard handlers to
keep focus inside). On unmount or when onClose runs, return focus to the element
saved before mounting. Update the CallvanRestrictionModal component to perform
these steps and wire them to the existing onClose prop.

In `@src/components/Callvan/hooks/useCallvanRestrictionModal.tsx`:
- Around line 11-22: The current hook useCallvanRestrictionModal opens the modal
but the related queries use a static key and no staleTime; update
callvanQueryKeys to make restriction a function
(callvanQueryKeys.restriction(token)) so the queryKey includes the token, and
change callvanQueries.restriction(token) to use that key, pass a queryFn that
calls getCallvanRestriction(token), and set a sensible staleTime (e.g., 60000
ms) to avoid unnecessary refetches; apply the same pattern for the notifications
query so its key includes token and it sets staleTime as well.

In `@src/components/Callvan/utils/callvanRestriction.ts`:
- Around line 18-22: The current isCallvanRestrictedError function compares
String(error.code) to CALLVAN_RESTRICTED_ERROR_CODE but KoinError.code is a
number and your backend uses numeric status codes; update
isCallvanRestrictedError (keep the isKoinError guard) to check the actual
field/type the backend uses for the restriction (e.g., error.status === 403 or
numeric error.code === <numeric constant>) instead of stringifying the numeric
code, or switch CALLVAN_RESTRICTED_ERROR_CODE to the numeric value if that
constant is wrong; after changing, run the manual "403 scenario" test from the
PR checklist to verify the restriction modal appears.

---

Nitpick comments:
In `@src/api/callvan/entity.ts`:
- Around line 77-83: Convert the flat CallvanRestrictionResponse into a
discriminated union so TypeScript enforces domain rules: create two interfaces
(e.g., UnrestrictedCallvanResponse with is_restricted: false and
restriction_type/restricted_until as null, and RestrictedCallvanResponse with
is_restricted: true and restriction_type: CallvanRestrictionType (non-null) and
restricted_until: string) and then make CallvanRestrictionResponse a union of
those two; update CallvanRestrictionType if needed to remove null and update
call sites such as getCallvanRestrictionModalCopy to rely on the discriminant
is_restricted for safe narrowing.

In `@src/api/callvan/queries.ts`:
- Around line 53-57: The restriction query (the restriction function using
queryOptions with queryKey callvanQueryKeys.restriction and queryFn
getCallvanRestriction) should avoid serving stale cached restraint state because
callers will often invoke it via queryClient.fetchQuery on 403 flows; update the
queryOptions call to set staleTime: 0 (and optionally a shorter
cacheTime/gcTime) so the value is always refetched/fresh for modal copy and
error flows.

In
`@src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss`:
- Around line 50-93: The styles repeat design tokens (hex color `#b611f5` and
font-family Pretendard, sans-serif) inside the CallvanRestrictionModal BEM
blocks (&__accent, &__button, &__title/&__description), so replace the
hard-coded values with SCSS variables (e.g. $color-primary, $font-family-base)
and import/consume your project's token file or a local variables partial at the
top of CallvanRestrictionModal.module.scss; update occurrences in selectors
&__accent, &__button and the font declarations in &__title, &__description to
use those variables to ensure consistency and easier future changes.

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx`:
- Around line 29-30: The overlay close button (the element with className
"modal__dim" that calls onClose) currently uses the same aria-label/text as the
in-modal close control causing duplicate names for screen reader users; update
that overlay button to either use a distinct accessible name (e.g.,
aria-label="배경 클릭으로 닫기") or hide it from assistive technology and prevent
keyboard focus by adding aria-hidden="true" together with tabIndex={-1} while
keeping the onClose behavior for pointer users—adjust the element with className
modal__dim and its use of onClose accordingly.
- Around line 38-42: The map over descriptionLines in the
CallvanRestrictionModal renders <p> elements using the raw line string as the
key, which can produce duplicate React keys if identical lines appear; update
the mapping inside the component to produce stable, unique keys (e.g., combine
the line with its index or an upstream stable id) when rendering inside the
styles.modal__description block — locate the descriptionLines.map in
CallvanRestrictionModal and change the key generation to something like a
composite key (line + index) or use a precomputed id array so each <p> has a
deterministic unique key.
- Around line 14-26: The modal currently only handles Escape via the
handleKeyDown effect (in CallvanRestrictionModal) but doesn't lock body
scrolling; import and invoke the existing useBodyScrollLock hook (from
utils/hooks/ui/useBodyScrollLock) inside the CallvanRestrictionModal component
when the modal is mounted/open to prevent background scroll—i.e., add the import
for useBodyScrollLock and call useBodyScrollLock() alongside the existing
useEffect that references onClose so the body scroll is locked while the modal
is active and automatically released on unmount.

In `@src/components/Callvan/hooks/useCallvanRestrictionModal.tsx`:
- Around line 33-39: The current logic opens the modal when
restriction.is_restricted is falsy by passing null to open; instead, only open
the modal when restriction.is_restricted === true and return false when it's
false so the original toast/error flow continues. Update the block that calls
queryClient.fetchQuery(callvanQueries.restriction(token)) to: after fetching, if
restriction.is_restricted === true call open(restriction) and return true; if
restriction.is_restricted === false do not call open and return false; keep the
catch handling (sendClientError(restrictionError)) and return false there as
well. Ensure you change usages around open, queryClient.fetchQuery,
callvanQueries.restriction, and sendClientError accordingly.

In `@src/components/Callvan/hooks/useCreateCallvan.ts`:
- Around line 16-25: Wrap the call to openFromError(e) in a try/catch so that if
openFromError throws (e.g., network failure inside queryClient.fetchQuery) we
still fall back to the existing toast/error handling; specifically, in
useCreateCallvan.ts change the onError handler to try { if (await
openFromError(e)) return; } catch { /* proceed to fallback */ } and then run the
existing isKoinError(e) / sendClientError(e) / showToast(...) logic; apply the
same safe try/catch pattern to the identical onError handling in
useJoinCallvan.ts so both hooks guarantee a toast fallback when openFromError
fails.

In `@src/components/Callvan/utils/callvanRestriction.ts`:
- Around line 12-16: The date formatter restrictionDateFormatter currently omits
the year which can cause ambiguity across year boundaries; update its
Intl.DateTimeFormat options (the object passed when constructing
restrictionDateFormatter) to include year: 'numeric' (or use a full localized
format including year, month and day) so formatted strings show the year (e.g.,
"2026년 1월 15일") and remove ambiguity at year boundaries.
- Around line 33-61: getCallvanRestrictionModalCopy currently maps known
restriction types but falls back to a generic "이용 제한" copy for any unknown or
null restriction_type, which can be ambiguous; update
getCallvanRestrictionModalCopy to (1) explicitly handle unknown/null
restriction_type by returning a clear fallback message (e.g., include a neutral
line like "제재 유형이 확인되지 않습니다. 고객센터에 문의해주세요." or otherwise confirm desired copy)
instead of silently reusing the same PERMANENT-like description, and (2) add
monitoring/logging when restriction_type is null or unrecognized (e.g., call
your app logger/metrics in the unknown branch) so data anomalies (is_restricted:
true but null type) are tracked; locate and modify the
getCallvanRestrictionModalCopy function (and its callsite if needed) to
implement these changes.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: cfff1a90-2776-4b49-a920-2a50f15e6089

📥 Commits

Reviewing files that changed from the base of the PR and between 74e357d and e412ccd.

📒 Files selected for processing (10)
  • src/api/callvan/APIDetail.ts
  • src/api/callvan/entity.ts
  • src/api/callvan/index.ts
  • src/api/callvan/queries.ts
  • src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss
  • src/components/Callvan/components/CallvanRestrictionModal/index.tsx
  • src/components/Callvan/hooks/useCallvanRestrictionModal.tsx
  • src/components/Callvan/hooks/useCreateCallvan.ts
  • src/components/Callvan/hooks/useJoinCallvan.ts
  • src/components/Callvan/utils/callvanRestriction.ts

Comment on lines +28 to +50
return (
<div className={styles.modal__overlay} role="dialog" aria-modal="true" aria-labelledby="callvan-restriction-title">
<button type="button" className={styles.modal__dim} onClick={onClose} aria-label="닫기" />
<div className={styles.modal__sheet}>
<div className={styles.modal__content}>
<div className={styles.modal__text}>
<h2 id="callvan-restriction-title" className={styles.modal__title}>
<span className={styles.modal__accent}>{titleAccent}</span>
{titleRest}
</h2>
<div className={styles.modal__description}>
{descriptionLines.map((line) => (
<p key={line}>{line}</p>
))}
</div>
</div>
<button type="button" className={styles.modal__button} onClick={onClose}>
닫기
</button>
</div>
</div>
</div>
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

모달 포커스 관리(접근성) 보완 필요

role="dialog" aria-modal="true"만 선언되어 있고, 모달이 열릴 때 모달 내부로 포커스 이동, Tab 포커스 트랩, 닫힐 때 트리거 요소로 포커스 복원이 누락되어 있습니다. 키보드/스크린리더 사용자가 모달 뒤의 요소로 포커스가 빠져나가 흐름이 깨질 수 있습니다. 또한 첫 번째 포커스 가능 요소가 비가시 modal__dim 버튼이라 Tab 키로 진입 시 의도와 다른 포커스 위치에서 시작됩니다.

권장: react-focus-lock 같은 검증된 라이브러리를 도입하거나, 최소한 마운트 시 닫기 버튼에 초기 포커스를 부여하고 unmount 시 이전 active element로 포커스를 복원하세요.

♻️ 최소한의 초기 포커스 및 복원 예시
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
@@
 export default function CallvanRestrictionModal({ restriction, onClose }: CallvanRestrictionModalProps) {
   const { titleAccent, titleRest, descriptionLines } = getCallvanRestrictionModalCopy(restriction);
+  const closeButtonRef = useRef<HTMLButtonElement>(null);

   useEffect(() => {
+    const previouslyFocused = document.activeElement as HTMLElement | null;
+    closeButtonRef.current?.focus();
     const handleKeyDown = (event: KeyboardEvent) => {
       if (event.key === 'Escape') {
         onClose();
       }
     };

     document.addEventListener('keydown', handleKeyDown);

     return () => {
       document.removeEventListener('keydown', handleKeyDown);
+      previouslyFocused?.focus?.();
     };
   }, [onClose]);
@@
-          <button type="button" className={styles.modal__button} onClick={onClose}>
+          <button type="button" ref={closeButtonRef} className={styles.modal__button} onClick={onClose}>
             닫기
           </button>

코딩 가이드라인에 따라(src/components/**: 접근성(a11y) 관련 속성이 적절히 사용되고 있는지 확인) 모달 접근성 패턴을 보강해 주세요.

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

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 28 - 50, CallvanRestrictionModal currently sets role="dialog" but doesn't
manage focus: ensure when the component mounts focus moves into the modal, trap
Tab focus inside the modal while open, and restore focus to the previously
active element on unmount; specifically set initial focus to the visible close
button (the button with class modal__button) instead of the background dim
button (modal__dim), and implement a focus trap (use a library like
react-focus-lock around the modal__sheet or implement keyboard handlers to keep
focus inside). On unmount or when onClose runs, return focus to the element
saved before mounting. Update the CallvanRestrictionModal component to perform
these steps and wire them to the existing onClose prop.

Comment on lines +11 to +22
export default function useCallvanRestrictionModal(token: string) {
const portalManager = useModalPortal();
const queryClient = useQueryClient();

const open = useCallback(
(restriction: CallvanRestrictionResponse | null) => {
portalManager.open((portalOption: Portal) => (
<CallvanRestrictionModal restriction={restriction} onClose={portalOption.close} />
));
},
[portalManager],
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# callvanQueries.restriction 정의 및 queryKey/staleTime 확인
fd -t f 'queries' src/api/callvan
rg -nP -C8 "restriction\s*[:=]\s*\(?token" --type=ts
rg -nP -C5 "queryKey|staleTime" src/api/callvan

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 6139


🏁 Script executed:

# callvanQueryKeys 정의 확인
rg -nP "callvanQueryKeys\s*=" src/api/callvan --type=ts -A 15

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1321


🏁 Script executed:

# useCallvanRestrictionModal 전체 코드 확인
cat -n src/components/Callvan/hooks/useCallvanRestrictionModal.tsx

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1958


queryKey 및 staleTime 설정 수정 필요

callvanQueries.restriction의 queryKey에 token이 포함되어 있지 않습니다. 토큰이 변경될 때 동일한 queryKey ['callvan', 'restriction']을 사용하므로, 이전 토큰의 캐시 데이터가 의도치 않게 재사용될 수 있습니다. 또한 staleTime이 설정되지 않아 반복 호출 시 매번 새로운 네트워크 요청이 발생합니다.

수정 제안:

// src/api/callvan/queries.ts
export const callvanQueryKeys = {
  // ...
  restriction: (token: string) => ['callvan', 'restriction', token] as const,
};

export const callvanQueries = {
  // ...
  restriction: (token: string) =>
    queryOptions({
      queryKey: callvanQueryKeys.restriction(token),
      queryFn: () => getCallvanRestriction(token),
      staleTime: 60000, // 또는 적절한 값
    }),
};

동일한 문제가 notifications 쿼리에도 적용됩니다 (queryKey 정적, staleTime 미설정).

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

In `@src/components/Callvan/hooks/useCallvanRestrictionModal.tsx` around lines 11
- 22, The current hook useCallvanRestrictionModal opens the modal but the
related queries use a static key and no staleTime; update callvanQueryKeys to
make restriction a function (callvanQueryKeys.restriction(token)) so the
queryKey includes the token, and change callvanQueries.restriction(token) to use
that key, pass a queryFn that calls getCallvanRestriction(token), and set a
sensible staleTime (e.g., 60000 ms) to avoid unnecessary refetches; apply the
same pattern for the notifications query so its key includes token and it sets
staleTime as well.

Comment thread src/components/Callvan/utils/callvanRestriction.ts
Copy link
Copy Markdown
Contributor

@dooohun dooohun left a comment

Choose a reason for hiding this comment

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

수고하셨습니다~

Copy link
Copy Markdown

@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: 1

♻️ Duplicate comments (1)
src/components/Callvan/components/CallvanRestrictionModal/index.tsx (1)

31-49: ⚠️ Potential issue | 🟠 Major

모달 포커스 관리 보강이 아직 필요합니다.

이전 리뷰에서 지적한 초기 포커스 이동, Tab 트랩, unmount 시 포커스 복원이 아직 반영되지 않아 키보드 사용자가 모달 뒤로 빠질 수 있습니다. dim 버튼이 첫 포커스 대상이 되는 점도 그대로입니다. As per coding guidelines, "src/components/**: 접근성(a11y) 관련 속성이 적절히 사용되고 있는지 확인해주세요."

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

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 31 - 49, CallvanRestrictionModal lacks proper focus management: implement
focus capture, Tab-trap, and restore on unmount so keyboard users cannot tab
behind the modal and the dim button is not the initial focus. In the
CallvanRestrictionModal component, store document.activeElement on mount, use a
ref for the modal container (modal__sheet) to find the first focusable element
(or the close button modal__button or the heading) and programmatically focus
it; make the dim element (modal__dim) non-focusable or remove it from the tab
order; add a keydown listener on the modal to trap Tab/Shift+Tab within
focusable elements inside modal__sheet; on cleanup remove the listener and
restore the previously focused element; ensure these behaviors are implemented
using useEffect and refs so onClose/unmount restores focus reliably.
🤖 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/api/callvan/queries.ts`:
- Around line 15-16: The queryKey factories notifications and restriction
currently include the raw auth token which risks exposing secrets in React Query
cache/SSR; change notifications and restriction to return only non-sensitive
identifiers (e.g., ['callvan','notifications'] and ['callvan','restriction'] or
include a userId instead of token) and stop passing token in the key, then
ensure the token is provided to the queryFn at execution time (similar to
postDetail and chat) by reading it from context/params or injecting it into the
fetcher call; update any callers to pass token only to the queryFn, not into the
queryKey.

---

Duplicate comments:
In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx`:
- Around line 31-49: CallvanRestrictionModal lacks proper focus management:
implement focus capture, Tab-trap, and restore on unmount so keyboard users
cannot tab behind the modal and the dim button is not the initial focus. In the
CallvanRestrictionModal component, store document.activeElement on mount, use a
ref for the modal container (modal__sheet) to find the first focusable element
(or the close button modal__button or the heading) and programmatically focus
it; make the dim element (modal__dim) non-focusable or remove it from the tab
order; add a keydown listener on the modal to trap Tab/Shift+Tab within
focusable elements inside modal__sheet; on cleanup remove the listener and
restore the previously focused element; ensure these behaviors are implemented
using useEffect and refs so onClose/unmount restores focus reliably.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 6bf99cc0-aee9-4793-9f69-3862f53add65

📥 Commits

Reviewing files that changed from the base of the PR and between e412ccd and 7a386df.

📒 Files selected for processing (11)
  • src/api/callvan/entity.ts
  • src/api/callvan/mutations.ts
  • src/api/callvan/queries.ts
  • src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss
  • src/components/Callvan/components/CallvanRestrictionModal/index.tsx
  • src/components/Callvan/hooks/useCallvanRestrictionModal.tsx
  • src/components/Callvan/hooks/useCreateCallvan.ts
  • src/components/Callvan/hooks/useJoinCallvan.ts
  • src/components/Callvan/utils/callvanRestriction.ts
  • src/pages/callvan/index.tsx
  • src/pages/callvan/notifications/index.tsx
✅ Files skipped from review due to trivial changes (2)
  • src/components/Callvan/components/CallvanRestrictionModal/CallvanRestrictionModal.module.scss
  • src/api/callvan/entity.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/components/Callvan/hooks/useCallvanRestrictionModal.tsx

Comment on lines +15 to +16
notifications: (token: string) => ['callvan', 'notifications', token] as const,
restriction: (token: string) => ['callvan', 'restriction', token] as const,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/api/callvan/queries.ts | head -70

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 3050


🏁 Script executed:

rg -n "callvanQueryKeys\.(notifications|restriction)" src/

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 459


🏁 Script executed:

cat -n src/api/callvan/entity.ts | head -40

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1291


🏁 Script executed:

rg -n "token" src/api/callvan/index.ts | head -20

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/api/callvan/index.ts

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1865


🏁 Script executed:

rg -n "useQuery\|useSuspenseQuery" src/pages/callvan -A 5 | grep -A 5 "notifications\|restriction"

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/pages/callvan/notifications/index.tsx | head -60

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 2987


🏁 Script executed:

rg -n "useTokenState\|useUserState\|useAuthStore" src/ | head -20

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/utils/hooks/state/useTokenState.ts

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 293


🏁 Script executed:

rg -n "dehydrate" src/pages/callvan -B 2 -A 2

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 2621


🏁 Script executed:

cat -n src/pages/callvan/index.tsx | sed -n '40,70p'

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1206


🏁 Script executed:

rg -n "useTokenStore\|token" src/utils/zustand/auth.ts -A 5

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

fd -i "auth" src/utils/zustand --type f

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 129


🏁 Script executed:

cat -n src/utils/zustand/auth.ts 2>/dev/null || rg -n "useTokenStore" src/ -A 10

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1400


Raw token을 queryKey에 포함하지 마세요.

notifications/restriction의 queryKey에 인증 토큰을 포함하면 React Query 캐시와 SSR dehydrate() 페이로드에 토큰이 평문으로 노출됩니다. 같은 파일의 postDetail, chat처럼 queryKey에는 비민감 값만 포함하고, 토큰은 queryFn 실행 시점에 별도로 주입하는 방식을 권장합니다.

사용자당 캐시 격리가 필요하면 토큰 대신 사용자 ID나 별도 식별값을 사용하세요.

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

In `@src/api/callvan/queries.ts` around lines 15 - 16, The queryKey factories
notifications and restriction currently include the raw auth token which risks
exposing secrets in React Query cache/SSR; change notifications and restriction
to return only non-sensitive identifiers (e.g., ['callvan','notifications'] and
['callvan','restriction'] or include a userId instead of token) and stop passing
token in the key, then ensure the token is provided to the queryFn at execution
time (similar to postDetail and chat) by reading it from context/params or
injecting it into the fetcher call; update any callers to pass token only to the
queryFn, not into the queryKey.

@ff1451 ff1451 merged commit 702c988 into develop Apr 28, 2026
4 checks passed
@github-actions github-actions Bot deleted the feat/#1237/callvan-restriction-modal branch April 28, 2026 03: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.

[Callvan] Add restricted user modal for create and join flow

2 participants