Skip to content

Conversation

@0juicy
Copy link
Member

@0juicy 0juicy commented Jan 11, 2026

⚒️ 작업 내용

  • 유치원 상세 리뷰 무한 스크롤 추가
  • [리뷰 미작성 시 표시되는 '리뷰쓰기' 버튼 라우팅 미작동 문제 해결
  • FAQ 메뉴 추가 (임시 디자인)
  • 리뷰 삭제 리워드 광고 추가

⚙️ 관련 이슈

#47 #50

☎️ 관련 문의

생략

Summary by CodeRabbit

  • 새로운 기능

    • 리뷰 목록 무한 스크롤 및 페이지 단위 로딩 지원
    • 커뮤니티 인기글에 기간별(주/월/전체) 조회와 기간 선택 UI 추가
    • 문의 페이지에 FAQ 섹션 추가
    • 리뷰 삭제 시 리워드 광고 시청 흐름 추가
    • 툴팁(안내문) 표시 기능 추가
  • 개선

    • 좋아요 실패 안내문에 상세 설명 추가
    • 좋아요 즉시 반영(Optimistic UI) 적용으로 반응성 향상
  • 문구 수정

    • 일부 레이블·안내문(예: “고객” → “학급 운영”) 및 안내 텍스트 업데이트

✏️ Tip: You can customize this high-level summary in your review settings.

@0juicy 0juicy self-assigned this Jan 11, 2026
@coderabbitai
Copy link

coderabbitai bot commented Jan 11, 2026

Walkthrough

리뷰 무한 스크롤과 페이징 도입, 리뷰 삭제 시 보상 광고 흐름 통합, 낙관적 좋아요 업데이트 훅 추가, FAQ 섹션 및 여러 UI 레이블·툴팁 개선 등이 포함된 기능 확장입니다.

Changes

Cohort / File(s) 변경 요약
리뷰 API 및 쿼리 훅
src/entities/review/api.ts, src/entities/review/hooks/useGetReview.tsx
getWorkReviews/getInternshipReviewsReviewQueryParams 사용 및 PaginatedReviewResponse 반환으로 변경. useInfiniteWorkReviews/useInfiniteInternshipReviews 추가 및 기존 훅들이 쿼리 인수 형태 변경.
리뷰 삭제 + 보상 광고
src/entities/review/hooks/useDeleteWorkReview.tsx, src/entities/review/hooks/useDeleteInternshipReview.tsx, src/shared/hooks/useFlutterCommunication.ts, src/shared/utils/webViewCommunication.ts
삭제 뮤테이션에 skipAd?: boolean 추가. 광고 표시를 위한 useRewardAd 훅 및 requestRewardAd 구현으로 광고 완료/취소에 따라 삭제 흐름 제어.
무한 스크롤 UI 및 페이지 로직
src/widgets/review-panel/index.tsx, src/widgets/review-panel/lib/useReviewPage.tsx
무한 스크롤 연동(AutoFetchSentinel, LoadingSpinner), fetchNextPage, hasNextPage, isFetchingNextPage 노출 및 페이징 집계·평점 계산 추가.
낙관적 업데이트 및 좋아요 처리
src/shared/hooks/useOptimisticUpdate.ts, src/widgets/review-list/ui/ReviewCardList.tsx, src/entities/community/hooks/useToggleLike.tsx, src/entities/review/hooks/useReviewLike.tsx
useOptimisticUpdate 추가 및 ReviewCard에서 낙관적 좋아요 적용. useReviewLikemutatemutateAsync 전환. 오류 토스트 문구(좋아요 실패) 및 설명 추가.
커뮤니티 인기글 / 기간 선택 기능
src/entities/community/DTO.d.ts, src/entities/community/api.ts, src/entities/community/hooks/usePopularPostsByPeriod.tsx, src/shared/config/api.ts, src/widgets/community-feed/post-list/ui/PopularPostList.tsx, src/widgets/community-feed/ui/PeriodSelector.tsx, src/pages/community/index.tsx, src/widgets/home-dashboard/ui/PopularPostsPreview.tsx, src/widgets/community-feed/lib/category.ts
기간별 인기글 API/DTO/훅 추가, 기간 선택 UI(PeriodSelector) 및 인기글 목록/미리보기가 선택된 기간 기준으로 동작하도록 변경. 카테고리 구성 및 경로 상수에 POPULAR 경로 추가.
FAQ 기능 및 Inquiry 페이지 통합
src/shared/constants/inquiry.ts, src/widgets/user-dashboard/faq-list/index.tsx, src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx, src/pages/user/inquiry/index.tsx
FAQ_LIST 상수 추가, FaqList/FaqItem 컴포넌트 추가 및 Inquiry 페이지에 토글 가능한 FAQ 섹션 통합.
폼·레이블·툴팁 UI 변경
src/features/form/ui/fields/ScoredCommentField.tsx, src/widgets/review-editor/ui/WorkReviewForm.tsx, src/widgets/review-panel/lib/getFieldConfigsByType.ts
ScoredCommentFieldtooltipText? 추가 및 툴팁 렌더링. 여러 위치의 레이블 "고객" → "학급 운영"으로 변경 및 관련 툴팁 추가.
기타 문구·스토리북·작은 UI 변경
src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx, .storybook/preview.tsx, src/pages/home/index.tsx
삭제 확인문구에 광고 안내 추가, Storybook QueryProvider 경로 수정, 홈 배너 alt 텍스트 변경 등 소규모 UI/설정 변경.

Sequence Diagram(s)

sequenceDiagram
    participant User
    participant ReviewCard
    participant useOptimisticUpdate
    participant handleLike (mutateAsync)
    participant API
    participant Server

    User->>ReviewCard: 좋아요 클릭
    ReviewCard->>useOptimisticUpdate: setValue (낙관적 상태 적용)
    useOptimisticUpdate-->>ReviewCard: 즉시 반영된 상태
    ReviewCard->>handleLike: mutateAsync 호출
    handleLike->>API: 좋아요/취소 요청
    API->>Server: HTTP 요청
    alt 성공
        Server-->>API: 200 OK
        API-->>handleLike: 성공
        handleLike->>ReviewCard: 쿼리 무효화
    else 실패
        Server-->>API: 에러
        API-->>handleLike: 에러
        handleLike->>useOptimisticUpdate: rollback 호출
        useOptimisticUpdate-->>ReviewCard: 이전 상태 복구
        ReviewCard->>User: 실패 토스트 표시
    end
Loading
sequenceDiagram
    participant User
    participant ReviewPanel
    participant useReviewPage
    participant useInfiniteWorkReviews
    participant API
    participant Server

    User->>ReviewPanel: 페이지 진입
    ReviewPanel->>useReviewPage: 초기화
    useReviewPage->>useInfiniteWorkReviews: 첫 페이지 요청
    useInfiniteWorkReviews->>API: getWorkReviews(page=1)
    API->>Server: 페이지1 데이터 요청
    Server-->>API: 페이지1 응답 (totalPages 등)
    API-->>useInfiniteWorkReviews: 데이터 반환
    useInfiniteWorkReviews-->>useReviewPage: 페이지 데이터 제공
    useReviewPage-->>ReviewPanel: reviews 렌더링 데이터
    User->>ReviewPanel: 스크롤로 더보기 요구
    ReviewPanel->>useReviewPage: fetchNextPage 호출
    useReviewPage->>useInfiniteWorkReviews: 다음 페이지 요청
    useInfiniteWorkReviews->>API: getWorkReviews(page=2)
    API->>Server: 페이지2 데이터 요청
    Server-->>API: 페이지2 응답
    API-->>useInfiniteWorkReviews: 누적 데이터 반환
    useInfiniteWorkReviews-->>ReviewPanel: 추가된 리뷰 표시
Loading
sequenceDiagram
    participant User
    participant UI (삭제 트리거)
    participant useDeleteWorkReview
    participant useRewardAd
    participant FlutterWebView
    participant API
    participant Server

    User->>UI: 삭제 요청 (skipAd=false)
    UI->>useDeleteWorkReview: mutate({ workReviewId, skipAd:false })
    useDeleteWorkReview->>useRewardAd: showRewardAd()
    useRewardAd->>FlutterWebView: REQUEST_REWARD_AD 메시지
    FlutterWebView->>User: 광고 노출
    alt 광고 시청 완료
        FlutterWebView-->>useRewardAd: { status: 'success', rewarded: true/false }
        useRewardAd-->>useDeleteWorkReview: 결과 반환
        useDeleteWorkReview->>API: deleteWorkReview 요청
    else 광고 취소
        FlutterWebView-->>useRewardAd: { status: 'cancelled' }
        useRewardAd-->>useDeleteWorkReview: 취소 반환
        useDeleteWorkReview->>useDeleteWorkReview: 에러 throw (삭제 중단)
    end
    API->>Server: 삭제 요청
    Server-->>API: 200 OK
    API-->>useDeleteWorkReview: 성공
    useDeleteWorkReview->>UI: 쿼리 무효화 + 완료 토스트
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Suggested labels

Feat

Poem

🐰 스크롤 끝없이 내려가면 별이 뜨네,
광고 한 번 보고 비밀을 지워요,
좋아요는 재빨리 튀어나와 웃고,
FAQ는 척척 접혔다 폈다,
토끼가 축하해 — 점프! ✨🐇

🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.09% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'release/v1.3.3' is a version tag format, not a descriptive summary of the actual changes. It does not convey meaningful information about what was implemented (infinite scroll, routing fixes, FAQ, reward ads, etc.). 변경 사항의 주요 내용을 설명하는 명확한 제목으로 변경해주세요. 예: 'Add infinite scroll, fix review routing, add FAQ and reward ads for deletion' 또는 'release v1.3.3: 리뷰 무한스크롤, 라우팅 수정, FAQ 및 광고 추가'와 같은 형태를 권장합니다.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing touches
  • 📝 Generate docstrings

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: 5

🤖 Fix all issues with AI agents
In @src/pages/user/inquiry/index.tsx:
- Around line 44-49: The MenuItem for "1:1 문의하기" is using the wrong icon
constant; change the iconPath prop on the MenuItem (currently set to
SVG_PATHS.USER_MENU.LOGOUT) to SVG_PATHS.USER_MENU.INQUIRY so the displayed icon
matches the label and iconAlt; update the iconPath value in the <MenuItem ... />
invocation accordingly.

In @src/shared/utils/webViewCommunication.ts:
- Around line 214-245: In requestRewardAd the setTimeout created for
timeoutPromise isn't cleared when adPromise wins, leaking timers; capture the
timer id returned by setTimeout, then after awaiting Promise.race (using the
existing adPromise/timeoutPromise/result flow) call clearTimeout(timerId) before
any return path so the timer is always cleared (including the legacy-response
branch and normal return). Ensure the timer variable is defined in the outer
scope of the race so it can be cleared regardless of which promise resolved.

In @src/widgets/review-list/ui/ReviewCardList.tsx:
- Around line 93-96: The optimistic local like state is initialized to false
causing a UI flash; change the initializer passed into useOptimisticUpdate from
the literal false to the actual prop value isLiked (or a safe boolean fallback
like !!isLiked) so localIsLiked starts in sync with the real state; update the
call to useOptimisticUpdate(...) to use isLiked as the initial value and keep
the rest (setLocalIsLiked, rollbackIsLiked) unchanged.
- Around line 113-123: handleOptimisticLike lacks an isPending guard so rapid
clicks can trigger duplicate mutations; add an early return if isPending is
true, then set a pending flag (isPending = true) before calling handleLike and
clear it (isPending = false) in both the try and catch paths; keep the existing
optimistic updates (setLocalIsLiked, setLikeCount) and existing rollback calls
(rollbackIsLiked, rollbackLikeCount) but ensure isPending prevents re-entry
while the mutation is in-flight.

In @src/widgets/review-panel/lib/useReviewPage.tsx:
- Around line 30-38: Both hooks useSuspenseInfiniteQuery under the hood so
calling useInfiniteWorkReviews and useInfiniteInternshipReviews unconditionally
triggers both API requests; only the selected review type should mount its hook.
Fix by ensuring only the hook for the active type is invoked: either (A) split
the UI into two components (e.g., WorkReviewsPanel and InternshipReviewsPanel)
and move useInfiniteWorkReviews and useInfiniteInternshipReviews into those
components so only the mounted component runs its hook, or (B) switch the hooks
to useInfiniteQuery with an enabled flag and use the existing conditional
selection (keep function names
useInfiniteWorkReviews/useInfiniteInternshipReviews but implement them to use
useInfiniteQuery + enabled based on the passed type), or (C) wrap each hook call
behind a conditional render wrapper that only calls useInfiniteWorkReviews when
type === REVIEW_TYPES.WORK and useInfiniteInternshipReviews when type ===
REVIEW_TYPES.INTERNSHIP so the non-selected hook is never invoked.
🧹 Nitpick comments (5)
src/entities/community/hooks/useToggleLike.tsx (1)

11-17: 에러 메시지 개선이 좋습니다!

타이틀과 설명을 추가하여 사용자에게 더 명확한 피드백을 제공합니다.

🔍 선택적 개선: 디버깅을 위한 에러 로깅

에러 객체를 활용하여 디버깅 정보를 로깅하면 문제 해결에 도움이 될 수 있습니다:

-    onError: () => {
+    onError: (error) => {
+      console.error('좋아요 토글 실패:', error);
       toast({
         title: "좋아요 실패",
         description: "잠시 후 다시 시도해주세요.",
         variant: "destructive",
       });
     },
src/shared/constants/inquiry.ts (1)

30-55: FAQ 콘텐츠가 적절하게 구성되었습니다.

FAQ 항목들이 사용자의 주요 질문들을 잘 다루고 있습니다. 다만 2번 답변이 매우 길어서(약 432자) 모바일 화면에서 가독성에 영향을 줄 수 있습니다. 향후 FAQ가 늘어날 경우 CMS나 백엔드 API로 관리하는 것을 고려해볼 수 있습니다.

src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx (1)

18-38: 접근성 개선: aria-expanded 속성 추가 권장

스크린 리더 사용자를 위해 버튼에 aria-expanded 속성을 추가하면 현재 확장 상태를 명확히 전달할 수 있습니다. InquiryExpandButton 컴포넌트에서도 유사한 패턴을 사용하고 있습니다.

♻️ 접근성 개선 제안
      <button
        onClick={onToggle}
+       aria-expanded={expanded}
        className="relative flex items-start gap-3 rounded-lg border border-primary-light02 bg-white p-4 text-left duration-200 active:brightness-95"
      >
src/entities/review/hooks/useDeleteInternshipReview.tsx (1)

24-41: 광고 처리 로직이 useDeleteWorkReview와 중복됩니다.

광고 표시 및 결과 처리 로직이 두 훅에서 동일하게 반복됩니다. 재사용 가능한 헬퍼 함수로 추출하면 유지보수성이 향상됩니다.

♻️ 공통 로직 추출 예시
// 예: src/shared/utils/adGatedMutation.ts
export async function executeWithAdGate(
  showRewardAd: () => Promise<RewardAdResult>,
  skipAd: boolean
): Promise<void> {
  if (!skipAd) {
    const adResult = await showRewardAd();
    if (adResult.status === "cancelled") {
      throw new Error("광고를 끝까지 시청해야 리뷰를 삭제할 수 있습니다.");
    }
  }
}
src/entities/review/hooks/useGetReview.tsx (1)

213-247: 구현은 올바르지만, 중복 코드 리팩토링을 고려해보세요.

useInfiniteWorkReviews와 거의 동일한 로직입니다. 향후 유지보수를 위해 제네릭 훅으로 통합하는 것을 고려해볼 수 있습니다.

♻️ 제네릭 훅으로 통합 제안 (선택사항)
const useInfiniteReviews = <T>(
  queryKeyPrefix: string,
  kindergartenId: string,
  fetchFn: (id: number, params: ReviewQueryParams) => Promise<PaginatedResponse<T>>,
  sortType?: SortType,
  pageSize: number = 10
) => {
  const numericId = safeParseId(kindergartenId);

  return useSuspenseInfiniteQuery({
    queryKey: [queryKeyPrefix, kindergartenId, sortType, "infinite", pageSize],
    queryFn: ({ pageParam = 0 }) => {
      if (!numericId) {
        return Promise.resolve({ content: [], totalPages: 0 });
      }
      return fetchFn(numericId, { page: pageParam as number, size: pageSize, sortType });
    },
    getNextPageParam: (lastPage, allPages) => {
      if (!lastPage?.content || lastPage.content.length === 0) return undefined;
      const currentPage = allPages.length - 1;
      return currentPage + 1 >= lastPage.totalPages ? undefined : currentPage + 1;
    },
    initialPageParam: 0,
    ...DYNAMIC_CACHE_CONFIG,
  });
};

// Usage
export const useInfiniteWorkReviews = (kindergartenId: string, sortType?: SortType, pageSize?: number) =>
  useInfiniteReviews("workReviews", kindergartenId, getWorkReviews, sortType, pageSize);
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between d5418b0 and 9ad6895.

📒 Files selected for processing (20)
  • src/entities/community/hooks/useToggleLike.tsx
  • src/entities/review/api.ts
  • src/entities/review/hooks/useDeleteInternshipReview.tsx
  • src/entities/review/hooks/useDeleteWorkReview.tsx
  • src/entities/review/hooks/useGetReview.tsx
  • src/entities/review/hooks/useReviewLike.tsx
  • src/features/form/ui/fields/ScoredCommentField.tsx
  • src/pages/user/inquiry/index.tsx
  • src/shared/constants/inquiry.ts
  • src/shared/hooks/useFlutterCommunication.ts
  • src/shared/hooks/useOptimisticUpdate.ts
  • src/shared/utils/webViewCommunication.ts
  • src/widgets/review-editor/ui/WorkReviewForm.tsx
  • src/widgets/review-list/ui/ReviewCardList.tsx
  • src/widgets/review-panel/index.tsx
  • src/widgets/review-panel/lib/getFieldConfigsByType.ts
  • src/widgets/review-panel/lib/useReviewPage.tsx
  • src/widgets/user-dashboard/faq-list/index.tsx
  • src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx
  • src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx
🧰 Additional context used
🧬 Code graph analysis (13)
src/widgets/user-dashboard/faq-list/index.tsx (2)
src/shared/constants/inquiry.ts (1)
  • FAQ_LIST (30-55)
src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx (1)
  • FaqItem (15-61)
src/widgets/review-panel/lib/getFieldConfigsByType.ts (1)
src/widgets/review-panel/lib/config.d.ts (1)
  • ReviewFieldConfig (1-4)
src/pages/user/inquiry/index.tsx (5)
src/entities/auth/model.ts (1)
  • userAtom (5-5)
src/widgets/user-dashboard/ui/MenuItem.tsx (1)
  • MenuItem (13-55)
src/shared/constants/assets-path.ts (1)
  • SVG_PATHS (101-261)
src/widgets/user-dashboard/faq-list/index.tsx (1)
  • FaqList (7-26)
src/shared/constants/url-path.ts (1)
  • URL_PATHS (1-59)
src/shared/hooks/useFlutterCommunication.ts (1)
src/pages/index.tsx (1)
  • getToken (25-37)
src/entities/review/hooks/useDeleteInternshipReview.tsx (3)
src/shared/hooks/useFlutterCommunication.ts (1)
  • useRewardAd (280-328)
src/entities/review/DTO.d.ts (1)
  • LikeResponse (82-85)
src/entities/review/api.ts (1)
  • deleteInternshipReview (194-202)
src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx (4)
src/shared/constants/assets-path.ts (1)
  • SVG_PATHS (101-261)
src/widgets/user-dashboard/inquiry-list/ui/InquiryExpandButton.tsx (2)
  • InquiryExpandButton (11-34)
  • InquiryExpandButtonProps (5-9)
src/widgets/review-list/ui/ReviewSummary.tsx (2)
  • ReviewSummary (16-80)
  • ReviewSummaryProps (5-14)
src/widgets/home-dashboard/ui/RecentReviewPreview.tsx (2)
  • review (30-60)
  • RecentReviewPreview (14-66)
src/entities/review/api.ts (3)
src/entities/review/DTO.d.ts (4)
  • ReviewQueryParams (73-77)
  • PaginatedReviewResponse (68-71)
  • WorkReview (23-36)
  • InternshipReview (38-46)
src/shared/config/api.ts (1)
  • API_PATHS (2-98)
src/entities/review/hooks/useGetAllReviews.tsx (2)
  • params (11-34)
  • getAllWorkReviews (19-24)
src/widgets/review-editor/ui/WorkReviewForm.tsx (2)
src/widgets/review-editor/ui/LearningReviewForm.tsx (2)
  • LearningReviewFormValues (9-17)
  • LearningReviewForm (24-80)
src/entities/review/DTO.d.ts (1)
  • WorkReview (23-36)
src/entities/review/hooks/useReviewLike.tsx (1)
src/widgets/review-list/ui/ReviewActions.tsx (2)
  • ReviewActions (13-34)
  • ReviewActionsProps (5-11)
src/widgets/review-panel/lib/useReviewPage.tsx (4)
src/entities/review/DTO.d.ts (2)
  • InternshipReview (38-46)
  • WorkReview (23-36)
src/widgets/review-panel/lib/getFieldConfigsByType.ts (1)
  • getFieldConfigsByType (47-59)
src/entities/auth/model.ts (1)
  • userAtom (5-5)
src/shared/constants/review.ts (1)
  • REVIEW_TYPES (4-7)
src/entities/review/hooks/useDeleteWorkReview.tsx (3)
src/shared/hooks/useFlutterCommunication.ts (1)
  • useRewardAd (280-328)
src/entities/review/DTO.d.ts (1)
  • LikeResponse (82-85)
src/entities/review/api.ts (1)
  • deleteWorkReview (184-192)
src/widgets/review-panel/index.tsx (5)
src/shared/constants/review.ts (1)
  • REVIEW_TYPES (4-7)
src/widgets/review-panel/lib/useReviewPage.tsx (1)
  • useReviewPage (21-143)
src/shared/ui/loading/loading-spinner/index.tsx (1)
  • LoadingSpinner (8-43)
src/widgets/all-review-list/index.tsx (2)
  • AllReviewList (29-113)
  • fetchNextPage (95-95)
src/pages/review/index.tsx (1)
  • AllReviewPage (10-31)
src/widgets/review-list/ui/ReviewCardList.tsx (5)
src/entities/review/hooks/useReviewLike.tsx (1)
  • useReviewLike (6-43)
src/shared/hooks/useOptimisticUpdate.ts (1)
  • useOptimisticUpdate (19-48)
src/widgets/review-list/ui/ReviewContent.tsx (1)
  • ReviewContent (15-119)
src/widgets/review-list/ui/ReviewActions.tsx (2)
  • ReviewActions (13-34)
  • ReviewActionsProps (5-11)
src/widgets/review-list/ui/ReviewSummary.tsx (2)
  • ReviewSummary (16-80)
  • ReviewSummaryProps (5-14)
🔇 Additional comments (23)
src/shared/hooks/useOptimisticUpdate.ts (1)

40-45: 서버 동기화 후 롤백 동작을 확인해주세요.

serverValue가 변경되면 previousValueRef도 함께 업데이트됩니다(Line 42). 이는 서버 동기화 이후 rollback을 호출하면 낙관적 업데이트 이전 값이 아닌 서버 값으로 복원된다는 의미입니다.

일반적인 낙관적 업데이트 패턴(업데이트 → 실패 시 롤백 → 성공 시 서버 동기화)에서는 문제가 없지만, 서버 동기화 후 롤백이 호출될 수 있는 엣지 케이스가 있다면 예상과 다른 동작이 발생할 수 있습니다. 이 동작이 의도된 것인지 확인해주세요.

src/features/form/ui/fields/ScoredCommentField.tsx (1)

10-10: LGTM!

툴팁 기능 추가가 깔끔하게 구현되었습니다. 선택적 prop 패턴을 따르고 있으며, 조건부 렌더링이 올바르게 처리되었습니다.

Also applies to: 21-21, 31-31, 44-48

src/entities/review/api.ts (1)

18-54: LGTM!

페이지네이션 지원을 위한 리팩토링이 올바르게 구현되었습니다. ReviewQueryParams 객체를 통한 파라미터 전달, 적절한 기본값 설정, 그리고 URLSearchParams를 사용한 쿼리 문자열 구성이 getAllWorkReviewsgetAllInternshipReviews의 패턴과 일관성 있게 적용되었습니다.

src/pages/user/inquiry/index.tsx (1)

2-2: LGTM!

FAQ 섹션이 깔끔하게 추가되었습니다. 기본적으로 확장된 상태로 표시되며, 토글 기능이 올바르게 구현되었습니다.

Also applies to: 8-8, 13-13, 35-43

src/widgets/user-dashboard/my-post-list/ui/MyPostItem.tsx (1)

111-114: 광고 통합이 올바르게 구현되어 있습니다.

useDeleteWorkReviewuseDeleteInternshipReview 훅에서 보상 광고 플로우가 properly 구현되어 있음을 확인했습니다. 두 훅 모두 skipAd 옵션이 없는 경우 showRewardAd()를 호출하고, 사용자가 광고를 중단하면 삭제를 막으며, 광고 시청 완료 후 리뷰 삭제를 진행합니다. UI 텍스트가 실제 구현과 일치합니다.

src/widgets/review-panel/lib/getFieldConfigsByType.ts (1)

13-13: 라벨 변경이 사용자 이해도를 개선합니다.

"고객"에서 "학급 운영"으로 라벨을 변경하여 유치원 리뷰 맥락에서 필드의 의미가 더 명확해졌습니다. WorkReviewForm.tsx의 툴팁 설명과도 잘 연계되어 있습니다.

Also applies to: 21-21

src/widgets/review-editor/ui/WorkReviewForm.tsx (1)

16-16: 라벨 및 툴팁 추가로 사용자 경험이 개선되었습니다.

"학급 운영" 필드에 대한 상세한 설명을 툴팁으로 제공하여 사용자가 어떤 내용을 작성해야 하는지 명확하게 안내하고 있습니다. 라벨 변경도 getFieldConfigsByType.ts와 일관성 있게 적용되었습니다.

Also applies to: 102-103

src/widgets/user-dashboard/faq-list/index.tsx (1)

7-26: LGTM! 아코디언 동작이 올바르게 구현되었습니다.

한 번에 하나의 FAQ만 펼쳐지도록 하는 로직이 정확하고, 상태 관리도 적절합니다. FaqItem 컴포넌트에 전달되는 props도 타입과 잘 맞습니다.

src/entities/review/hooks/useReviewLike.tsx (1)

11-11: Promise는 이미 올바르게 처리되고 있습니다.

handleLike 함수가 Promise를 반환하지만, ReviewCardList.tsx에서 handleOptimisticLike 래퍼 함수로 감싸져 있으며, 이 함수 내부에서 await handleLike()로 Promise를 처리하고 try-catch로 에러를 처리합니다. 따라서 처리되지 않은 Promise rejection이 발생할 가능성은 없습니다.

다만 타입 안전성 관점에서, handleOptimisticLike는 async 함수이지만 ReviewActionsonLike: () => void 시그니처와 맞지 않습니다. 현재 구현은 함수 내부에서 에러 처리를 완료하므로 실제 문제는 없지만, 타입 정확성을 위해 인터페이스를 명시적으로 onLike: () => Promise<void>로 변경하는 것을 검토해보세요.

src/widgets/user-dashboard/faq-list/ui/FaqItem.tsx (1)

1-61: 전반적인 구현이 적절합니다.

FAQ 아이템 컴포넌트의 구조가 깔끔하고, grid-rows 기반의 확장/축소 애니메이션이 잘 구현되어 있습니다. 기존 코드베이스의 스타일링 패턴을 따르고 있습니다.

src/shared/utils/webViewCommunication.ts (1)

246-254: 에러 시 status: "success" 반환은 의도된 설계로 보입니다.

구버전 앱 대응 및 사용자 경험을 위해 에러 발생 시에도 삭제를 허용하는 graceful degradation 패턴이 적용되어 있습니다. 주석으로 의도가 명확히 설명되어 있어 좋습니다.

src/widgets/review-panel/index.tsx (1)

67-74: 무한 스크롤 구현이 적절합니다.

AllReviewList 컴포넌트와 동일한 패턴으로 무한 스크롤이 구현되어 있어 코드베이스 일관성이 유지됩니다. AutoFetchSentinelLoadingSpinner의 조합이 올바르게 사용되었습니다.

src/entities/review/hooks/useDeleteInternshipReview.tsx (1)

42-60: 삭제 성공 후 처리 로직이 적절합니다.

쿼리 무효화, 사용자 정보 갱신, 토스트 알림이 올바르게 구현되어 있습니다.

src/widgets/review-panel/lib/useReviewPage.tsx (1)

52-106: 평균 점수 계산 로직이 올바르게 구현되어 있습니다.

리뷰 타입에 따른 점수 계산과 reviews.length가 0일 때의 fallback 처리(|| 0)가 적절합니다.

src/shared/hooks/useFlutterCommunication.ts (1)

280-328: useRewardAd 훅이 기존 패턴을 잘 따르고 있습니다.

useKakaoShare, useRequestFcmToken 등 기존 훅들과 동일한 구조를 유지하며, 브라우저 환경과 에러 상황에 대한 graceful degradation이 적절히 구현되어 있습니다. Docblock 예시도 명확합니다.

src/entities/review/hooks/useDeleteWorkReview.tsx (2)

24-41: 광고 게이팅 로직이 올바르게 구현되어 있습니다.

사용자가 광고를 취소한 경우에만 삭제를 차단하고, 광고 로드 실패 등 사용자 책임이 아닌 상황에서는 삭제를 허용하는 방식이 적절합니다.


42-60: 이 코드는 정상적으로 작동합니다.

getUserInfo()는 반환값 외에도 setUserInfo(user)를 통해 전역 상태를 업데이트하는 사이드 이펙트를 가집니다. await를 사용한 것은 유저 정보 갱신이 완료된 후 토스트 메시지를 표시하도록 순서를 보장하기 위한 것이므로, 반환값을 사용하지 않는 것은 문제가 아닙니다. 현재 구현이 의도대로 작동하고 있습니다.

Likely an incorrect or invalid review comment.

src/widgets/review-list/ui/ReviewCardList.tsx (3)

42-52: LGTM!

workReviewId 또는 internshipReviewId를 추출하여 안정적인 React key를 사용하고, isLastItem을 인덱스 기반으로 계산하는 접근 방식이 적절합니다.


98-111: LGTM!

isWriteButtonDisabled 로직과 reviewContentProps 객체를 통한 props 통합이 가독성과 유지보수성을 향상시킵니다.


125-131: LGTM!

사용자 역할에 따라 리뷰 타입을 결정하는 라우팅 로직이 명확합니다. PROSPECTIVE_TEACHER의 경우 learning 타입으로 전환되어 #47 이슈의 요구사항을 충족합니다.

src/entities/review/hooks/useGetReview.tsx (3)

1-6: LGTM!

useSuspenseInfiniteQuery import와 DYNAMIC_CACHE_CONFIG 설정이 무한 스크롤 기능 구현에 적절하게 추가되었습니다.


46-46: LGTM!

API 호출 파라미터가 { sortType } 객체 형태로 변경되어 ReviewQueryParams 인터페이스와 일관성을 유지합니다.


180-208: 무한 스크롤 구현이 잘 되어있습니다.

쿼리 키에 "infinite"pageSize를 포함하여 일반 쿼리와의 캐시 충돌을 방지한 점이 좋습니다. getNextPageParam 로직도 0-기반 페이지네이션에 맞게 올바르게 구현되었습니다.

DYNAMIC_CACHE_CONFIG의 설정값(staleTime 5분, gcTime 15분)은 사용자 생성 콘텐츠인 리뷰의 무한 스크롤 UX에 적합합니다. 5분의 stale time은 데이터 신선도와 성능 사이의 균형을 잘 맞추고 있으며, 15분의 garbage collection 설정은 페이지네이션된 데이터를 메모리에 유지하기에 적절합니다.

Comment on lines 44 to 49
<MenuItem
iconPath={SVG_PATHS.USER_MENU.LOGOUT}
to={URL_PATHS.INQUIRY_EDITOR}
iconAlt="유저 아이콘"
iconAlt="문의 아이콘"
label="1:1 문의하기"
/>
Copy link

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:

# First, let's find and read the file mentioned in the review
cat -n src/pages/user/inquiry/index.tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 2359


🏁 Script executed:

# Search for SVG_PATHS definitions to understand available icons
rg "SVG_PATHS" --type ts --type tsx -A 3 | head -100

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 104


🏁 Script executed:

# Look for INQUIRY or similar icon definitions
rg "INQUIRY.*ICON|INQUIRY.*SVG" --type ts --type tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 104


🏁 Script executed:

# Check the USER_MENU icon paths to understand the pattern
rg "USER_MENU\s*[:=]|LOGOUT" --type ts --type tsx -B 2 -A 10 | head -150

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 104


🏁 Script executed:

# Find SVG_PATHS definitions
find . -name "*asset*path*" -o -name "*svg*path*" -o -name "constants.ts" -o -name "constants.tsx" | grep -i assets

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 114


🏁 Script executed:

# Search for SVG_PATHS definitions using rg with js/ts files
rg "SVG_PATHS\s*=" -t js -t ts --max-count 5 -A 30

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1779


🏁 Script executed:

# Look for where SVG_PATHS.QUESTION is defined
rg "QUESTION" --type js --type ts | grep -i "svg\|path" | head -20

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 364


🏁 Script executed:

# Read the full SVG_PATHS definition to see all available icons
cat -n src/shared/constants/assets-path.ts

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 11510


잘못된 아이콘 상수를 수정하세요.

이 MenuItem은 iconPath={SVG_PATHS.USER_MENU.LOGOUT}으로 설정되어 있지만, 기능은 "1:1 문의하기"입니다. iconAlt가 이미 "문의 아이콘"으로 변경되었으나, 실제 아이콘 경로는 로그아웃 아이콘을 가리키고 있습니다.

SVG_PATHS.USER_MENU.INQUIRY가 존재하므로, iconPath를 다음과 같이 변경하세요:

iconPath={SVG_PATHS.USER_MENU.INQUIRY}
🤖 Prompt for AI Agents
In @src/pages/user/inquiry/index.tsx around lines 44 - 49, The MenuItem for "1:1
문의하기" is using the wrong icon constant; change the iconPath prop on the MenuItem
(currently set to SVG_PATHS.USER_MENU.LOGOUT) to SVG_PATHS.USER_MENU.INQUIRY so
the displayed icon matches the label and iconAlt; update the iconPath value in
the <MenuItem ... /> invocation accordingly.

Comment on lines +214 to +245
export async function requestRewardAd(): Promise<RewardAdResult> {
try {
// 타임아웃 설정 (30초) - 광고 로드 및 시청 시간 고려
const timeoutPromise = new Promise<RewardAdResult>((resolve) => {
setTimeout(() => {
console.warn("보상형 광고 응답 타임아웃");
resolve({
status: "error",
rewarded: false,
message: "광고를 불러오는데 시간이 너무 오래 걸립니다.",
});
}, 30000);
});

const adPromise = sendToFlutter<
Record<string, never>,
RewardAdResult
>(MessageType.REQUEST_REWARD_AD, {});

const result = await Promise.race([adPromise, timeoutPromise]);

// 구버전 앱의 default 응답 처리 (rewarded 필드가 없는 경우)
if (result.status === "success" && result.rewarded === undefined) {
console.warn("구버전 앱 감지 - 광고 기능이 없는 버전입니다.");
return {
status: "success",
rewarded: false,
message: "광고 기능을 지원하지 않는 버전입니다.",
};
}

return result;
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

타임아웃 타이머 정리 누락

Promise.race 패턴에서 adPromise가 먼저 완료되더라도 setTimeout이 계속 실행됩니다. 메모리 누수를 방지하기 위해 타이머를 정리해야 합니다.

🔧 타이머 정리 제안
export async function requestRewardAd(): Promise<RewardAdResult> {
  try {
+   let timeoutId: ReturnType<typeof setTimeout>;
+   
    // 타임아웃 설정 (30초) - 광고 로드 및 시청 시간 고려
    const timeoutPromise = new Promise<RewardAdResult>((resolve) => {
-     setTimeout(() => {
+     timeoutId = setTimeout(() => {
        console.warn("보상형 광고 응답 타임아웃");
        resolve({
          status: "error",
          rewarded: false,
          message: "광고를 불러오는데 시간이 너무 오래 걸립니다.",
        });
      }, 30000);
    });

    const adPromise = sendToFlutter<
      Record<string, never>,
      RewardAdResult
    >(MessageType.REQUEST_REWARD_AD, {});

    const result = await Promise.race([adPromise, timeoutPromise]);
+   clearTimeout(timeoutId!);
    
    // 구버전 앱의 default 응답 처리 (rewarded 필드가 없는 경우)
🤖 Prompt for AI Agents
In @src/shared/utils/webViewCommunication.ts around lines 214 - 245, In
requestRewardAd the setTimeout created for timeoutPromise isn't cleared when
adPromise wins, leaking timers; capture the timer id returned by setTimeout,
then after awaiting Promise.race (using the existing
adPromise/timeoutPromise/result flow) call clearTimeout(timerId) before any
return path so the timer is always cleared (including the legacy-response branch
and normal return). Ensure the timer variable is defined in the outer scope of
the race so it can be cleared regardless of which promise resolved.

Comment on lines +93 to 96
const [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate(
false,
isLiked
);
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

초기값 설정으로 인한 UI 깜빡임 가능성.

localIsLikedfalse로 초기화된 후 useEffect에서 isLiked 값으로 동기화됩니다. 이로 인해 실제 좋아요 상태와 관계없이 첫 렌더링에서 "좋아요 안 함" 상태로 잠시 표시될 수 있습니다.

🔧 초기값을 isLiked로 설정하는 수정 제안
- const [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate(
-   false,
-   isLiked
- );
+ const [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate(
+   isLiked ?? false,
+   isLiked
+ );
📝 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 [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate(
false,
isLiked
);
const [localIsLiked, setLocalIsLiked, rollbackIsLiked] = useOptimisticUpdate(
isLiked ?? false,
isLiked
);
🤖 Prompt for AI Agents
In @src/widgets/review-list/ui/ReviewCardList.tsx around lines 93 - 96, The
optimistic local like state is initialized to false causing a UI flash; change
the initializer passed into useOptimisticUpdate from the literal false to the
actual prop value isLiked (or a safe boolean fallback like !!isLiked) so
localIsLiked starts in sync with the real state; update the call to
useOptimisticUpdate(...) to use isLiked as the initial value and keep the rest
(setLocalIsLiked, rollbackIsLiked) unchanged.

Comment on lines +113 to 123
const handleOptimisticLike = async () => {
setLocalIsLiked((prev: boolean) => !prev);
setLikeCount((prev: number) => prev + (localIsLiked ? -1 : 1));

handleLike();
try {
await handleLike();
} catch {
rollbackIsLiked();
rollbackLikeCount();
}
};
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

좋아요 중복 클릭 방지 로직 누락.

handleOptimisticLikeisPending 상태를 확인하지 않아 사용자가 빠르게 연속 클릭할 경우 중복 mutation이 발생할 수 있습니다.

🔧 isPending 체크 추가 제안
  const handleOptimisticLike = async () => {
+   if (isPending) return;
+
    setLocalIsLiked((prev: boolean) => !prev);
    setLikeCount((prev: number) => prev + (localIsLiked ? -1 : 1));

    try {
      await handleLike();
    } catch {
      rollbackIsLiked();
      rollbackLikeCount();
    }
  };
📝 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 handleOptimisticLike = async () => {
setLocalIsLiked((prev: boolean) => !prev);
setLikeCount((prev: number) => prev + (localIsLiked ? -1 : 1));
handleLike();
try {
await handleLike();
} catch {
rollbackIsLiked();
rollbackLikeCount();
}
};
const handleOptimisticLike = async () => {
if (isPending) return;
setLocalIsLiked((prev: boolean) => !prev);
setLikeCount((prev: number) => prev + (localIsLiked ? -1 : 1));
try {
await handleLike();
} catch {
rollbackIsLiked();
rollbackLikeCount();
}
};
🤖 Prompt for AI Agents
In @src/widgets/review-list/ui/ReviewCardList.tsx around lines 113 - 123,
handleOptimisticLike lacks an isPending guard so rapid clicks can trigger
duplicate mutations; add an early return if isPending is true, then set a
pending flag (isPending = true) before calling handleLike and clear it
(isPending = false) in both the try and catch paths; keep the existing
optimistic updates (setLocalIsLiked, setLikeCount) and existing rollback calls
(rollbackIsLiked, rollbackLikeCount) but ensure isPending prevents re-entry
while the mutation is in-flight.

Comment on lines +30 to +38
const workReviews = useInfiniteWorkReviews(kindergartenId, sortType, 10);
const internshipReviews = useInfiniteInternshipReviews(
kindergartenId,
sortType,
10
);

const infiniteQuery =
type === REVIEW_TYPES.WORK ? workReviews : internshipReviews;
Copy link

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:

find src -type f -name "*.ts" -o -name "*.tsx" | grep -i hook | head -20

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 978


🏁 Script executed:

fd -e ts -e tsx "useInfiniteWorkReviews|useInfiniteInternshipReviews" src/

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 59


🏁 Script executed:

cat -n src/widgets/review-panel/lib/useReviewPage.tsx | head -50

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1995


🏁 Script executed:

rg -n "export.*useInfinite(Work|Internship)Reviews" --type=ts -B 2 -A 10

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1985


🏁 Script executed:

cat -n src/entities/review/hooks/useGetReview.tsx | sed -n '178,210p'

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1262


🏁 Script executed:

cat -n src/entities/review/hooks/useGetReview.tsx | sed -n '211,240p'

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1101


🏁 Script executed:

rg -n "useSuspenseInfiniteQuery" src/entities/review/hooks/useGetReview.tsx -B 2 -A 15

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1924


🏁 Script executed:

rg -n "DYNAMIC_CACHE_CONFIG" src/shared/config/query -B 2 -A 10

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 140


🏁 Script executed:

cat src/shared/config/query.ts

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 910


🏁 Script executed:

rg -n "enabled" src/entities/review/hooks/useGetReview.tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 59


🏁 Script executed:

web_search
useSuspenseInfiniteQuery enabled option tanstack react-query

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 190


🌐 Web query:

useSuspenseInfiniteQuery enabled option tanstack react-query

💡 Result:

Short answer: useSuspenseInfiniteQuery does NOT support the enabled option. The hook's options are the same as useInfiniteQuery except that suspense, throwOnError, enabled and placeholderData are removed. See the docs. [1][2]

Sources:

  • TanStack Query docs — useSuspenseInfiniteQuery. [1]
  • Chinese mirror of TanStack docs showing the same options exclusion. [2]

🏁 Script executed:

rg -n "enabled\s*:" src/entities/review/hooks/ -A 2 -B 2

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 59


🏁 Script executed:

rg -n "useSuspenseQuery|useInfiniteQuery" src/entities/review/hooks/useGetReview.tsx -B 5 -A 20 | head -100

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 2931


조건부 호출로 개선 필요

useInfiniteWorkReviewsuseInfiniteInternshipReviews가 항상 함께 호출되어 선택되지 않은 리뷰 타입에 대해서도 API 요청이 발생합니다. 두 훅 모두 useSuspenseInfiniteQuery를 사용하므로 마운트 시 즉시 데이터를 가져옵니다.

주의: useSuspenseInfiniteQueryenabled 옵션을 지원하지 않습니다(공식 문서에서 제거된 옵션). 대신 다음 방안을 검토해 주세요:

  • useInfiniteQuery + enabled 옵션으로 변경 (Suspense 제거 필요)
  • 리뷰 타입별로 컴포넌트 분리
  • 조건부 훅 호출 패턴 적용 (별도 래퍼 컴포넌트)
🤖 Prompt for AI Agents
In @src/widgets/review-panel/lib/useReviewPage.tsx around lines 30 - 38, Both
hooks useSuspenseInfiniteQuery under the hood so calling useInfiniteWorkReviews
and useInfiniteInternshipReviews unconditionally triggers both API requests;
only the selected review type should mount its hook. Fix by ensuring only the
hook for the active type is invoked: either (A) split the UI into two components
(e.g., WorkReviewsPanel and InternshipReviewsPanel) and move
useInfiniteWorkReviews and useInfiniteInternshipReviews into those components so
only the mounted component runs its hook, or (B) switch the hooks to
useInfiniteQuery with an enabled flag and use the existing conditional selection
(keep function names useInfiniteWorkReviews/useInfiniteInternshipReviews but
implement them to use useInfiniteQuery + enabled based on the passed type), or
(C) wrap each hook call behind a conditional render wrapper that only calls
useInfiniteWorkReviews when type === REVIEW_TYPES.WORK and
useInfiniteInternshipReviews when type === REVIEW_TYPES.INTERNSHIP so the
non-selected hook is never invoked.

- 메인 화면 인기 게시글에 주간/월간/전체 탭 추가
- 커뮤니티 NavBar에 "인기글" 탭 추가 (제일 앞)
- 커뮤니티 카테고리에서 TOP10 제거
- 인기글과 일반 게시글 UI/UX 명확하게 분리
- 새로운 API 엔드포인트 /community/popular 연동

BREAKING CHANGE:
- 커뮤니티 URL 구조 변경: ?type=popular 추가
- 기존 top10 카테고리 제거

@0juicy 확인 부탁드립니다
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: 2

Caution

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

⚠️ Outside diff range comments (1)
src/widgets/community-feed/lib/category.ts (1)

92-92: top10 라벨을 제거해야 합니다.

top10CATEGORY_LABELS에 정의되어 있지만 코드베이스의 어디에서도 참조되지 않습니다. CATEGORY_INFO의 categories 배열에도 포함되지 않으며, 새로운 "인기글" 기능(type=popular)이 이를 대체했습니다. 불필요한 dead code이므로 92번 줄에서 제거하세요.

🤖 Fix all issues with AI agents
In @src/pages/community/index.tsx:
- Around line 25-27: The code casts the URL value to PeriodType using
(searchParams.get("period") || "weekly") as PeriodType which doesn't validate
unknown values; update the logic around period (where searchParams.get("period")
is read) to check the returned string against the allowed PeriodType values
(e.g., a whitelist/enum of valid periods) and if it is not one of them, fall
back to the default "weekly" before assigning to the period variable so period
always holds a valid PeriodType.

In @src/widgets/community-feed/ui/CategorySelector.tsx:
- Around line 37-46: The refetch call builds a queryKey as an object
(queryParams) which doesn't match the array shape used by useCommunityPosts;
update the refetch to build the same array key shape used elsewhere (e.g.
["communityPosts", categoryConst, pageSizeString, categoryNameOrAll, "", "",
""]). Concretely, replace the current queryParams object usage in
queryClient.refetchQueries with a computed array using the same elements as the
real key (use the category value mapped to "TEACHER"|"PROSPECTIVE_TEACHER",
String(pageSize) for "10", and categoryName ?? "all" for the fourth slot, plus
the remaining empty string placeholders) so the refetch key matches
useCommunityPosts.
🧹 Nitpick comments (5)
src/widgets/community-feed/ui/PeriodSelector.tsx (1)

17-28: URL 파라미터 타입 안전성 개선 권장

Line 19에서 as PeriodType 타입 단언을 사용하고 있는데, URL이 임의로 조작될 경우 유효하지 않은 값이 들어올 수 있습니다. 런타임 유효성 검사를 추가하는 것이 안전합니다.

♻️ 타입 안전성을 위한 유효성 검사 추가
+const isValidPeriod = (value: string | null): value is PeriodType => {
+  return value === "weekly" || value === "monthly" || value === "all";
+};
+
 export default function PeriodSelector({ type }: PeriodSelectorProps) {
   const [searchParams, setSearchParams] = useSearchParams();
-  const currentPeriod = (searchParams.get("period") || "weekly") as PeriodType;
+  const periodParam = searchParams.get("period");
+  const currentPeriod: PeriodType = isValidPeriod(periodParam) ? periodParam : "weekly";
src/entities/community/hooks/usePopularPostsByPeriod.tsx (1)

1-1: 파일 확장자 변경은 선택사항

이 파일은 JSX를 포함하지 않는 것이 맞습니다. 다만 코드베이스의 관례상 hook 파일들은 useSocialAuth.ts를 제외하고 모두 .tsx 확장자를 사용하고 있습니다. .ts로 변경하려면 프로젝트의 naming convention을 일관되게 조정해야 합니다.

src/widgets/community-feed/post-list/ui/PopularPostList.tsx (1)

8-14: PeriodTypePERIOD_LABELS가 여러 파일에 중복 정의되어 있습니다.

PeriodType은 이미 src/widgets/community-feed/ui/PeriodSelector.tsx에서 export되고 있으며, PERIOD_LABELSPopularPostsPreview.tsx에 동일하게 정의되어 있습니다. 공통 상수 파일로 추출하여 재사용하는 것이 유지보수에 좋습니다.

♻️ 제안: 공통 모듈에서 import하기
-type PeriodType = "weekly" | "monthly" | "all";
-
-const PERIOD_LABELS = {
-  weekly: "주간",
-  monthly: "월간",
-  all: "전체",
-} as const;
+import { PeriodType, PERIOD_LABELS } from "@/widgets/community-feed/ui/PeriodSelector";

또는 src/widgets/community-feed/lib/category.ts와 같은 공통 파일에 상수를 정의하고 import하는 방식을 권장합니다.

src/widgets/home-dashboard/ui/PopularPostsPreview.tsx (2)

11-23: PeriodType, PERIOD_OPTIONS, PERIOD_LABELS가 중복 정의되어 있습니다.

이 상수들이 PopularPostList.tsxPeriodSelector.tsx에도 정의되어 있습니다. DRY 원칙에 따라 공통 모듈로 추출하여 일관성을 유지하고 향후 수정 시 한 곳만 변경하도록 하는 것이 좋습니다.


57-67: [0, 1, 2].map 대신 .slice(0, 3)을 사용하면 더 명확합니다.

현재 구현도 동작하지만, slice를 사용하면 의도가 더 분명해지고 조건부 null 체크가 불필요해집니다.

♻️ 제안된 개선
-        [0, 1, 2].map((i) =>
-          currentPosts[i] ? (
-            <PostCard
-              key={currentPosts[i].id}
-              post={currentPosts[i]}
-              index={i}
-              currentCategory="top10"
-              getCategoryLabel={getCategoryLabel}
-            />
-          ) : null
-        )
+        currentPosts.slice(0, 3).map((post, index) => (
+          <PostCard
+            key={post.id}
+            post={post}
+            index={index}
+            currentCategory="top10"
+            getCategoryLabel={getCategoryLabel}
+          />
+        ))
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 9ad6895 and d085fe5.

📒 Files selected for processing (13)
  • .storybook/preview.tsx
  • src/entities/community/DTO.d.ts
  • src/entities/community/api.ts
  • src/entities/community/hooks/index.ts
  • src/entities/community/hooks/usePopularPostsByPeriod.tsx
  • src/pages/community/index.tsx
  • src/pages/home/index.tsx
  • src/shared/config/api.ts
  • src/widgets/community-feed/lib/category.ts
  • src/widgets/community-feed/post-list/ui/PopularPostList.tsx
  • src/widgets/community-feed/ui/CategorySelector.tsx
  • src/widgets/community-feed/ui/PeriodSelector.tsx
  • src/widgets/home-dashboard/ui/PopularPostsPreview.tsx
🧰 Additional context used
🧬 Code graph analysis (7)
src/entities/community/hooks/usePopularPostsByPeriod.tsx (2)
src/entities/community/DTO.d.ts (1)
  • PopularPostsByPeriodResponse (102-110)
src/entities/community/api.ts (1)
  • getPopularPostsByPeriod (29-34)
src/widgets/community-feed/ui/CategorySelector.tsx (5)
src/pages/community/post-editor.tsx (3)
  • currentCategoryName (64-83)
  • categoryName (86-98)
  • category (39-42)
src/pages/search/community.tsx (1)
  • allPosts (56-62)
src/shared/utils/lastVisitedPathUtils.ts (1)
  • setCommunityCategory (142-147)
src/widgets/community-feed/post-list/index.tsx (1)
  • options (54-75)
src/widgets/community-feed/post-list/ui/PostCard.tsx (1)
  • PostCard (19-97)
src/widgets/home-dashboard/ui/PopularPostsPreview.tsx (4)
src/widgets/community-feed/ui/PeriodSelector.tsx (1)
  • PeriodType (5-5)
src/entities/community/hooks/usePopularPostsByPeriod.tsx (1)
  • usePopularPostsByPeriod (8-14)
src/widgets/community-feed/post-list/ui/PostCard.tsx (1)
  • PostCard (19-97)
src/shared/utils/categoryUtils.ts (1)
  • getCategoryLabel (12-14)
src/entities/community/api.ts (3)
src/entities/community/DTO.d.ts (1)
  • PopularPostsByPeriodResponse (102-110)
src/shared/api/utils.ts (1)
  • apiCall (47-195)
src/shared/config/api.ts (1)
  • API_PATHS (2-99)
src/widgets/community-feed/post-list/ui/PopularPostList.tsx (2)
src/widgets/community-feed/ui/PeriodSelector.tsx (1)
  • PeriodType (5-5)
src/entities/community/hooks/usePopularPostsByPeriod.tsx (1)
  • usePopularPostsByPeriod (8-14)
src/pages/community/index.tsx (5)
src/widgets/community-feed/ui/PeriodSelector.tsx (2)
  • PeriodType (5-5)
  • PeriodSelector (17-46)
src/shared/utils/lastVisitedPathUtils.ts (1)
  • setCommunityState (119-123)
src/features/nav/ui/NavBar.tsx (1)
  • NavBar (123-143)
src/widgets/community-feed/post-list/ui/PopularPostList.tsx (1)
  • PopularPostList (20-79)
src/widgets/community-feed/post-list/index.tsx (3)
  • PostList (37-165)
  • PostListProps (169-174)
  • window (109-109)
src/widgets/community-feed/ui/PeriodSelector.tsx (1)
src/widgets/all-review-list/index.tsx (1)
  • newSortType (52-58)
🔇 Additional comments (12)
src/pages/home/index.tsx (1)

54-60: LGTM!

이미지 소스 IMAGE_PATHS.BANNER.COMMUNITY와 일치하도록 alt 텍스트를 "커뮤니티 배너"로 수정한 것은 적절합니다. 접근성 측면에서 더 정확한 이미지 설명을 제공합니다.

.storybook/preview.tsx (1)

5-5: QueryProvider 임포트 경로 변경이 올바르게 적용되었습니다.

src/app/providers/QueryProvider로의 경로 변경이 완료되었으며, 기존 경로는 제거되었습니다. 프로젝트 전반의 provider 구조 개편에 맞춘 적절한 변경입니다.

src/widgets/community-feed/lib/category.ts (1)

20-29: LGTM!

새로운 "인기글" 카테고리 아이콘 항목이 기존 항목들과 일관된 구조로 추가되었습니다. SVG_PATHS.KINDERGARTEN_INFO.CHART 경로도 유효하며, 아이콘 크기와 라벨 형식이 다른 항목들과 동일합니다.

src/widgets/community-feed/ui/PeriodSelector.tsx (1)

30-44: LGTM!

버튼 렌더링 로직이 명확하고, PERIOD_OPTIONS를 활용한 매핑 방식이 유지보수에 용이합니다. 현재 선택된 기간에 따른 variant 스타일 처리도 적절합니다.

src/shared/config/api.ts (1)

44-44: LGTM!

기존 API 경로 패턴과 일관되게 POPULAR 엔드포인트가 추가되었습니다.

src/entities/community/hooks/index.ts (1)

7-7: LGTM!

새로운 훅의 배럴 익스포트가 기존 패턴을 따르며 알파벳 순서대로 적절히 배치되었습니다.

src/widgets/community-feed/ui/CategorySelector.tsx (1)

19-19: LGTM!

기본 카테고리가 "top10"에서 "all"로 변경되었으며, 이는 인기 게시물 흐름이 분리된 PR 변경사항과 일치합니다.

src/entities/community/hooks/usePopularPostsByPeriod.tsx (1)

1-14: LGTM!

API가 모든 기간 데이터(weekly, monthly, all)를 한 번에 반환하므로 정적 쿼리키 사용이 적절합니다. useSuspenseQuery 사용으로 부모 컴포넌트에 Suspense 경계가 필요합니다.

src/entities/community/api.ts (1)

26-34: LGTM!

새로운 getPopularPostsByPeriod API 함수가 기존 패턴을 잘 따르고 있습니다. API_PATHS.COMMUNITY.POST.POPULAR 경로를 사용하며, 반환 타입이 올바르게 지정되어 있습니다.

src/widgets/community-feed/post-list/ui/PopularPostList.tsx (1)

20-22: LGTM!

period prop을 통한 데이터 접근과 optional chaining을 사용한 안전한 데이터 처리가 잘 구현되어 있습니다.

src/entities/community/DTO.d.ts (1)

101-110: LGTM!

새로운 PopularPostsByPeriodResponse 인터페이스가 기존 응답 타입 패턴과 일관성 있게 정의되어 있습니다. weekly, monthly, all 속성이 명확하게 타입화되어 있습니다.

src/pages/community/index.tsx (1)

77-100: LGTM!

인기글 모드와 일반 카테고리 모드의 분기 처리가 명확하게 구현되어 있습니다. Suspense를 활용한 로딩 처리와 각 모드에 맞는 컴포넌트 렌더링이 잘 구성되어 있습니다.

Comment on lines +25 to +27
const typeParam = searchParams.get("type") || "popular"; // 기본값: 인기글
const period = (searchParams.get("period") || "weekly") as PeriodType;
const categoryName = searchParams.get("category") || "all";
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

URL에서 가져온 period 값에 대한 유효성 검증이 없습니다.

as PeriodType 타입 단언은 URL에 잘못된 값(예: ?period=invalid)이 들어왔을 때 런타임 오류를 방지하지 못합니다. 유효하지 않은 값일 경우 기본값으로 폴백하는 것이 안전합니다.

🔧 제안된 수정
+const VALID_PERIODS = ["weekly", "monthly", "all"] as const;
+
 const typeParam = searchParams.get("type") || "popular";
-const period = (searchParams.get("period") || "weekly") as PeriodType;
+const periodParam = searchParams.get("period");
+const period: PeriodType = VALID_PERIODS.includes(periodParam as PeriodType)
+  ? (periodParam as PeriodType)
+  : "weekly";
 const categoryName = searchParams.get("category") || "all";
📝 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 typeParam = searchParams.get("type") || "popular"; // 기본값: 인기글
const period = (searchParams.get("period") || "weekly") as PeriodType;
const categoryName = searchParams.get("category") || "all";
const VALID_PERIODS = ["weekly", "monthly", "all"] as const;
const typeParam = searchParams.get("type") || "popular"; // 기본값: 인기글
const periodParam = searchParams.get("period");
const period: PeriodType = VALID_PERIODS.includes(periodParam as PeriodType)
? (periodParam as PeriodType)
: "weekly";
const categoryName = searchParams.get("category") || "all";
🤖 Prompt for AI Agents
In @src/pages/community/index.tsx around lines 25 - 27, The code casts the URL
value to PeriodType using (searchParams.get("period") || "weekly") as PeriodType
which doesn't validate unknown values; update the logic around period (where
searchParams.get("period") is read) to check the returned string against the
allowed PeriodType values (e.g., a whitelist/enum of valid periods) and if it is
not one of them, fall back to the default "weekly" before assigning to the
period variable so period always holds a valid PeriodType.

Comment on lines +37 to +46
const queryParams = {
pageSize: 10,
category: type === "teacher" ? "TEACHER" : "PROSPECTIVE_TEACHER",
categoryName: category !== "all" ? category : undefined,
};

queryClient.refetchQueries({
queryKey: ["communityPosts", queryParams],
exact: false,
});
} else if (category === "top10") {
queryClient.refetchQueries({
queryKey: ["popularPosts"],
});
}
queryClient.refetchQueries({
queryKey: ["communityPosts", queryParams],
exact: false,
});
Copy link

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
# Description: communityPosts 쿼리키 사용 패턴 확인

rg -n "queryKey.*communityPosts" --type=ts --type=tsx -C2

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 104


🏁 Script executed:

#!/bin/bash
# Search for communityPosts query key patterns in TypeScript files
rg -n "communityPosts" --type-list | grep -i type
rg -n "communityPosts" -t ts -C2

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 4670


🏁 Script executed:

#!/bin/bash
# Read useCommunityPosts to see the full queryKey structure
cat -n src/entities/community/hooks/useCommunityPosts.tsx | head -40

# Read CategorySelector to see queryParams structure
cat -n src/widgets/community-feed/ui/CategorySelector.tsx | head -50

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 3300


🏁 Script executed:

#!/bin/bash
# Check how refetchQueries is used elsewhere in the codebase
rg -n "refetchQueries" -C3

# Also search for how queryKey is being used with exact parameter
rg -n "exact:" -C2

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 1020


🏁 Script executed:

#!/bin/bash
# Check if there are other ways communityPosts queries are created
rg -n "useCommunityPosts\|useInfiniteQuery.*communityPosts" -A5 -B2

# Check the full CategorySelector file to understand the context
wc -l src/widgets/community-feed/ui/CategorySelector.tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 130


🏁 Script executed:

#!/bin/bash
# Read the entire CategorySelector file
cat -n src/widgets/community-feed/ui/CategorySelector.tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 2506


🏁 Script executed:

#!/bin/bash
# Check how post-list uses useCommunityPosts to confirm queryKey structure
grep -n "useCommunityPosts" src/widgets/community-feed/post-list/index.tsx -A10

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 872


🏁 Script executed:

#!/bin/bash
# Check what queryOptions are passed to useCommunityPosts
grep -n "queryOptions" src/widgets/community-feed/post-list/index.tsx -B15 | head -40

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 982


🏁 Script executed:

#!/bin/bash
# Get the full queryOptions definition
sed -n '54,75p' src/widgets/community-feed/post-list/index.tsx

Repository: OneByOne-Kindergarten/OneByOne_FE

Length of output: 565


쿼리키 구조 불일치로 리패치가 작동하지 않음

queryParams 객체를 배열 요소로 전달하면 실제 쿼리키 구조와 매칭되지 않습니다.

실제 쿼리키: ["communityPosts", "TEACHER", "10", "all", "", "", ""]
전달된 쿼리키: ["communityPosts", {pageSize: 10, category: "TEACHER", categoryName: undefined}]

리패치가 실행되지 않아 카테고리 변경 시 데이터가 갱신되지 않습니다. useCommunityPosts의 queryKey 구조에 맞게 수정이 필요합니다.

🤖 Prompt for AI Agents
In @src/widgets/community-feed/ui/CategorySelector.tsx around lines 37 - 46, The
refetch call builds a queryKey as an object (queryParams) which doesn't match
the array shape used by useCommunityPosts; update the refetch to build the same
array key shape used elsewhere (e.g. ["communityPosts", categoryConst,
pageSizeString, categoryNameOrAll, "", "", ""]). Concretely, replace the current
queryParams object usage in queryClient.refetchQueries with a computed array
using the same elements as the real key (use the category value mapped to
"TEACHER"|"PROSPECTIVE_TEACHER", String(pageSize) for "10", and categoryName ??
"all" for the fourth slot, plus the remaining empty string placeholders) so the
refetch key matches useCommunityPosts.

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.

3 participants