Skip to content

05.06 최신화 - #302

Merged
shinwokkang merged 64 commits into
mainfrom
dev
May 19, 2026
Merged

shinwokkang merged 64 commits into
mainfrom
dev

Conversation

@shinwokkang

@shinwokkang shinwokkang commented May 6, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

  • 변경 사항에 대한 간략한 요약을 적어주세요.
  • 관련 이슈가 있다면 링크를 걸어주세요 (예: [fix] cicd / build 문제 #123).

🛠️ 변경 사항 (Changes)

  • 새로운 기능 추가
  • 버그 수정
  • 코드 리팩토링
  • 문서 업데이트
  • 기타 (설명: )

📸 스크린샷 (Screenshots)

(UI 변경 사항이 있다면 첨부해주세요)

✅ 체크리스트 (Checklist)

  • 빌드가 성공적으로 수행되었나요? (pnpm build)
  • 린트 에러가 없나요? (pnpm lint)
  • 불필요한 콘솔 로그나 주석을 제거했나요?

Summary by CodeRabbit

  • New Features

    • Multi-step report/block modal flow; branded animated toasts; centralized default avatar with URL validation; landing pages and sections; block management UI (unblock).
    • Story create/edit supports Draft vs Publish with contextual actions.
  • Bug Fixes

    • Improved optimistic updates for likes/follows and follower deletion.
    • News carousel filters/sorts promotions and handles empty/error states.
  • Style

    • Scroll-aware header, animated nav underline, motion-enhanced modals and notifications.
  • Documentation

    • Extensive domain and handover docs added.
  • Chores

    • Version date updated; animation runtime added.

Review Change Stack

shinwokkang and others added 17 commits May 2, 2026 18:22
fix: 구독 관련 이미지 수정 및 타입 가드 적용
feat: 상태 관리 아키텍처 구현
feat: 구독자 삭제 및 구독 취소 시 항목이 즉시 사라지지 않고 버튼 상태만 변경되도록 수정
feat: 알림 조회 요청 관련 enable 옵션 추가 및 레드닷 로직 추가
feat: 비공개 모임에 대한 공지사항 및 책장 UI 구현
fix: 배너에 프로모션 리스트 수정
@shinwokkang shinwokkang self-assigned this May 6, 2026
@shinwokkang shinwokkang added ✨ feat 새로운 기능 추가 ♻️ refactor 리팩토링 🐛 bug 버그 수정 labels May 6, 2026
@vercel

vercel Bot commented May 6, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
check-mo Ready Ready Preview, Comment May 18, 2026 2:20pm
checkmo Ready Ready Preview, Comment May 18, 2026 2:20pm

@coderabbitai

coderabbitai Bot commented May 6, 2026 •

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Centralizes avatar fallbacks via DEFAULT_PROFILE_IMAGE, tightens URL validation to a type predicate, unifies avatar rendering, adds framer-motion header/notification UX, implements report/block modal flow with animated toasts, tweaks optimistic mutation cache behavior, derives promotion slides by publishEndAt, and adds landing/layout/docs.

Changes

Consolidated Update

Layer / File(s) Summary
All modified checkpoints
src/constants/*, src/utils/*, src/hooks/*, src/components/*, src/app/*, docs/*
Adds DEFAULT_PROFILE_IMAGE, converts isValidUrl to a type predicate, replaces hard-coded '/profile2.svg' fallbacks with DEFAULT_PROFILE_IMAGE and uses isValidUrl(...) guards, adds useScrollDirection, framer-motion UX (Header, NavItem, NotificationDropdown), report/block modal flow (ActionSelectionModal, BlockConfirmModal, useReportBlockFlow, REPORT_TYPE_MAP), showCustomToast, landing components and pages, optimistic mutation/cache invalidation tweaks, news promotion filtering by publishEndAt, layout wrapper adjustments, and extensive documentation additions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • checkmo2025/FE#289: Related — overlapping profile-image fallback and isValidUrl standardization.
  • checkmo2025/FE#315: Related — adds BlockPage and BlockedUserItem components similar to this PR.
  • checkmo2025/FE#294: Related — both change useNotificationPreviewQuery signature and header/notification usage.

Poem

🐰 I hopped through code with nimble paws,
Replaced the strings and tightened laws,
Motion makes the header glide and sing,
Reports and blocks now check and bring,
Toasts pop up, avatars stay bright — a shared default delight!

✨ 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 dev

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request centralizes the default profile image path into a constant, refactors profile image rendering across numerous components, and improves the user experience for follower deletion and book/story liking through optimistic updates and refined cache invalidation strategies. It also enhances the news banner with promotion filtering and sorting, adds an unread notification indicator to the header, and updates various empty state UIs. Feedback was provided regarding code formatting in NotificationDropdown.tsx and potential date parsing issues in NewsListBanner.tsx.

const { mutate: readNotification } = useReadNotificationMutation();
const { isLoggedIn } = useAuthStore();
const { data: notificationsData, isLoading } = useNotificationPreviewQuery(5, isLoggedIn);
const notifications = isLoggedIn ? notificationsData : []; const { mutate: readNotification } = useReadNotificationMutation();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

한 줄에 두 개의 const 선언이 포함되어 있어 가독성이 떨어집니다. 코드의 명확성을 위해 각 선언을 별도의 줄로 분리하는 것이 좋습니다.

Suggested change
const notifications = isLoggedIn ? notificationsData : []; const { mutate: readNotification } = useReadNotificationMutation();
const notifications = isLoggedIn ? notificationsData : [];
const { mutate: readNotification } = useReadNotificationMutation();

const promotionList = useMemo(() => {
return newsList
.filter((news) => news.carousel === "PROMOTION")
.sort((a, b) => new Date(b.publishEndAt).getTime() - new Date(a.publishEndAt).getTime())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

publishEndAt 값이 유효하지 않은 날짜 문자열일 경우 new Date().getTime()이 NaN을 반환하여 정렬 결과가 부정확해질 수 있습니다. 날짜 변환 시 유효성을 확인하거나 안전한 비교 로직을 사용하는 것이 좋습니다.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

⚠️ Outside diff range comments (2)
src/utils/url.ts (1)

4-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Restrict this type guard to image-safe schemes.

new URL(url) treats any parseable absolute scheme as valid, so this helper now narrows data:, ftp:, javascript:, and other non-HTTP schemes to string as well. Since all 30+ call sites in the codebase use it to decide whether to pass the value straight into Next.js Image components, the guard should only accept /..., //..., http:, or https: URLs.

Suggested fix
 export const isValidUrl = (url: string | null | undefined): url is string => {
-    if (!url || url === "string" || url.trim() === "") return false;
+    if (!url || url === "string" || url.trim() === "") return false;
+    const trimmed = url.trim();

     // 허용되는 상대 경로 패턴 (예: /profile2.svg)
-    if (url.startsWith("/")) return true;
+    if (trimmed.startsWith("/")) return true;

     try {
-        new URL(url);
-        return true; // http, https 등 유효한 scheme이 있는 경우
+        const parsed = new URL(trimmed);
+        return parsed.protocol === "http:" || parsed.protocol === "https:";
     } catch {
         // 프로토콜 상대 URL 지원 (예: //example.com/image.png)
-        if (url.startsWith("//")) {
+        if (trimmed.startsWith("//")) {
             try {
-                new URL(`https:${url}`);
-                return true;
+                const parsed = new URL(`https:${trimmed}`);
+                return parsed.protocol === "https:";
             } catch {
                 return false;
             }
         }
         return false;
     }
 };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/url.ts` around lines 4 - 25, The isValidUrl type guard currently
accepts any absolute URL scheme; update it so it only returns true for relative
paths starting with "/" , protocol-relative URLs starting with "//" (validate by
prefixing "https:"), or absolute URLs whose URL.prototype.protocol is exactly
"http:" or "https:". Keep the existing null/empty checks (including trimming)
and the special-case handling for strings starting with "/", but after
constructing new URL(url) verify urlObj.protocol === "http:" || urlObj.protocol
=== "https:"; for the "//" branch construct new URL(`https:${url}`) and treat
that as valid only if it parses successfully.
src/hooks/mutations/useBookMutations.ts (1)

145-156: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the likedBooks cache consistent during the optimistic toggle.

fullBookToInject is captured from the pre-mutation caches, so fullBookToInject.likedByMe is still false on a fresh like and the inject branch never runs. On the unlike path, the new .map()-only update leaves the book inside likedBooks with likedByMe: false. Since Lines 217-219 now skip the immediate refetch, both cases leave "내 서재" stale until a later manual refresh.

🐛 Suggested fix
-                            let updatedBooks = page.books
-                                .map((book) =>
-                                    book.isbn === isbn ? { ...book, likedByMe: !book.likedByMe } : book
-                                );
+                            let updatedBooks = page.books
+                                .map((book) =>
+                                    book.isbn === isbn ? { ...book, likedByMe: !book.likedByMe } : book
+                                )
+                                .filter((book) => !isMyLibrary || book.likedByMe);

                             // Inject the new book into the very first page if it's not already in the library,
                             // we are actually liking it, and we are looking at My Library
                             if (index === 0 && isMyLibrary && !isAlreadyInLibrary && fullBookToInject) {
-                                const isLikedAfterMutation = fullBookToInject.likedByMe; // This is the state *after* the mutation execution context (already toggled by previous logic)
-                                if (isLikedAfterMutation) {
-                                    // Make sure to add it as liked
-                                    updatedBooks = [{ ...fullBookToInject, likedByMe: true }, ...updatedBooks];
-                                }
+                                updatedBooks = [{ ...fullBookToInject, likedByMe: true }, ...updatedBooks];
                             }

Also applies to: 217-219

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/mutations/useBookMutations.ts` around lines 145 - 156, The
optimistic update fails to keep the likedBooks cache consistent because
fullBookToInject is captured pre-mutation (so fullBookToInject.likedByMe is
stale); change the logic that decides whether to inject the book into the first
page to use the intended post-toggle state (i.e., compute isLikedAfterMutation
as the toggled value, for example using the isbn-targeting map result or
!fullBookToInject.likedByMe / !current value) instead of reading
fullBookToInject.likedByMe, and when unliking ensure you remove or update the
book in the likedBooks cache (updatedBooks) accordingly; update the branch that
mutates updatedBooks (in the function handling optimistic toggle where variables
index, isMyLibrary, isAlreadyInLibrary, fullBookToInject, updatedBooks and
likedByMe appear) so the inject and removal behavior uses the computed
isLikedAfterMutation to keep "내 서재" consistent without requiring an immediate
refetch.
🧹 Nitpick comments (1)
src/components/layout/NotificationDropdown.tsx (1)

13-15: ⚡ Quick win

Subscribe only to isLoggedIn from the auth store.

useAuthStore() here subscribes the dropdown to the entire auth state, so unrelated auth-store updates will rerender a layout-level component and re-evaluate the notification query wiring. A selector keeps this scoped to the one field the component actually uses. Please verify this against your current Zustand selector guidance.

♻️ Suggested change
-    const { isLoggedIn } = useAuthStore();
+    const isLoggedIn = useAuthStore((state) => state.isLoggedIn);
     const { data: notificationsData, isLoading } = useNotificationPreviewQuery(5, isLoggedIn);
-    const notifications = isLoggedIn ? notificationsData : []; const { mutate: readNotification } = useReadNotificationMutation();
+    const notifications = isLoggedIn ? notificationsData : [];
+    const { mutate: readNotification } = useReadNotificationMutation();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/layout/NotificationDropdown.tsx` around lines 13 - 15, The
component currently calls useAuthStore() without a selector causing the whole
auth store to be subscribed; change it to select only isLoggedIn (e.g., const
isLoggedIn = useAuthStore(state => state.isLoggedIn)) so NotificationDropdown
only re-renders when that boolean changes, and leave
useNotificationPreviewQuery(5, isLoggedIn) and const { mutate: readNotification
} = useReadNotificationMutation() as-is; update the import/usages in
NotificationDropdown.tsx accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/base-ui/Profile/Follow/FollowItem.tsx`:
- Around line 24-27: The delete handler allows duplicate calls because it only
checks isDeleted; add a local in-flight lock (e.g., isDeleting state or ref) and
include it in the guard for handleDelete so repeated clicks are ignored while a
delete is in progress: in FollowItem.tsx update the handleDelete function to
check both isDeleted and isDeleting, set isDeleting = true before invoking
onDelete(user.nickname), and keep it true until the parent finishes (if onDelete
returns a Promise await it and then clear or leave flagged if parent removes the
item); apply the same pattern to the other similar delete/follow handlers
referenced (lines ~45-58).

In `@src/hooks/mutations/useMemberMutations.ts`:
- Around line 335-343: Replace the explicit any in the onError handler with the
ApiError type: import ApiError from "@/lib/api/ApiError" and change the onError
signature to use (error: ApiError, nickname, context) so the code can safely
access error.response?.data?.message and error.message; update the import list
at the top of useMemberMutations.ts to include ApiError and ensure no other any
remains in the onError callback for delete-follower logic (the function name to
edit is the onError handler within useMemberMutations).
- Around line 291-292: The followers/followings invalidation is set to
refetchType: 'none' which leaves optimistic updates in place when
useToggleFollowMutation's mutationFn early-returns due to its throttle check;
either move the throttle guard from mutationFn (the early-return in
useToggleFollowMutation.mutationFn) into onMutate so throttled clicks never
apply optimistic changes, or remove refetchType: 'none' for the
queryClient.invalidateQueries calls that reference memberKeys.followers() and
memberKeys.followings() in the onSettled block so those caches are refetched on
settle and cannot remain stale after a throttled mutation.
- Around line 305-333: The onSuccess handler in useDeleteFollowerMutation
currently only invalidates memberKeys.followers(), leaving the follower count
stale; update the onSuccess (and/or the finalization) to also invalidate
memberKeys.followCount() so components using useFollowCountQuery() refresh —
locate useDeleteFollowerMutation and add queryClient.invalidateQueries({
queryKey: memberKeys.followCount(), refetchType: "none" }) alongside the
existing invalidate for memberKeys.followers().

---

Outside diff comments:
In `@src/hooks/mutations/useBookMutations.ts`:
- Around line 145-156: The optimistic update fails to keep the likedBooks cache
consistent because fullBookToInject is captured pre-mutation (so
fullBookToInject.likedByMe is stale); change the logic that decides whether to
inject the book into the first page to use the intended post-toggle state (i.e.,
compute isLikedAfterMutation as the toggled value, for example using the
isbn-targeting map result or !fullBookToInject.likedByMe / !current value)
instead of reading fullBookToInject.likedByMe, and when unliking ensure you
remove or update the book in the likedBooks cache (updatedBooks) accordingly;
update the branch that mutates updatedBooks (in the function handling optimistic
toggle where variables index, isMyLibrary, isAlreadyInLibrary, fullBookToInject,
updatedBooks and likedByMe appear) so the inject and removal behavior uses the
computed isLikedAfterMutation to keep "내 서재" consistent without requiring an
immediate refetch.

In `@src/utils/url.ts`:
- Around line 4-25: The isValidUrl type guard currently accepts any absolute URL
scheme; update it so it only returns true for relative paths starting with "/" ,
protocol-relative URLs starting with "//" (validate by prefixing "https:"), or
absolute URLs whose URL.prototype.protocol is exactly "http:" or "https:". Keep
the existing null/empty checks (including trimming) and the special-case
handling for strings starting with "/", but after constructing new URL(url)
verify urlObj.protocol === "http:" || urlObj.protocol === "https:"; for the "//"
branch construct new URL(`https:${url}`) and treat that as valid only if it
parses successfully.

---

Nitpick comments:
In `@src/components/layout/NotificationDropdown.tsx`:
- Around line 13-15: The component currently calls useAuthStore() without a
selector causing the whole auth store to be subscribed; change it to select only
isLoggedIn (e.g., const isLoggedIn = useAuthStore(state => state.isLoggedIn))
so NotificationDropdown only re-renders when that boolean changes, and leave
useNotificationPreviewQuery(5, isLoggedIn) and const { mutate: readNotification
} = useReadNotificationMutation() as-is; update the import/usages in
NotificationDropdown.tsx accordingly.
🪄 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: defaults

Review profile: CHILL

Plan: Pro

Run ID: ed7fb9b4-9eed-40d2-8e2f-32f8a65b9a87

📥 Commits

Reviewing files that changed from the base of the PR and between a9fa2ac and 285b8c7.

📒 Files selected for processing (44)
  • src/app/(admin)/admin/(app)/stories/[id]/page.tsx
  • src/app/(main)/profile/[nickname]/follows/page.tsx
  • src/app/(main)/profile/mypage/follows/page.tsx
  • src/app/(main)/stories/[id]/page.tsx
  • src/app/groups/[id]/admin/applicant/page.tsx
  • src/app/groups/[id]/admin/members/page.tsx
  • src/app/groups/[id]/bookcase/page.tsx
  • src/app/groups/[id]/layout.tsx
  • src/app/groups/[id]/notice/page.tsx
  • src/app/groups/[id]/page.tsx
  • src/components/base-ui/Admin/stories/bookstory_detail.tsx
  • src/components/base-ui/Admin/stories/comment_section.tsx
  • src/components/base-ui/Admin/users/items/AdminBookStoryCard.tsx
  • src/components/base-ui/Admin/users/items/AdminReportItem.tsx
  • src/components/base-ui/BookStory/Common/bookstory_card.tsx
  • src/components/base-ui/BookStory/Detatil/bookstory_detail.tsx
  • src/components/base-ui/Bookcase/Admin/bookdetailgrouping/MemberPool.tsx
  • src/components/base-ui/Bookcase/bookid/TeamMemberItem.tsx
  • src/components/base-ui/Comment/comment_item.tsx
  • src/components/base-ui/Comment/comment_section_bookcase 2.tsx
  • src/components/base-ui/Comment/comment_section_bookcase.tsx
  • src/components/base-ui/Join/steps/ProfileImage/ProfileImageUploader.tsx
  • src/components/base-ui/Join/steps/ProfileImage/useProfileImage.ts
  • src/components/base-ui/MyPage/ProfileSection/UserProfile.tsx
  • src/components/base-ui/News/NewsListBanner.tsx
  • src/components/base-ui/Profile/Follow/FollowItem.tsx
  • src/components/base-ui/Profile/OtherUser/ProfileUserInfo.tsx
  • src/components/base-ui/Settings/EditProfile/ProfileImageSection.tsx
  • src/components/base-ui/Settings/Report/ReportItem.tsx
  • src/components/base-ui/home/Recommendation/list_subscribe_element.tsx
  • src/components/base-ui/home/Recommendation/list_subscribe_large.tsx
  • src/components/base-ui/home/Subscription/list_subscribe_element.tsx
  • src/components/base-ui/home/Subscription/list_subscribe_large.tsx
  • src/components/layout/AdminHeader.tsx
  • src/components/layout/Header.tsx
  • src/components/layout/NotificationDropdown.tsx
  • src/constants/images.ts
  • src/hooks/mutations/useBookMutations.ts
  • src/hooks/mutations/useMemberMutations.ts
  • src/hooks/mutations/useStoryMutations.ts
  • src/hooks/queries/useNotificationQueries.ts
  • src/types/member.ts
  • src/types/news.ts
  • src/utils/url.ts

Comment on lines +335 to +343
onError: (error: any, nickname, context) => {
console.error("Failed to delete follower:", error);
const errorMessage = error.response?.data?.message || error.message || "구독자 삭제에 실패했습니다.";
toast.error(errorMessage);

// 에러 발생 시 이전 상태로 롤백
if (context?.previousFollowers) {
queryClient.setQueryData(memberKeys.followers(), context.previousFollowers);
}

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

git ls-files | grep -E "(useMemberMutations|eslint)" | head -20

Repository: checkmo2025/FE

Length of output: 118


🏁 Script executed:

cat -n src/hooks/mutations/useMemberMutations.ts | sed -n '330,350p'

Repository: checkmo2025/FE

Length of output: 978


🏁 Script executed:

fd -type f -name '.eslintrc*' -o -name 'eslint.config.*'

Repository: checkmo2025/FE

Length of output: 229


🏁 Script executed:

find . -name '.eslintrc*' -o -name 'eslint.config.*' 2>/dev/null

Repository: checkmo2025/FE

Length of output: 10098


🏁 Script executed:

cat eslint.config.mjs | head -100

Repository: checkmo2025/FE

Length of output: 523


🏁 Script executed:

head -30 src/hooks/mutations/useMemberMutations.ts

Repository: checkmo2025/FE

Length of output: 1316


🏁 Script executed:

rg "axios|fetch|http" src/hooks/mutations/useMemberMutations.ts | head -20

Repository: checkmo2025/FE

Length of output: 440


🏁 Script executed:

cat -n src/services/memberService.ts | head -50

Repository: checkmo2025/FE

Length of output: 2641


🏁 Script executed:

rg "AxiosError|Error\)" src/hooks/mutations/ -A 2 -B 2

Repository: checkmo2025/FE

Length of output: 485


🏁 Script executed:

grep -r "error.response" src/hooks/mutations/ --include="*.ts" --include="*.tsx"

Repository: checkmo2025/FE

Length of output: 1079


🏁 Script executed:

cat -n src/lib/api/client.ts | head -80

Repository: checkmo2025/FE

Length of output: 2987


🏁 Script executed:

rg "AxiosError|import.*Error" src/lib/api/ -A 2

Repository: checkmo2025/FE

Length of output: 472


🏁 Script executed:

grep -r "onError.*error" src/hooks/mutations/useAuthMutations.ts -A 3 | head -40

Repository: checkmo2025/FE

Length of output: 572


🏁 Script executed:

cat -n src/lib/api/ApiError.ts

Repository: checkmo2025/FE

Length of output: 395


🏁 Script executed:

cat -n src/lib/api/client.ts | sed -n '80,120p'

Repository: checkmo2025/FE

Length of output: 1854


🏁 Script executed:

rg "import.*ApiError" src/hooks/mutations/useMemberMutations.ts

Repository: checkmo2025/FE

Length of output: 40


🏁 Script executed:

rg "import.*ApiError" src/hooks/mutations/

Repository: checkmo2025/FE

Length of output: 40


🏁 Script executed:

cat -n src/hooks/mutations/useMemberMutations.ts | sed -n '1,20p'

Repository: checkmo2025/FE

Length of output: 922


🏁 Script executed:

rg "onError.*error.*:" src/hooks/mutations/useMemberMutations.ts -B 3 -A 5

Repository: checkmo2025/FE

Length of output: 2623


Replace the any type with ApiError in this error handler.

Line 335 uses any which violates the @typescript-eslint/no-explicit-any rule enabled in your ESLint configuration. Import ApiError from @/lib/api/ApiError and use it as the error parameter type instead—it properly matches the code's access pattern for error.response?.data?.message and error.message.

🧰 Tools
🪛 ESLint

[error] 335-335: Unexpected any. Specify a different type.

(@typescript-eslint/no-explicit-any)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/mutations/useMemberMutations.ts` around lines 335 - 343, Replace
the explicit any in the onError handler with the ApiError type: import ApiError
from "@/lib/api/ApiError" and change the onError signature to use (error:
ApiError, nickname, context) so the code can safely access
error.response?.data?.message and error.message; update the import list at the
top of useMemberMutations.ts to include ApiError and ensure no other any remains
in the onError callback for delete-follower logic (the function name to edit is
the onError handler within useMemberMutations).

…ation

refactor: 레이아웃 파일에 대한 main 태그 HTML 표준에 맞도록 수정
@shinwokkang

Copy link
Copy Markdown
Contributor Author

@coderabbitai
지금 해당 PR 에 대해서 올라온 리뷰 내용들에 대해서 타당성을 검증하고 수정하는 이슈를 하나 만들어주세요.

yhi9839 and others added 17 commits May 17, 2026 18:37
- Hero 배경: landing-first-background.svg
- 홈 섹션: landing-second-screen.svg (메인) + carrecel/bookstory 플로팅 카드 배치

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 캐러셀 우하단, 북스토리 좌측 배치로 변경
- 두 플로팅 카드 opacity 제거 (완전 불투명)
- 북스토리 카드 위치 상향 조정
- 다크 패널 색상 #3C2A22 적용
- 캐러셀 파일명 오타 수정 (carrecel → carousel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 북스토리 카드 축소 (48% → 36%)
- 캐러셀 카드 확대 (52% → 78%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 캐러셀 카드 위치 조정 (우측 상단)
- 북스토리/캐러셀 opacity-70 적용
- public/landing/ 에 club 섹션 이미지 4개 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 배경 이미지 적용 (landing-third-backgroud.svg)
- 메인 스크린 + 생성/가입 버튼 오버레이
- 플로팅 클럽 카드 6장 섹션 edge 잘림 효과 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 책 이야기: 좌측 텍스트 + 우측 스크린 + 책 스택 데코
- 소식: 배경 풀블리드 + 뉴스 카드 3장 cascade 쌓임 효과

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 좌측: 텍스트 + 폰 프레임 안 모바일 화면
- 우측: 신청 관리 / 투표 화면 2장 + screen3 투표 항목 오버레이

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 배경: landing-seventh-background.svg
- 상단 책 아이콘: landing-seventh-book.svg
- screen1 (앱 화면) + screen2 (한줄평 팝업) 배치
- 한줄평 작성 소개 텍스트

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- /landing 경로로 랜딩 페이지 분리, /home으로 메인 홈 라우트 생성
- 루트(/)에서 localStorage 기반 seen 체크 후 조건부 리다이렉트 (30일 주기)
- 랜딩 진입 시 seen_landing_v1 타임스탬프 기록
- LandingNav 텍스트/링크 변경: 로그인→바로가기(/home), 지금 가입하기→로그인/회원가입(/login)
- LandingChatSection: bookcard1 오버레이, comment1~4 스택, screen3+select1/2 발제 섹션 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LandingChatSection: 채팅 기능 활용 섹션 추가 (screen4/5, chat1-4, group1, 점선 연결, chat-input)
- LandingChatSection: 발제 섹션 select 크기 확대, 채팅 버블 좌우 정렬
- LandingHero: 지금 가입하기 클릭 시 /home 이동 + 로그인 모달 오픈
- LandingHomeSection: 대각선 클립 수정 (왼쪽 아래→위쪽 중앙), 투명도 제거
- LandingManageSection: 폰 프레임 중앙 이동, 데스크탑 화면 오른쪽 배치
- LandingNav: 로그인/회원가입 클릭 시 /home 이동 + 로그인 모달 오픈

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- seventh → sixth 파일명 일괄 변경 (public/landing)
- LandingChatSection: 채팅 버블 HTML로 교체 및 좌우 튀어나오는 효과 적용
- LandingHomeSection: 대각선 클립 수정 (아래 중앙-10% → 위 중앙+10%)
- LandingManageSection: 데스크탑 화면 위치 조정, 모바일 화면 상단 이동
- LandingNewsSection: 뉴스 카드 순서 반전 (4→3→2→1) 및 위치 상향

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 모바일(375) / 태블릿(768) / 데스크탑 반응형 레이아웃 적용
- 각 섹션 모바일에서 설명 위 / 컨텐츠(스크린) 아래 구조로 변경
- LandingNav: 모바일 패딩 축소, 바로가기 숨김, 버튼 크기 조정
- LandingHomeSection: 모바일 전체 다크배경, 대각선 태블릿+만 표시
- LandingNewsSection: 모바일 텍스트 위/카드 아래 order 적용
- LandingManageSection: 모바일 심플 스택 레이아웃 추가
- LandingChatSection: 섹션별 모바일 레이아웃 분리, 하단 가입하기 버튼 추가
- 폰트 raw pixel 값 디자인 시스템 유틸리티로 통일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- LandingManageSection: 모바일/태블릿/데스크탑 3단계 레이아웃 분리
  - 모바일(~767): 화면만 수직 스택, 폰 이미지 숨김
  - 태블릿(768~1440): 폰 + 화면 가로 배치, 같이 축소
  - 데스크탑(1440+): 기존 absolute 복잡 레이아웃 유지
- LandingManageSection: 768~1024에서 absolute 레이아웃 제거
- LandingChatSection: 채팅 버블 컨테이너 배경 #EAE5E2 적용
- LandingChatSection: A조 헤더 텍스트 Gray 컬러로 변경
- LandingChatSection: 채팅 버블 텍스트 break-words, body_2_2 적용
- 전체 섹션 h2 타이틀 태블릿에서 subhead_1, 데스크탑에서만 headline_3
- 랜딩 하단 로고 + 지금 가입하기 버튼 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- FadeInSection 컴포넌트 추가 (Framer Motion whileInView 기반)
- 전 섹션 모바일(375)/태블릿(768)/데스크탑(1440) 반응형 레이아웃 구현
- 헤딩 폰트 통일: subhead_1 → d:headline_3 (데스크탑만 확대)
- LandingManageSection 태블릿 레이아웃 겹침 문제 수정
- 채팅 버블 컨테이너 내부 배치로 반응형 깨짐 해결
- 랜딩 최하단 로고 + '지금 가입하기' 버튼 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix: 소식 캐러셀 이미지 크롭 기준을 왼쪽 상단으로 변경
feat: 책 이야기 이어쓰기 및 임시 저장 api 연동
@shinwokkang

Copy link
Copy Markdown
Contributor Author

@coderabbitai 리뷰 내용 중에서 중요하게 수정되어야 할 부분들에 대해서 새로운 이슈로 만들어주세요.

@coderabbitai

coderabbitai Bot commented May 18, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

PR #302의 리뷰 항목 중 기능/데이터 정합성/보안에 직접 영향을 미치는 중요 항목들만 추려서 새 이슈를 생성하겠습니다.


🏁 Script executed:

#!/bin/bash
cat > /tmp/critical_issue_body.md << 'ISSUE_EOF'
## 개요

PR `#302` (https://github.com/checkmo2025/FE/pull/302) 코드 리뷰 중 **기능 버그·데이터 정합성·보안**에 직접 영향을 미치는 항목만 선별하여 정리한 이슈입니다.

> 관련 전체 검증 이슈: `#304`
> 요청자: `@shinwokkang`

---

## 🔴 Critical — 즉시 수정 필요

### 1. `src/utils/url.ts` — `isValidUrl` 위험 스킴 허용 (보안)
- **위치**: `isValidUrl` 함수 내부
- **문제**: `new URL(url)` 파싱은 `javascript:`, `data:`, `ftp:` 등 위험한 스킴도 유효로 판단함. 30개 이상의 Next.js `Image` 및 링크 컴포넌트에서 이 함수를 신뢰하므로 XSS 등 보안 위협으로 이어질 수 있음
- **수정 방향**:
  ```ts
  // protocol이 http: 또는 https:인 경우만 true 반환
  // 상대경로 (/로 시작)는 그대로 허용
  if (url.startsWith('/') || url.startsWith('//')) return true;
  const parsed = new URL(url);
  return parsed.protocol === 'http:' || parsed.protocol === 'https:';
  ```

---

### 2. `src/hooks/mutations/useBookMutations.ts` — `likedBooks` 낙관적 업데이트 로직 오류 (데이터 정합성)
- **위치**: `handleLikedBooksUpdate` 함수, `onSuccess` 내 invalidation
- **문제**:
  1. `fullBookToInject`는 mutation **이전** 상태로 캡처되므로 `likedByMe`가 항상 pre-toggle 값 → 좋아요 시 inject 조건이 틀려 동작하지 않음
  2. 좋아요 취소(unlike) 시 `likedByMe: false` 상태의 책이 `likedBooks` 캐시에 남음
  3. `refetchType: 'none'` 설정으로 "내 서재" 화면이 수동 새로고침 전까지 stale 상태 유지
- **수정 방향**: inject 조건을 post-toggle 상태 기준으로 수정; unlike 시 `likedByMe: false` 항목을 캐시에서 필터링

---

### 3. `src/components/base-ui/Profile/Follow/FollowItem.tsx` — 중복 삭제 API 호출 가능 (데이터 정합성)
- **위치**: `handleDelete` 핸들러
- **문제**: `handleDelete`가 `user.isDeleted`(부모 prop)만 체크하므로, 부모 데이터가 아직 갱신되지 않은 상태에서 빠른 재클릭 시 동일 사용자에 대한 삭제 API가 중복 호출될 수 있음
- **수정 방향**: `isDeleting` ref 또는 state를 추가하여 in-flight 중 추가 클릭을 차단
  ```ts
  const isDeletingRef = useRef(false);
  const handleDelete = async () => {
    if (isDeleted || isDeletingRef.current || !onDelete) return;
    isDeletingRef.current = true;
    try { await onDelete(user.memberId); }
    finally { isDeletingRef.current = false; }
  };
  ```

---

### 4. `src/hooks/mutations/useMemberMutations.ts` — 팔로우 스로틀 + 낙관적 업데이트 조합 캐시 고착 (데이터 정합성)
- **위치**: `useToggleFollowMutation` — `mutationFn`의 스로틀 체크, `onMutate` 낙관적 업데이트, `onSettled` invalidation
- **문제**: `onMutate`에서 낙관적 업데이트가 먼저 적용된 뒤 `mutationFn` 내부의 스로틀 체크로 API 호출이 스킵될 경우, `refetchType: 'none'`으로 설정된 `followers`/`followings` 캐시가 낙관적 상태로 영구 고착됨
- **수정 방향**: 스로틀 체크를 `onMutate` 최상단으로 이동하여 낙관적 업데이트 자체를 막거나, 영향받는 invalidation에서 `refetchType: 'none'` 제거

---

### 5. `src/hooks/mutations/useMemberMutations.ts` — 팔로워 삭제 후 팔로워 수 미갱신 (데이터 정합성)
- **위치**: `useDeleteFollowerMutation` — `onSuccess` 핸들러
- **문제**: `onSuccess`에서 `memberKeys.followers()`만 무효화하고 `memberKeys.followCount()`는 무효화하지 않아, `useFollowCountQuery`를 사용하는 프로필 화면에서 팔로워 수가 stale하게 유지됨
- **수정 방향**:
  ```ts
  onSuccess: async () => {
    await queryClient.invalidateQueries({ queryKey: memberKeys.followers() });
    await queryClient.invalidateQueries({ queryKey: memberKeys.followCount() }); // 추가
  }
  ```

---

## 체크리스트

- [ ] **`#1`** `src/utils/url.ts` — isValidUrl에서 http/https 외 스킴 차단
- [ ] **`#2`** `src/hooks/mutations/useBookMutations.ts` — likedBooks 낙관적 업데이트 및 캐시 필터링 수정
- [ ] **`#3`** `src/components/base-ui/Profile/Follow/FollowItem.tsx` — 중복 삭제 방지 in-flight 락 추가
- [ ] **`#4`** `src/hooks/mutations/useMemberMutations.ts` — 팔로우 스로틀 위치 수정 또는 refetchType 제거
- [ ] **`#5`** `src/hooks/mutations/useMemberMutations.ts` — 팔로워 삭제 후 followCount 무효화 추가

---

*PR `#302` 리뷰 중 Critical 항목 선별 이슈 — https://github.com/checkmo2025/FE/pull/302*
ISSUE_EOF

gh issue create \
  --repo checkmo2025/FE \
  --title "[PR `#302`] Critical 수정 항목 — 보안 및 데이터 정합성" \
  --assignee shinwokkang \
  --body-file /tmp/critical_issue_body.md

Length of output: 45


@shinwokkang 새 이슈가 생성되었습니다! 👇

🔗 #324


PR #302 리뷰 항목 중 보안 및 데이터 정합성에 직접 영향을 미치는 5개 Critical 항목만 선별하여 포함했습니다:

# 파일 문제 유형
1 src/utils/url.ts javascript:, data: 등 위험 스킴 허용 → XSS 위험
2 src/hooks/mutations/useBookMutations.ts likedBooks 낙관적 업데이트 로직 오류 → "내 서재" stale
3 src/components/base-ui/Profile/Follow/FollowItem.tsx 빠른 재클릭 시 중복 삭제 API 호출 가능
4 src/hooks/mutations/useMemberMutations.ts 팔로우 스로틀 + 낙관적 업데이트 조합으로 캐시 영구 고착
5 src/hooks/mutations/useMemberMutations.ts 팔로워 삭제 후 followCount 미갱신

전체 8개 검증 항목(Nitpick 포함)은 기존 #304 이슈를 참고해 주세요.

(´。• ᵕ •。`)🐇

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

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/app/(main)/stories/new/page.tsx (1)

55-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not always send description for drafts.

Line 58 always sends description, even when draft content is empty. Since this field is optional, send it only when non-empty to avoid avoidable API validation failures in draft saves.

🐛 Proposed fix
-    createStoryMutation.mutate({
-      isbn: selectedBook.isbn,
-      title,
-      description: detail,
-      status,
-    }, {
+    const trimmedTitle = title.trim();
+    const trimmedDetail = detail.trim();
+
+    createStoryMutation.mutate({
+      isbn: selectedBook.isbn,
+      title: trimmedTitle,
+      ...(trimmedDetail ? { description: trimmedDetail } : {}),
+      status,
+    }, {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/stories/new/page.tsx around lines 55 - 60, The payload passed
to createStoryMutation.mutate always includes description (detail) even for
drafts; change the call in page.tsx so the object only includes the description
property when detail is non-empty (e.g., build a payload object with isbn,
title, status and conditionally add description if detail.trim() !== "" or if
detail is defined); update the createStoryMutation.mutate invocation to send
that conditional payload instead of always passing description so drafts without
content don't send an empty description field.
🧹 Nitpick comments (2)
src/app/(main)/home/page.tsx (1)

43-43: ⚡ Quick win

Encode nickname before building the dynamic profile route.

Line 43 should encode the path segment to prevent malformed routes for special characters in nicknames.

♻️ Proposed change
-                onProfileClick={(nickname) => router.push(`/profile/${nickname}`)}
+                onProfileClick={(nickname) => router.push(`/profile/${encodeURIComponent(nickname)}`)}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/`(main)/home/page.tsx at line 43, The onProfileClick handler
currently builds the dynamic route directly with
router.push(`/profile/${nickname}`) which can break for special characters;
change it to encode the nickname path segment (e.g., use encodeURIComponent on
the nickname) before passing it to router.push so the route becomes
router.push(`/profile/${encodedNickname}`), ensuring correct, safe URLs; update
the onProfileClick usage accordingly.
src/components/base-ui/Landing/LandingNav.tsx (1)

12-13: ⚡ Quick win

Use a selector for auth-store subscription.

Line 12 currently subscribes to the full auth store. Select only openLoginModal to avoid unnecessary re-renders on unrelated auth-state updates.

♻️ Proposed change
-  const { openLoginModal } = useAuthStore();
+  const openLoginModal = useAuthStore((state) => state.openLoginModal);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/base-ui/Landing/LandingNav.tsx` around lines 12 - 13, The
component currently calls useAuthStore() without a selector which subscribes to
the entire auth store and may cause unnecessary re-renders; change the call in
LandingNav to select only the openLoginModal property (e.g., useAuthStore(state
=> state.openLoginModal)) so the component subscribes only to that piece of
state and avoids updates from unrelated auth changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/app/page.tsx`:
- Around line 13-16: The code reads localStorage.getItem(LANDING_KEY) into seen
and uses parseInt(seen, 10) to compute expiry, but parseInt can produce NaN
which makes NaN > EXPIRY_MS false and treats malformed values as not expired;
update the logic around parseInt(seen, 10) (the seen parsing/expiry check in
page.tsx) to first parse into a number (e.g., const ts = parseInt(seen, 10)) and
use Number.isFinite(ts) (or Number.isFinite(parseInt(...))) to detect malformed
values and treat them as expired when false—only consider the timestamp valid
when Number.isFinite(ts) && (Date.now() - ts) <= EXPIRY_MS so malformed or
non-finite values route to the landing flow instead of /home.

In `@src/hooks/mutations/useMemberMutations.ts`:
- Around line 159-171: The throttle currently lives in onMutate but that only
affects optimistic updates; move the 500ms guard into the mutation function so
API calls are prevented: inside the existing mutationFn (the async ({ nickname,
isFollowing }) => { ... }) check followThrottleMap[nickname] and Date.now(),
return early if within 500ms, otherwise set followThrottleMap[nickname] = now
and then call memberService.followMember or memberService.unfollowMember as
appropriate; keep onMutate for optimistic state updates but remove/adjust its
throttle so it no longer needs to block API calls.

---

Outside diff comments:
In `@src/app/`(main)/stories/new/page.tsx:
- Around line 55-60: The payload passed to createStoryMutation.mutate always
includes description (detail) even for drafts; change the call in page.tsx so
the object only includes the description property when detail is non-empty
(e.g., build a payload object with isbn, title, status and conditionally add
description if detail.trim() !== "" or if detail is defined); update the
createStoryMutation.mutate invocation to send that conditional payload instead
of always passing description so drafts without content don't send an empty
description field.

---

Nitpick comments:
In `@src/app/`(main)/home/page.tsx:
- Line 43: The onProfileClick handler currently builds the dynamic route
directly with router.push(`/profile/${nickname}`) which can break for special
characters; change it to encode the nickname path segment (e.g., use
encodeURIComponent on the nickname) before passing it to router.push so the
route becomes router.push(`/profile/${encodedNickname}`), ensuring correct, safe
URLs; update the onProfileClick usage accordingly.

In `@src/components/base-ui/Landing/LandingNav.tsx`:
- Around line 12-13: The component currently calls useAuthStore() without a
selector which subscribes to the entire auth store and may cause unnecessary
re-renders; change the call in LandingNav to select only the openLoginModal
property (e.g., useAuthStore(state => state.openLoginModal)) so the component
subscribes only to that piece of state and avoids updates from unrelated auth
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: defaults

Review profile: CHILL

Plan: Pro

Run ID: b9ff1ed9-faa0-46a5-b217-26796875cc03

📥 Commits

Reviewing files that changed from the base of the PR and between c02ca0e and a3a9595.

⛔ Files ignored due to path filters (44)
  • package-lock.json is excluded by !**/package-lock.json
  • public/landing/landing-fifth-screen.svg is excluded by !**/*.svg
  • public/landing/landing-fifth-screen1.svg is excluded by !**/*.svg
  • public/landing/landing-fifth-screen2.svg is excluded by !**/*.svg
  • public/landing/landing-fifth-screen3.svg is excluded by !**/*.svg
  • public/landing/landing-first-background.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-background.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-books.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-news1.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-news2.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-news3.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-news4.svg is excluded by !**/*.svg
  • public/landing/landing-fourth-screen.svg is excluded by !**/*.svg
  • public/landing/landing-second-bookstory.svg is excluded by !**/*.svg
  • public/landing/landing-second-carousel.svg is excluded by !**/*.svg
  • public/landing/landing-second-screen.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-background.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-book.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-bookcard1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-chat-input.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-chat1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-chat2.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-chat3.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-chat4.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-comment1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-comment2.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-comment3.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-comment4.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-group1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-screen1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-screen2.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-screen3.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-screen4.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-screen5.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-select1.svg is excluded by !**/*.svg
  • public/landing/landing-sixth-select2.svg is excluded by !**/*.svg
  • public/landing/landing-third-backgroud.svg is excluded by !**/*.svg
  • public/landing/landing-third-club1.svg is excluded by !**/*.svg
  • public/landing/landing-third-club2.svg is excluded by !**/*.svg
  • public/landing/landing-third-club3.svg is excluded by !**/*.svg
  • public/landing/landing-third-club4.svg is excluded by !**/*.svg
  • public/landing/landing-third-create-button.svg is excluded by !**/*.svg
  • public/landing/landing-third-join-button.svg is excluded by !**/*.svg
  • public/landing/landing-third-screen.svg is excluded by !**/*.svg
📒 Files selected for processing (27)
  • src/app/(main)/home/page.tsx
  • src/app/(main)/stories/[id]/edit/page.tsx
  • src/app/(main)/stories/new/page.tsx
  • src/app/(public)/find-account/page.tsx
  • src/app/landing/page.tsx
  • src/app/page.tsx
  • src/components/base-ui/BookStory/Common/BookStoryInfiniteList.tsx
  • src/components/base-ui/BookStory/Common/bookstory_card.tsx
  • src/components/base-ui/Landing/FadeInSection.tsx
  • src/components/base-ui/Landing/LandingChatSection.tsx
  • src/components/base-ui/Landing/LandingClubSection.tsx
  • src/components/base-ui/Landing/LandingHero.tsx
  • src/components/base-ui/Landing/LandingHomeSection.tsx
  • src/components/base-ui/Landing/LandingManageSection.tsx
  • src/components/base-ui/Landing/LandingNav.tsx
  • src/components/base-ui/Landing/LandingNewsSection.tsx
  • src/components/base-ui/Landing/LandingStorySection.tsx
  • src/components/base-ui/Landing/shared/DesktopMockup.tsx
  • src/components/base-ui/Landing/shared/MobileMockup.tsx
  • src/components/base-ui/Profile/Follow/FollowItem.tsx
  • src/components/base-ui/home/Banner/NewsBannerSlider.tsx
  • src/components/base-ui/home/News/NewsBannerSlider.tsx
  • src/hooks/mutations/useBookMutations.ts
  • src/hooks/mutations/useMemberMutations.ts
  • src/hooks/mutations/useStoryMutations.ts
  • src/types/story.ts
  • src/utils/url.ts
✅ Files skipped from review due to trivial changes (1)
  • src/app/(public)/find-account/page.tsx
🚧 Files skipped from review as they are similar to previous changes (3)
  • src/hooks/mutations/useBookMutations.ts
  • src/components/base-ui/home/News/NewsBannerSlider.tsx
  • src/components/base-ui/Profile/Follow/FollowItem.tsx

Comment thread src/app/page.tsx
Comment on lines +13 to +16
const seen = localStorage.getItem(LANDING_KEY);
if (seen) {
const expired = Date.now() - parseInt(seen, 10) > EXPIRY_MS;
if (!expired) {

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 | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

cat -n src/app/page.tsx | head -30

Repository: checkmo2025/FE

Length of output: 787


🏁 Script executed:

node << 'EOF'
// Test 1: parseInt with invalid input
const seen = "invalid_value";
const result = parseInt(seen, 10);
console.log("parseInt('invalid_value', 10):", result);
console.log("NaN check:", result === NaN, Number.isNaN(result));

// Test 2: NaN comparison behavior
const EXPIRY_MS = 30 * 24 * 60 * 60 * 1000;
const expired = Date.now() - result > EXPIRY_MS;
console.log("NaN > EXPIRY_MS evaluates to:", expired);

// Test 3: Flow with current code
const seen2 = "not_a_number";
if (seen2) {
  const expired2 = Date.now() - parseInt(seen2, 10) > EXPIRY_MS;
  console.log("With malformed value, expired:", expired2);
  console.log("With malformed value, !expired:", !expired2);
  if (!expired2) {
    console.log("WOULD ROUTE TO /home (INCORRECT)");
  }
}

// Test 4: Verify the proposed fix
const seenAt = Number(seen2);
console.log("\nProposed fix - Number(seen):", seenAt);
console.log("Number.isFinite(NaN):", Number.isFinite(seenAt));
const expiredFixed = !Number.isFinite(seenAt) || Date.now() - seenAt > EXPIRY_MS;
console.log("With fix, expired:", expiredFixed);
console.log("With fix, !expired:", !expiredFixed);
if (!expiredFixed) {
  console.log("WOULD ROUTE TO /home");
} else {
  console.log("WOULD ROUTE TO /landing (CORRECT)");
}

// Test 5: Valid recent timestamp
const validRecent = String(Date.now());
const seenAtValid = Number(validRecent);
console.log("\nWith valid recent timestamp:", validRecent);
const expiredValidRecent = !Number.isFinite(seenAtValid) || Date.now() - seenAtValid > EXPIRY_MS;
console.log("Is expired:", expiredValidRecent);
console.log("Routes to:", !expiredValidRecent ? "/home" : "/landing");
EOF

Repository: checkmo2025/FE

Length of output: 483


Treat malformed seen_landing_v1 values as expired.

At Line 15, parseInt(seen, 10) can be NaN; then NaN > EXPIRY_MS is false, so invalid storage values are treated as not expired and incorrectly route to /home. Use Number.isFinite() to detect and handle malformed values.

Proposed fix
   useEffect(() => {
     const seen = localStorage.getItem(LANDING_KEY);
     if (seen) {
-      const expired = Date.now() - parseInt(seen, 10) > EXPIRY_MS;
+      const seenAt = Number(seen);
+      const expired = !Number.isFinite(seenAt) || Date.now() - seenAt > EXPIRY_MS;
       if (!expired) {
         router.replace("/home");
         return;
       }
     }
📝 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 seen = localStorage.getItem(LANDING_KEY);
if (seen) {
const expired = Date.now() - parseInt(seen, 10) > EXPIRY_MS;
if (!expired) {
const seen = localStorage.getItem(LANDING_KEY);
if (seen) {
const seenAt = Number(seen);
const expired = !Number.isFinite(seenAt) || Date.now() - seenAt > EXPIRY_MS;
if (!expired) {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/page.tsx` around lines 13 - 16, The code reads
localStorage.getItem(LANDING_KEY) into seen and uses parseInt(seen, 10) to
compute expiry, but parseInt can produce NaN which makes NaN > EXPIRY_MS false
and treats malformed values as not expired; update the logic around
parseInt(seen, 10) (the seen parsing/expiry check in page.tsx) to first parse
into a number (e.g., const ts = parseInt(seen, 10)) and use Number.isFinite(ts)
(or Number.isFinite(parseInt(...))) to detect malformed values and treat them as
expired when false—only consider the timestamp valid when Number.isFinite(ts) &&
(Date.now() - ts) <= EXPIRY_MS so malformed or non-finite values route to the
landing flow instead of /home.

Comment on lines 159 to +171
mutationFn: async ({ nickname, isFollowing }: { nickname: string; isFollowing: boolean }) => {
const now = Date.now();
const lastTime = followThrottleMap[nickname] || 0;

// Throttle: 500ms
if (now - lastTime < 500) {
return; // Ignore rapid clicks
}
followThrottleMap[nickname] = now;

if (isFollowing) {
await memberService.unfollowMember(nickname);
} else {
await memberService.followMember(nickname);
}
},
onMutate: async ({ nickname, isFollowing }) => {
// Throttle: 500ms — 낙관적 업데이트 자체를 막아 캐시 고착 방지
const now = Date.now();
const lastTime = followThrottleMap[nickname] || 0;
if (now - lastTime < 500) return;
followThrottleMap[nickname] = now;

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 | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify throttle location: should exist in mutationFn, not only onMutate.
cat -n src/hooks/mutations/useMemberMutations.ts | sed -n '155,210p'

Repository: checkmo2025/FE

Length of output: 3345


🏁 Script executed:

#!/bin/bash
# Check if there are any debounce/throttle wrappers around the mutation call
rg "useToggleFollowMutation|toggleFollowMutation" --context=5 -A 10

Repository: checkmo2025/FE

Length of output: 16454


🏁 Script executed:

#!/bin/bash
# Check the entire file for throttle-related logic
rg "throttle|debounce|500" src/hooks/mutations/useMemberMutations.ts -n

Repository: checkmo2025/FE

Length of output: 170


🏁 Script executed:

#!/bin/bash
# Check if followThrottleMap is used elsewhere or has other safeguards
rg "followThrottleMap" src/hooks/mutations/useMemberMutations.ts -B 2 -A 2

Repository: checkmo2025/FE

Length of output: 514


Throttle currently does not prevent API calls—only optimistic updates are skipped.

The 500ms guard in onMutate (lines 167–171) does not prevent mutationFn (lines 159–165) from executing. React Query runs mutationFn regardless of early returns in onMutate, so rapid clicks still spam the follow/unfollow API.

Move the throttle check into mutationFn:

Fix
 mutationFn: async ({ nickname, isFollowing }: { nickname: string; isFollowing: boolean }) => {
+    const now = Date.now();
+    const lastTime = followThrottleMap[nickname] || 0;
+    if (now - lastTime < 500) return;
+    followThrottleMap[nickname] = now;
+
     if (isFollowing) {
         await memberService.unfollowMember(nickname);
     } else {
         await memberService.followMember(nickname);
     }
 },
 onMutate: async ({ nickname, isFollowing }) => {
-    // Throttle: 500ms — 낙관적 업데이트 자체를 막아 캐시 고착 방지
-    const now = Date.now();
-    const lastTime = followThrottleMap[nickname] || 0;
-    if (now - lastTime < 500) return;
-    followThrottleMap[nickname] = now;
-
     // Cancel outgoing refetches
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/hooks/mutations/useMemberMutations.ts` around lines 159 - 171, The
throttle currently lives in onMutate but that only affects optimistic updates;
move the 500ms guard into the mutation function so API calls are prevented:
inside the existing mutationFn (the async ({ nickname, isFollowing }) => { ...
}) check followThrottleMap[nickname] and Date.now(), return early if within
500ms, otherwise set followThrottleMap[nickname] = now and then call
memberService.followMember or memberService.unfollowMember as appropriate; keep
onMutate for optimistic state updates but remove/adjust its throttle so it no
longer needs to block API calls.

@shinwokkang
shinwokkang merged commit eb43f46 into main May 19, 2026
3 checks passed

This branch was successfully deployed

3 active (2 outdated) deployments
Preview — a3a95956 Deployed May 18, 2026 by vercel[bot]
Preview – check-mo — 9de0aee9 Deployed May 6, 2026 by vercel[bot]
Preview – checkmo — 9de0aee9 Deployed May 6, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🐛 bug 버그 수정 ✨ feat 새로운 기능 추가 ♻️ refactor 리팩토링

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants