Skip to content

05.23 최신화 - #342

Merged
yhi9839 merged 20 commits into
mainfrom
dev
May 23, 2026
Merged

yhi9839 merged 20 commits into
mainfrom
dev

Conversation

@yhi9839

@yhi9839 yhi9839 commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

📌 개요 (Summary)

05.23 최신화

Summary by CodeRabbit

  • Style

    • Updated call-to-action button styling on landing pages with improved hover/active visual feedback (brightness, shadow, and scale effects).
    • Refined navigation button styling to respond to scroll position with contextual color changes.
  • SEO/Infrastructure

    • Added robots.txt configuration to control search engine crawling.
    • Enhanced page metadata across the application for improved search visibility and social sharing.

Review Change Stack

yhi9839 and others added 20 commits May 20, 2026 23:14
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 정적 페이지(landing, home, 소식, 책이야기, 독서모임, 도서검색, 회원가입 등) metadata 추가
- 동적 페이지(소식/책이야기/모임/도서/프로필 상세) generateMetadata로 API 기반 동적 metadata 적용
- setting/*, profile/mypage에 noindex 적용
- OG URL을 checkmo.co.kr로 수정
- "use client" 페이지를 서버 컴포넌트로 전환 후 *Client.tsx로 분리

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
'모임명 - 독서 모임 | 책모' → '모임명 | 책모'

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- stories/new: "책이야기 - 작성 | 책모"
- groups/create: "모임 생성 | 책모"
- groups/[id]/notice: "모임명 - 공지사항 | 책모"
- groups/[id]/bookcase: "모임명 - 책장 | 책모"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- bookcase/[bookId]: "모임명 - 책장 | 책모"
- bookcase/[bookId]/meeting: "모임명 - 책장 | 책모"
- notice/[noticeId]: "모임명 - 공지사항 | 책모"

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 마이페이지: "마이페이지 | 책모"
- 팔로우: "팔로우 | 책모"
- 책이야기 수정: "제목 - 수정 | 책모" (동적)
- 설정 17개 페이지: 설정/계정상태/차단관리/이메일변경/로그아웃/새소식/알림설정/비밀번호변경/프로필수정/신고내역/고객지원/이용약관/버전정보

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- 팔로우 페이지: "팔로우" → "구독 | 책모"
- 설정 모든 하위 페이지: 각자 타이틀 → "설정 | 책모" 통일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…any 제거

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…adata 유지

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
fix: 랜딩페이지 버튼 hover 커서 및 시각 효과 추가
chore: 페이지별 타이틀/description 설정
chore: OG 이미지 및 Twitter 카드 메타데이터 추가
chore: robots.txt 추가 - 크롤러 접근 제어 설정
@vercel

vercel Bot commented May 22, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated (UTC)
checkmo Ready Ready Preview, Comment May 22, 2026 1:46pm

@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 implements a large-scale refactoring of the application's routing and component structure, moving client-side logic into dedicated *Client.tsx components. This change facilitates the integration of Next.js App Router features, specifically dynamic metadata generation for SEO and the use of Suspense for improved loading performance. The update also includes the addition of a robots.ts file and styling refinements for landing page components. Review feedback highlights opportunities to further optimize performance by using URL parameters as the primary source of truth instead of local state, correcting invalid Tailwind CSS utility classes, and improving code maintainability through better function scoping and removal of redundant event listeners. A security-related suggestion was also made to strengthen password validation logic.

Comment on lines +64 to +113
const [activeTab, setActiveTab] = useState<TabKey>("meeting");

const { user } = useAuthStore();

const myName = user?.nickname ?? "My_Name";
const myProfileImageUrl = user?.profileImageUrl ?? "/profile4.svg";

const [isDebateWriting, setIsDebateWriting] = useState(false);
const [isReviewWriting, setIsReviewWriting] = useState(false);

const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [reportTarget, setReportTarget] = useState<{
reportedMemberNickname: string;
} | null>(null);

const { data: meData } = useClubMeQuery(clubId);
const isStaff = !!meData?.staff;

const {
data: bookshelfDetail,
isLoading: isBookshelfLoading,
isError: isBookshelfError,
} = useBookshelfDetailQuery(clubId, meetingId);

const topicsQuery = useBookshelfTopicsInfiniteQuery(clubId, meetingId);
const reviewsQuery = useBookshelfReviewsInfiniteQuery(clubId, meetingId);

const { mutate: createTopic } = useCreateTopicMutation();
const { mutate: updateTopic } = useUpdateTopicMutation();
const { mutate: deleteTopic } = useDeleteTopicMutation();

const { mutate: createReview } = useCreateReviewMutation();
const { mutate: updateReview } = useUpdateReviewMutation();
const { mutate: deleteReview } = useDeleteReviewMutation();

const { mutateAsync: deleteBookshelf, isPending: isDeletingBookshelf } =
useDeleteBookshelfMutation();

const { mutateAsync: reportMember } = useReportMemberMutation();

useEffect(() => {
const tab = searchParams.get("tab");
if (isTabKey(tab) && tab !== activeTab) setActiveTab(tab);

if (!tab) {
const next = new URLSearchParams(searchParams.toString());
next.set("tab", "meeting");
router.replace(`${pathname}?${next.toString()}`, { scroll: false });
}
}, [searchParams]);

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

URL의 tab 파라미터를 activeTab 상태로 동기화하기 위해 useState와 useEffect를 사용하고 있습니다. 이는 불필요한 추가 렌더링을 유발하며, Next.js에서는 URL을 직접적인 상태의 원천(Source of Truth)으로 사용하는 것이 권장됩니다. useState와 useEffect를 제거하고 useSearchParams에서 직접 값을 읽어오도록 개선하면 코드도 간결해지고 성능도 향상됩니다.

@@ -0,0 +1,665 @@
/* eslint-disable react-hooks/set-state-in-effect */

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

react-hooks/set-state-in-effect 규칙을 비활성화하고 있습니다. 이는 주로 URL 파라미터와 로컬 상태를 동기화하는 과정에서 발생하는데, URL을 직접적인 상태의 원천으로 사용하도록 구조를 개선하면 이 규칙을 위반하지 않고도 안전하게 로직을 구현할 수 있습니다. selectedTeamId를 useState 대신 useMemo를 통해 URL 파라미터와 teams 데이터로부터 직접 유도하는 방식을 권장합니다.

</div>

{!isEmpty && (
<div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-50 py-4 flex items-center justify-center gap-0 z-50">

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

d:left-50은 표준 Tailwind CSS 클래스가 아닙니다. 중앙 정렬을 의도했다면 d:left-1/2 d:-translate-x-1/2를 사용하거나, 특정 위치를 의도했다면 d:left-[50px]와 같이 정확한 단위를 명시해야 합니다.

Suggested change
<div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-50 py-4 flex items-center justify-center gap-0 z-50">
<div className="fixed bottom-20 left-0 right-0 t:bottom-4 d:bottom-4 d:left-1/2 d:-translate-x-1/2 py-4 flex items-center justify-center gap-0 z-50">

Comment on lines +43 to +49
const formatDate = (iso: string) => {
const d = new Date(iso);
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${yyyy}.${mm}.${dd}`;
};

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

formatDate 함수가 컴포넌트 내부에서 정의되어 매 렌더링마다 불필요하게 생성되고 있습니다. 이 함수는 외부 의존성이 없으므로 컴포넌트 외부로 이동시키거나 useCallback을 사용하여 메모이제이션하는 것이 좋습니다.

return;
}

const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{6,12}$/;

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

현재 비밀번호 정규식은 영문자와 특수문자만 체크하며 숫자를 포함하도록 강제하지 않습니다. 보안 수준을 높이기 위해 숫자 포함 여부도 체크하는 정규식으로 개선하는 것을 권장합니다. (예: /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*])(?=.*[0-9]).{8,16}$/)

Suggested change
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*]).{6,12}$/;
const passwordRegex = /^(?=.*[a-zA-Z])(?=.*[!@#$%^&*])(?=.*[0-9]).{6,12}$/;

Comment on lines +297 to +300
setClubDescription(e.target.value);
autoResize(e.currentTarget);
}}
onInput={(e) => autoResize(e.currentTarget)}

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

onChange와 onInput 모두에서 autoResize를 호출하고 있습니다. onInput 이벤트만으로도 텍스트 입력 시 크기 조절을 처리하기에 충분하므로 중복 호출을 제거할 수 있습니다.

                onChange={(e) => {
                  setClubDescription(e.target.value);
                }}
                onInput={(e) => autoResize(e.currentTarget)}

@coderabbitai

coderabbitai Bot commented May 22, 2026 •

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Large refactor: interactive logic moved into new client components while Next.js route files become server wrappers with generateMetadata. Adds root layout social metadata, robots route, and minor landing UI style updates.

Changes

App Router client/server split across features

Layer / File(s) Summary
Books detail client and server metadata
src/app/(main)/books/[id]/BookDetailClient.tsx, src/app/(main)/books/[id]/page.tsx
Adds BookDetailClient and converts the page to a server wrapper with generateMetadata.
Groups search/list client and server wrapper
src/app/(main)/groups/GroupsPageClient.tsx, src/app/(main)/groups/page.tsx
Implements client groups page with search/apply; server page exports metadata and renders client.
Group detail client and server metadata
src/app/(main)/groups/[id]/GroupDetailClient.tsx, src/app/(main)/groups/[id]/page.tsx
Client renders group detail; server page adds generateMetadata and delegates.
Group bookcase list/detail/meeting clients and server wrappers
src/app/(main)/groups/[id]/bookcase/*
Adds clients for bookcase list/detail/meeting; related pages switch to server with metadata/Suspense.
Group notices list/detail clients and server wrappers
src/app/(main)/groups/[id]/notice/*
Adds notice list/detail clients; pages converted to server with generateMetadata.
News list/detail clients and server wrappers
src/app/(main)/news/*
Adds NewsPageClient and NewsDetailClient; pages now server with generateMetadata.
Stories list/detail/edit/new clients and server wrappers
src/app/(main)/stories/*
Adds story clients for list/detail/edit/new; pages become server with metadata.
Profile pages: other user, mypage, follows clients and server wrappers
src/app/(main)/profile/*
Adds profile clients and converts pages to server with metadata/Suspense.
Home and Search clients and server wrappers
src/app/(main)/home/*, src/app/(main)/search/*
Adds clients and switches pages to server wrappers with metadata.
Settings: layout, menu, and all subpages (clients + wrappers)
src/app/(main)/setting/**/*
Adds numerous settings clients and converts layout/pages to server wrappers with metadata.
Public auth flows: find account/password and signup (clients + wrappers)
src/app/(public)/*
Adds client flows and converts pages to server wrappers and redirects with metadata.
Landing page client and server wrapper
src/app/landing/*
Adds LandingPageClient and server page with static metadata.

Global SEO: layout metadata and robots

Layer / File(s) Summary
Root layout Open Graph/Twitter cards and robots
src/app/layout.tsx, src/app/robots.ts
Extends metadata with metadataBase and OG/Twitter images; adds robots route with disallow rules and sitemap.

Landing UI polish

Layer / File(s) Summary
CTA hover/active styles and nav button state
src/components/base-ui/Landing/*
Updates CTA buttons to brightness/shadow/active scale and nav button classes based on scroll.

Sequence Diagram(s)

sequenceDiagram
  participant ServerRoute
  participant API
  participant ClientComponent
  ServerRoute->>API: fetch data for generateMetadata
  API-->>ServerRoute: JSON (title/og data)
  ServerRoute-->>ClientComponent: render interactive UI
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • checkmo2025/FE#238: Also modifies src/app/layout.tsx metadata (icons), overlapping with this PR’s social/OG additions.
  • checkmo2025/FE#205: Touches news/[id]/page.tsx, related to this PR’s conversion to server with generateMetadata.
  • checkmo2025/FE#315: Works on the “차단 관리” page; this PR refactors the same route into a wrapper rendering BlockPageClient.

Suggested labels

✨ feat, ♻️ refactor

Poem

A rabbit refactors with gentle might,
Splitting server from client in the night.
Metadata stars, robots draw the map,
Stories and groups now neatly unwrap.
Buttons gleam brighter—hop and glow—
Ship it, ship it—onward we go! 🐇✨

✨ 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

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

Note

Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.

🟡 Minor comments (11)
src/app/(main)/news/[id]/NewsDetailClient.tsx-24-33 (1)

24-33: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the rendered image list length for carousel indexing.

Line 28 rotates with news.imageUrls.length, but rendering uses filtered imageUrls (Line 71). When invalid URLs are removed, the index can exceed rendered bounds and show a blank frame.

🛠️ Proposed fix
-  useEffect(() => {
-    if (!news || !news.imageUrls || news.imageUrls.length <= 1) return;
-
-    const timer = setInterval(() => {
-      setCurrentImageIndex((prev) => (prev + 1) % news.imageUrls.length);
-    }, 5000);
-
-    return () => clearInterval(timer);
-  }, [news?.imageUrls?.length]);
-
   const recommendedBooks = useMemo(() => {
@@
   const filteredUrls = rawImageUrls.filter(isValidSrc);
   const imageUrls = filteredUrls.length > 0 ? filteredUrls : ["/news_sample4.svg"];
+
+  useEffect(() => {
+    setCurrentImageIndex(0);
+  }, [imageUrls.length]);
+
+  useEffect(() => {
+    if (imageUrls.length <= 1) return;
+    const timer = setInterval(() => {
+      setCurrentImageIndex((prev) => (prev + 1) % imageUrls.length);
+    }, 5000);
+    return () => clearInterval(timer);
+  }, [imageUrls.length]);

Also applies to: 65-71

🤖 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)/news/[id]/NewsDetailClient.tsx around lines 24 - 33, The
carousel uses news.imageUrls.length for rotation but the UI renders a filtered
list, so compute a single renderedImages array (e.g., validImageUrls or
filteredImageUrls) from news.imageUrls (same filter used around the render
block) and use that array everywhere: in the useEffect guard, in the setInterval
modulo (setCurrentImageIndex(prev => (prev + 1) % renderedImages.length)), and
in the render logic that maps images (instead of directly using news.imageUrls);
also update the effect dependency to [renderedImages.length] and ensure
currentImageIndex is clamped/reset if renderedImages becomes shorter than the
current index.
src/app/(public)/find-password/FindPasswordPageClient.tsx-21-27 (1)

21-27: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate and normalize email before mutation call.

At Line 22, whitespace-only or invalid email format still proceeds to API. Add trim() + basic email format validation before sendTempPassword(...).

Proposed fix
 const handleSendTempPassword = () => {
-    if (!email) {
+    const normalizedEmail = email.trim();
+    const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
+
+    if (!normalizedEmail) {
         toast.error("이메일을 입력해주세요.");
         return;
     }
+    if (!emailPattern.test(normalizedEmail)) {
+        toast.error("올바른 이메일 형식을 입력해주세요.");
+        return;
+    }
 
-    sendTempPassword(email, {
+    sendTempPassword(normalizedEmail, {
         onSuccess: () => {
             router.push("/");
         }
     });
 };
🤖 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/`(public)/find-password/FindPasswordPageClient.tsx around lines 21 -
27, The handler handleSendTempPassword currently allows whitespace-only or
malformed emails to reach sendTempPassword; update it to trim the email (const
normalizedEmail = email?.trim()) and run a basic email format check (e.g.,
simple regex) before calling sendTempPassword; if normalizedEmail is empty or
fails validation, call toast.error("이메일을 입력해주세요." or a format-specific message)
and return, otherwise call sendTempPassword(normalizedEmail, {...}) so the
mutation always receives a normalized, validated address.
src/app/(main)/search/SearchPageClient.tsx-33-39 (1)

33-39: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use isLoading to avoid flashing a false 0개 state.

On a fresh search, this renders 총 0개 until the first page resolves because isLoading is ignored and searchResults starts empty. That briefly shows incorrect feedback on every new query.

Proposed fix
-          <p className="max-w-[1040px] text-Gray-4 body_1_3 t:subhead_4_1 ml-2 t:ml-0 mb-4">
-            총 <span className="text-primary-3">{totalResults}개</span>의 검색결과가 있습니다.
-          </p>
+          {!isLoading && (
+            <p className="max-w-[1040px] text-Gray-4 body_1_3 t:subhead_4_1 ml-2 t:ml-0 mb-4">
+              총 <span className="text-primary-3">{totalResults}개</span>의 검색결과가 있습니다.
+            </p>
+          )}

Also applies to: 100-104

🤖 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)/search/SearchPageClient.tsx around lines 33 - 39, The
total-count UI is showing "총 0개" during the initial fetch because
useInfiniteBookSearchQuery's isLoading is ignored and searchResults is computed
empty; update the render logic in SearchPageClient (the area using
useInfiniteBookSearchQuery, searchResults, and total count) to respect
isLoading: when isLoading is true show a loading state (or suppress the total
count) instead of computing/printing total from an empty searchResults, and only
compute/display the aggregated total from data.pages (or from searchResults)
once isLoading is false (or data exists); ensure similar fixes apply to the
other occurrences flagged (lines ~100-104) where total or empty-state is derived
without checking isLoading.
src/app/(main)/search/SearchPageClient.tsx-21-25 (1)

21-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Allow empty submissions to clear the active search.

After the user clears the input, pressing Enter does nothing, so the old q param stays in the URL and the previous results remain on screen. Route to /search when the trimmed query is empty.

Proposed fix
 const handleSearch = () => {
-  if (searchValue.trim()) {
-    router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`);
-  }
+  const nextQuery = searchValue.trim();
+  router.push(nextQuery ? `/search?q=${encodeURIComponent(nextQuery)}` : "/search");
 };
🤖 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)/search/SearchPageClient.tsx around lines 21 - 25,
handleSearch currently only routes when searchValue.trim() is non-empty so
clearing the input and pressing Enter leaves the old q param; update
handleSearch to call router.push('/search') when searchValue.trim() is empty
(and keep router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`)
when non-empty) so the URL and displayed results are cleared; modify the logic
in the handleSearch function that uses searchValue and router.push accordingly.
src/app/(main)/groups/[id]/bookcase/BookcasePageClient.tsx-76-82 (1)

76-82: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Use the same positive/finite guard you already use for the query.

Right now only NaN is rejected here. Values like 0, -1, or Infinity skip fetching but still render the empty-state message instead of an invalid-route message.

Suggested fix
-  if (Number.isNaN(groupId)) {
+  if (!Number.isFinite(groupId) || groupId <= 0) {
     return (
       <div className="w-full flex flex-col gap-[24px] text-Gray-7 body_1_2">
         잘못된 모임 ID
       </div>
     );
   }
🤖 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)/groups/[id]/bookcase/BookcasePageClient.tsx around lines 76 -
82, The current guard in BookcasePageClient.tsx only checks
Number.isNaN(groupId) so values like 0, negative numbers or Infinity still pass
and render the wrong empty-state; change the validation to the same
positive/finite check used by the query: replace the NaN check with a guard like
if (!(Number.isFinite(groupId) && groupId > 0)) and return the "잘못된 모임 ID"
invalid-route UI when that condition is true (update the check around groupId in
the BookcasePageClient component).
src/app/(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx-104-112 (1)

104-112: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Normalize invalid tab query values.

When tab is present but unsupported, the UI falls back to "meeting" while the URL keeps the bad value. That leaves copied/reloaded links in a broken state. Treat unknown values the same as a missing param and replace them with tab=meeting.

🤖 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)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx around
lines 104 - 112, The useEffect handling searchParams currently ignores invalid
tab values in the URL; when searchParams.get("tab") returns a value that is not
isTabKey(tab) you should treat it like a missing param: setActiveTab("meeting"),
create a new URLSearchParams from searchParams, set("tab","meeting"), and call
router.replace(`${pathname}?${next.toString()}`, { scroll: false }) so the URL
is normalized; keep the existing branch that only sets activeTab for valid tabs
but add this branch for the invalid-tab case (use the same symbols: useEffect,
searchParams, isTabKey, activeTab, router.replace, pathname).
src/app/(main)/groups/[id]/notice/NoticePageClient.tsx-43-49 (1)

43-49: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Avoid local-time parsing for notice dates.

new Date(iso) shifts the calendar day for UTC timestamps near midnight. The detail page formats dates by slicing the server string, so list/detail can disagree by one day.

🤖 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)/groups/[id]/notice/NoticePageClient.tsx around lines 43 - 49,
The formatDate function currently parses ISO timestamps with new Date(iso) which
converts to local time and can shift the day; update formatDate (in
NoticePageClient.tsx) to format the server date in UTC (e.g., use Date methods
that read UTC like getUTCFullYear/getUTCMonth/getUTCDate) or simply derive the
yyyy-mm-dd from the ISO string (iso.slice(0,10)) and replace '-' with '.' so
list and detail pages always use the server date without local-time shifts.
src/app/(main)/profile/mypage/MypageClient.tsx-15-22 (1)

15-22: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep tab and activeTab synchronized.

This page initializes from ?tab=..., but later tab changes never update the URL. After switching tabs, refresh/bookmark/share will restore the wrong section. Either mirror activeTab back into the query string or derive the rendered tab directly from searchParams.

Also applies to: 36-40

🤖 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)/profile/mypage/MypageClient.tsx around lines 15 - 22, The tab
state is initialized from useSearchParams but later changes to activeTab (const
[activeTab, setActiveTab]) don't update the URL, so sharing/refresh loses the
current tab; fix by synchronizing activeTab and the query param: either derive
the rendered tab directly from useSearchParams (remove local state) or keep
activeTab but add an effect that updates searchParams when activeTab changes
(use setSearchParams or router.replace) and also listen for searchParams changes
to update activeTab (useEffect reading useSearchParams). Update the logic around
initialTab, activeTab, setActiveTab and any handlers that change tabs (also
apply the same pattern referenced around lines 36-40) so the URL and component
state remain consistent.
src/app/(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx-20-21 (1)

20-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clamp tab to the supported values.

The cast hides invalid query strings from TypeScript, but ?tab=foo still reaches runtime and leaves activeTab unsupported, which disables both list queries. Parse the param explicitly and fall back to "follower" instead.

Suggested fix
-    const initialTab = (searchParams?.get("tab") as "follower" | "following") || "follower";
+    const tabParam = searchParams?.get("tab");
+    const initialTab: "follower" | "following" =
+        tabParam === "following" ? "following" : "follower";
🤖 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)/profile/[nickname]/follows/NicknameFollowsClient.tsx around
lines 20 - 21, The code currently casts the query param directly which allows
invalid values to reach runtime; update the logic that computes initialTab (and
thus activeTab) to explicitly read searchParams.get("tab"), check if it's
exactly "follower" or "following", and only then use it—otherwise default to
"follower"; change the initializer around initialTab/activeTab (the variables
initialTab, activeTab, setActiveTab, and the useState call in
NicknameFollowsClient) to perform this explicit clamp so only the two supported
values are used at runtime.
src/app/(main)/profile/mypage/follows/MypageFollowsClient.tsx-18-19 (1)

18-19: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the incoming tab query value.

The direct cast accepts any string at runtime, so ?tab=foo can leave activeTab outside the supported union and break which list/query branch runs. Normalize it to "follower" or "following" before seeding state.

Suggested fix
-    const initialTab = (searchParams?.get("tab") as "follower" | "following") || "follower";
+    const tabParam = searchParams?.get("tab");
+    const initialTab: "follower" | "following" =
+        tabParam === "following" ? "following" : "follower";
🤖 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)/profile/mypage/follows/MypageFollowsClient.tsx around lines
18 - 19, The code seeds activeTab by directly casting searchParams.get("tab") to
the union which lets invalid strings slip through; update the initialization to
validate/normalize the incoming value from searchParams.get("tab") so only
"follower" or "following" are accepted (otherwise default to "follower"). Do
this by checking the raw string (from searchParams.get("tab")) against the two
allowed values (or by adding a small helper like normalizeTab) and use the
validated result when calling useState for activeTab (and likewise use the same
validation whenever parsing the tab query elsewhere).
src/app/(main)/books/[id]/page.tsx-19-23 (1)

19-23: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Preserve shared Open Graph fields when overriding here.

Next.js App Router metadata merging is shallow: when src/app/(main)/books/[id]/page.tsx defines an openGraph object in generateMetadata, it replaces the parent/root openGraph object for this route (not deep/recursive merged). This will drop shared fields like siteName, locale, type, url, etc. unless you explicitly merge them. Construct the returned openGraph by spreading parent.openGraph (e.g., images/overrides only) inside generateMetadata.
https://nextjs.org/docs/app/api-reference/functions/generate-metadata

🤖 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)/books/[id]/page.tsx around lines 19 - 23, The route-level
generateMetadata is replacing the parent openGraph object and dropping shared
fields; update generateMetadata in src/app/(main)/books/[id]/page.tsx to merge
the parent metadata by spreading parent.openGraph into the returned openGraph
and then override only the book-specific fields (e.g., images, title,
description) so shared keys like siteName, locale, type, and url are preserved.
🧹 Nitpick comments (4)
src/app/robots.ts (2)

24-24: 💤 Low value

Hardcoded sitemap URL matches production domain.

The sitemap URL is set to the production domain. This is consistent with the metadataBase in the root layout, but note that it won't adapt to dev/staging environments if needed.

🤖 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/robots.ts` at line 24, The robots configuration hardcodes sitemap:
"https://www.checkmo.co.kr/sitemap.xml", which ties it to production; update the
sitemap value to derive from the app's runtime base URL (e.g., reuse
metadataBase from the root layout or construct from an environment/runtime
variable) so it adapts for dev/staging; locate the sitemap property in
src/app/robots.ts and replace the literal URL with a dynamic value built from
the app's metadataBase or process/env variable (ensuring a trailing / and
appending "sitemap.xml") to produce the correct full URL at runtime.

9-21: Clarify wildcard handling for disallow entries in MetadataRoute.Robots

  • Next.js MetadataRoute.Robots outputs each disallow string verbatim into robots.txt as a Disallow: line (no conversion/sanitization of /groups/*/admin or /stories/*/edit).
  • Robots.txt wildcard (*) semantics are not standardized across crawlers, so these patterns may not be interpreted as intended; validate against the specific search engines/crawlers you care about.
🤖 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/robots.ts` around lines 9 - 21, The disallow array in the
MetadataRoute.Robots configuration (the disallow property in src/app/robots.ts)
contains literal wildcard patterns like "/groups/*/admin" and "/stories/*/edit"
which Next.js will emit verbatim into robots.txt and may not be honored by
crawlers; update the disallow entries to only use concrete path prefixes or
validated patterns supported by your target crawlers (e.g., replace
"/groups/*/admin" with "/groups/" or explicit paths, or generate multiple
concrete Disallow entries), or add a comment and tests explaining which crawlers
will interpret "*" if you intend to keep them; locate and modify the disallow
array in the MetadataRoute.Robots definition to apply the chosen approach.
src/app/landing/LandingPageClient.tsx (1)

16-18: 💤 Low value

Consider if overwriting the timestamp on every visit is intentional.

The useEffect writes a new timestamp to localStorage on every mount. If the intent is to track only the first visit, add a guard:

if (!localStorage.getItem(LANDING_KEY)) {
  localStorage.setItem(LANDING_KEY, String(Date.now()));
}

If tracking every visit is intentional, the current implementation is correct.

🤖 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/landing/LandingPageClient.tsx` around lines 16 - 18, The current
useEffect in LandingPageClient.tsx unconditionally calls
localStorage.setItem(LANDING_KEY, String(Date.now())) on every mount; decide
whether you want first-visit-only behavior and, if so, change the effect to
check localStorage.getItem(LANDING_KEY) before setting it (i.e., only write the
timestamp if the key is missing); otherwise leave the useEffect as-is if
overwriting on every visit is intentional.
src/app/layout.tsx (1)

25-25: 💤 Low value

Consider using an environment variable for metadataBase.

The metadataBase URL is hardcoded to the production domain. For dev/staging environments, you might want to use process.env.NEXT_PUBLIC_BASE_URL to support different deployment contexts.

🌐 Optional: make metadataBase environment-aware
-  metadataBase: new URL("https://www.checkmo.co.kr"),
+  metadataBase: new URL(process.env.NEXT_PUBLIC_BASE_URL || "https://www.checkmo.co.kr"),
🤖 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/layout.tsx` at line 25, The metadataBase value in src/app/layout.tsx
is hardcoded to "https://www.checkmo.co.kr"; update metadataBase to read from an
environment variable (e.g. process.env.NEXT_PUBLIC_BASE_URL) with a safe
fallback to the existing production URL so different deployments
(dev/staging/prod) can provide their own base URL; locate the metadataBase
assignment in layout.tsx and replace the literal new URL(...) with logic that
constructs the URL from the env var (or falls back) before passing it to
metadataBase.
🤖 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/`(main)/books/[id]/page.tsx:
- Around line 12-14: generateMetadata currently returns a generic fallback title
for all failures; update the generateMetadata function in
src/app/(main)/books/[id]/page.tsx to call notFound() (imported from
next/navigation) when the response status is 404 (res.status === 404) and also
when the parsed payload has no result (!data?.result), while preserving the
existing fallback behavior only for non-404 transport or server errors (res.ok
=== false && res.status !== 404) so those still return { title: "도서" }.

In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx:
- Around line 245-247: The catch block in BookDetailPageClient.tsx currently
uses catch (e: any) and reads e.message; change it to catch (e: unknown) and
narrow the type before accessing message: check instanceof Error to extract
e.message (or handle string/other types safely, e.g. fallback to String(e) or
JSON.stringify) and then pass that safe msg into toast.error; update the catch
block around the report/error path where the current const msg = e?.message ??
"" is defined.

In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsx:
- Around line 213-220: The memo selector selectedTeamMembers assumes
meetingData.teamMembers exists and calls .find() directly; change the guard to
verify teamMembers is present before using .find (e.g., check meetingData &&
meetingData.teamMembers or use meetingData.teamMembers?.find) so that when
teamMembers is undefined you return [] early; update the logic around
selectedTeamId, matchedGroup and members to safely handle the absent teamMembers
case (matchedGroup?.members ?? []) to avoid runtime crashes.

In `@src/app/`(main)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsx:
- Around line 38-40: isAdmin currently only checks meData?.staff which can miss
users whose club info returns only a status; update the isAdmin computation
(where useClubMeQuery is used and the isAdmin constant is defined) to match
NoticePageClient by treating either meData?.staff truthy OR meData?.myStatus ===
"STAFF" as admin, so admin UI/privileges remain consistent between the list and
detail pages.

In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx:
- Around line 34-41: The empty-state is computed from potentially undefined
data, causing isEmpty to be true while useClubNoticesQuery (noticesQuery) is
loading or errored; update the logic to only compute pinnedNotices, notices and
totalPages when noticesQuery.isSuccess is true (or check noticesQuery.status ===
'success') and otherwise treat the list as unknown (do not show the empty UI),
and add explicit handling for noticesQuery.isLoading (show loader) and
noticesQuery.isError (show error UI); adjust where isEmpty is used so it only
triggers when noticesQuery.isSuccess is true, referencing useClubNoticesQuery,
noticesQuery, pinnedNotices, notices, totalPages and isEmpty to locate the
changes.

In `@src/app/`(main)/groups/create/CreateGroupPageClient.tsx:
- Line 73: The duplicate-name check is using the raw clubName value while the
submission and pre-check logic use clubName.trim(), causing mismatches; compute
a trimmedName = clubName.trim() and use that everywhere: pass trimmedName into
useClubNameCheckQuery (instead of clubName), use trimmedName in the pre-check
conditions that currently reference clubName (lines ~116-124), call
nameQuery.refetch with trimmedName, and include trimmedName in the final
payload/submission (lines ~194-195 and the create/submit handler) so the query
key and submitted value match exactly.

In `@src/app/`(main)/groups/GroupsPageClient.tsx:
- Around line 165-173: In onSubmitSearch, remove the immediate call to
refetchSearch() so the update to appliedParams drives the query lifecycle
instead of triggering a stale request; keep the keyword extraction (q.trim()),
the guard that sets appliedParams to null when empty, and the
setAppliedParams({... outputFilter: mapCategoryToOutputFilter(category),
inputFilter: mapInputFilter(group, region), keyword }), but delete the
refetchSearch() invocation so the new query instance created by the
appliedParams change is the one that runs.

In `@src/app/`(main)/news/NewsPageClient.tsx:
- Around line 100-104: The FloatingFab click handler in NewsPageClient.tsx opens
an external site with window.open(EXTERNAL_LINKS.NEWS_FROM_URL, "_blank") which
allows reverse-tabnabbing; update the onClick to open the URL with the
noopener,noreferrer feature (e.g. window.open(EXTERNAL_LINKS.NEWS_FROM_URL,
"_blank", "noopener,noreferrer")) and optionally set newWindow.opener = null if
a window handle is returned, ensuring FloatingFab's onClick prevents the opened
page from accessing window.opener.

In `@src/app/`(main)/setting/logout/LogoutPageClient.tsx:
- Around line 13-21: The logout flow in performLogout can throw from
authService.logout() and leave the UI stuck; modify performLogout to ensure
queryClient.clear(), router.refresh(), and router.push("/") always run by
wrapping the authService.logout() call in a try/finally (or try/catch/finally)
so that even on error the cleanup and navigation occur; keep error handling
minimal (e.g., log or report the error) but always execute the finally block to
clear queries and navigate using router.refresh() and router.push("/").

In `@src/app/`(main)/setting/notifications/NotificationPageClient.tsx:
- Around line 9-25: The page currently renders toggle controls even when
useNotificationSettingsQuery failed or returned no settings; update the render
logic to check the query's error state and the presence of settings before
showing toggles. Specifically, use the query's isError (or check !settings)
along with isLoading to short-circuit rendering inside NotificationPageClient:
when isError or settings is falsy render an error/empty state message instead of
the toggles, and only render the toggle controls that call handleToggle (which
uses toggleSetting) when settings is defined and valid. Ensure
NotificationSettingType-based toggles read their isChecked from settings safely
(after the existence check) to avoid undefined values.
- Around line 73-79: The “모임 댓글/답글 알림” row is wired to
settings?.clubMeetingCreated and handleToggle("CLUB_MEETING_CREATED") but that
enum/value actually represents regular meeting creation; either (A) update the
row text and description to reflect meeting-creation semantics (e.g., change
title/description to "정기모임 생성 알림"/matching description) so the UI matches
NotificationSettingType CLUB_MEETING_CREATED, or (B) if the intent was to toggle
club comment/reply notifications, add a new NotificationSettingType value (e.g.,
CLUB_COMMENT_REPLY), add a corresponding field to NotificationSettings (e.g.,
clubCommentReply), update backend/schema mapping to persist it, and wire the row
to settings?.clubCommentReply and handleToggle("CLUB_COMMENT_REPLY"); locate and
change the symbols settings?.clubMeetingCreated and
handleToggle("CLUB_MEETING_CREATED") in NotificationPageClient.tsx plus update
NotificationSettingType and NotificationSettings types and any API handlers that
read/write these settings.

In `@src/app/`(main)/setting/password/PasswordChangePageClient.tsx:
- Around line 42-44: The onError handler in PasswordChangePageClient currently
types the error as any and directly accesses error.message; change the parameter
to unknown and add a narrow/guard before reading message (e.g., create a small
type-guard that checks typeof err === "object" && err !== null && "message" in
err && typeof (err as any).message === "string", or detect AxiosError/Fetch
error shapes), then call toast.error with the guarded message or fall back to
the default Korean text; update the onError signature and use the guard inside
the body so no explicit any remains.

In `@src/app/`(main)/setting/SettingsPageClient.tsx:
- Line 46: The map callback uses an explicit any (group.items.map((item: any) =>
...)), bypassing the SETTINGS_MENU as const typing; replace the any with the
concrete inferred type from SETTINGS_MENU, e.g. declare a type alias like
SettingItem = (typeof SETTINGS_MENU)[number]['items'][number] (or inline: (item:
(typeof SETTINGS_MENU)[number]['items'][number]) => ...) and update the map
signature to use that type so TypeScript preserves the literal types and
satisfies `@typescript-eslint/no-explicit-any`.

In `@src/app/`(main)/stories/[id]/edit/StoryEditPageClient.tsx:
- Around line 23-33: The useEffect in StoryEditPageClient sets description after
mount causing an extra render; instead initialize the description state directly
from the loaded story and remove the initialization effect and the
isDescriptionInitialized flag. Replace the useState for description to derive
its initial value from story (e.g., useState(() => story?.description ?? ""))
and delete the useEffect and
isDescriptionInitialized/setIsDescriptionInitialized usage so only the
description and setDescription remain.

In `@src/app/`(main)/stories/[id]/StoryDetailClient.tsx:
- Around line 32-47: The early return in StoryDetailClient causes a hooks-order
violation because hooks (useStoryDetailQuery, useToggleStoryLikeMutation,
useDeleteBookStoryMutation, useToggleFollowMutation, useAuthStore, useState) are
declared after the conditional return; fix it by moving all hook usage and
related state into a new child component (e.g., StoryDetailContent) that accepts
storyId: number, keep StoryDetailClient responsible only for extracting
params/id and doing the early-return check, and then render <StoryDetailContent
storyId={storyId} /> when id is valid; ensure the new component contains the
referenced symbols (useStoryDetailQuery, mutate: toggleLike,
deleteStory/isDeletePending, toggleFollow, isLoggedIn/openLoginModal,
isDeleteModalOpen/setIsDeleteModalOpen) and the rest of the UI logic.

In `@src/app/`(public)/find-account/FindAccountPageClient.tsx:
- Around line 29-34: The code in FindAccountPageClient is leaking PII by putting
data.email into router.push query; instead, before calling router.push in the
findEmail onSuccess handler, store the recovered email in ephemeral client state
(e.g., sessionStorage under a key like "foundEmail" or request an opaque backend
token) and then navigate to the result page without including email in the URL
(change the router.push call in findEmail to route to "/find-account/result"
with no email param). Update the result page client to read the email from
sessionStorage (with a safe fallback if absent) or exchange the opaque token for
the email, and remove any encodeURIComponent(data.email) usage in
FindAccountPageClient.

In `@src/app/`(public)/signup/[step]/SignupStepPageClient.tsx:
- Around line 22-49: The effect in SignupStepPageClient.tsx only sets isSocial
true and never resets it, causing stale social-signup state; update the
useEffect (which reads searchParams.get("isSocial"), computes
urlIsSocial/urlIsSocialFalse, storeIsSocial and shouldBeSocial) to explicitly
setIsSocial(false) when shouldBeSocial is false (and clear any social-derived
email via setEmail if it came from user?.email or searchParams.get("email") to
avoid carrying a social email into normal signup), and keep the existing
router.replace logic for urlIsSocialFalse and profile redirects when
shouldBeSocial is true.

---

Minor comments:
In `@src/app/`(main)/books/[id]/page.tsx:
- Around line 19-23: The route-level generateMetadata is replacing the parent
openGraph object and dropping shared fields; update generateMetadata in
src/app/(main)/books/[id]/page.tsx to merge the parent metadata by spreading
parent.openGraph into the returned openGraph and then override only the
book-specific fields (e.g., images, title, description) so shared keys like
siteName, locale, type, and url are preserved.

In `@src/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx:
- Around line 104-112: The useEffect handling searchParams currently ignores
invalid tab values in the URL; when searchParams.get("tab") returns a value that
is not isTabKey(tab) you should treat it like a missing param:
setActiveTab("meeting"), create a new URLSearchParams from searchParams,
set("tab","meeting"), and call router.replace(`${pathname}?${next.toString()}`,
{ scroll: false }) so the URL is normalized; keep the existing branch that only
sets activeTab for valid tabs but add this branch for the invalid-tab case (use
the same symbols: useEffect, searchParams, isTabKey, activeTab, router.replace,
pathname).

In `@src/app/`(main)/groups/[id]/bookcase/BookcasePageClient.tsx:
- Around line 76-82: The current guard in BookcasePageClient.tsx only checks
Number.isNaN(groupId) so values like 0, negative numbers or Infinity still pass
and render the wrong empty-state; change the validation to the same
positive/finite check used by the query: replace the NaN check with a guard like
if (!(Number.isFinite(groupId) && groupId > 0)) and return the "잘못된 모임 ID"
invalid-route UI when that condition is true (update the check around groupId in
the BookcasePageClient component).

In `@src/app/`(main)/groups/[id]/notice/NoticePageClient.tsx:
- Around line 43-49: The formatDate function currently parses ISO timestamps
with new Date(iso) which converts to local time and can shift the day; update
formatDate (in NoticePageClient.tsx) to format the server date in UTC (e.g., use
Date methods that read UTC like getUTCFullYear/getUTCMonth/getUTCDate) or simply
derive the yyyy-mm-dd from the ISO string (iso.slice(0,10)) and replace '-' with
'.' so list and detail pages always use the server date without local-time
shifts.

In `@src/app/`(main)/news/[id]/NewsDetailClient.tsx:
- Around line 24-33: The carousel uses news.imageUrls.length for rotation but
the UI renders a filtered list, so compute a single renderedImages array (e.g.,
validImageUrls or filteredImageUrls) from news.imageUrls (same filter used
around the render block) and use that array everywhere: in the useEffect guard,
in the setInterval modulo (setCurrentImageIndex(prev => (prev + 1) %
renderedImages.length)), and in the render logic that maps images (instead of
directly using news.imageUrls); also update the effect dependency to
[renderedImages.length] and ensure currentImageIndex is clamped/reset if
renderedImages becomes shorter than the current index.

In `@src/app/`(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx:
- Around line 20-21: The code currently casts the query param directly which
allows invalid values to reach runtime; update the logic that computes
initialTab (and thus activeTab) to explicitly read searchParams.get("tab"),
check if it's exactly "follower" or "following", and only then use it—otherwise
default to "follower"; change the initializer around initialTab/activeTab (the
variables initialTab, activeTab, setActiveTab, and the useState call in
NicknameFollowsClient) to perform this explicit clamp so only the two supported
values are used at runtime.

In `@src/app/`(main)/profile/mypage/follows/MypageFollowsClient.tsx:
- Around line 18-19: The code seeds activeTab by directly casting
searchParams.get("tab") to the union which lets invalid strings slip through;
update the initialization to validate/normalize the incoming value from
searchParams.get("tab") so only "follower" or "following" are accepted
(otherwise default to "follower"). Do this by checking the raw string (from
searchParams.get("tab")) against the two allowed values (or by adding a small
helper like normalizeTab) and use the validated result when calling useState for
activeTab (and likewise use the same validation whenever parsing the tab query
elsewhere).

In `@src/app/`(main)/profile/mypage/MypageClient.tsx:
- Around line 15-22: The tab state is initialized from useSearchParams but later
changes to activeTab (const [activeTab, setActiveTab]) don't update the URL, so
sharing/refresh loses the current tab; fix by synchronizing activeTab and the
query param: either derive the rendered tab directly from useSearchParams
(remove local state) or keep activeTab but add an effect that updates
searchParams when activeTab changes (use setSearchParams or router.replace) and
also listen for searchParams changes to update activeTab (useEffect reading
useSearchParams). Update the logic around initialTab, activeTab, setActiveTab
and any handlers that change tabs (also apply the same pattern referenced around
lines 36-40) so the URL and component state remain consistent.

In `@src/app/`(main)/search/SearchPageClient.tsx:
- Around line 33-39: The total-count UI is showing "총 0개" during the initial
fetch because useInfiniteBookSearchQuery's isLoading is ignored and
searchResults is computed empty; update the render logic in SearchPageClient
(the area using useInfiniteBookSearchQuery, searchResults, and total count) to
respect isLoading: when isLoading is true show a loading state (or suppress the
total count) instead of computing/printing total from an empty searchResults,
and only compute/display the aggregated total from data.pages (or from
searchResults) once isLoading is false (or data exists); ensure similar fixes
apply to the other occurrences flagged (lines ~100-104) where total or
empty-state is derived without checking isLoading.
- Around line 21-25: handleSearch currently only routes when searchValue.trim()
is non-empty so clearing the input and pressing Enter leaves the old q param;
update handleSearch to call router.push('/search') when searchValue.trim() is
empty (and keep
router.push(`/search?q=${encodeURIComponent(searchValue.trim())}`) when
non-empty) so the URL and displayed results are cleared; modify the logic in the
handleSearch function that uses searchValue and router.push accordingly.

In `@src/app/`(public)/find-password/FindPasswordPageClient.tsx:
- Around line 21-27: The handler handleSendTempPassword currently allows
whitespace-only or malformed emails to reach sendTempPassword; update it to trim
the email (const normalizedEmail = email?.trim()) and run a basic email format
check (e.g., simple regex) before calling sendTempPassword; if normalizedEmail
is empty or fails validation, call toast.error("이메일을 입력해주세요." or a
format-specific message) and return, otherwise call
sendTempPassword(normalizedEmail, {...}) so the mutation always receives a
normalized, validated address.

---

Nitpick comments:
In `@src/app/landing/LandingPageClient.tsx`:
- Around line 16-18: The current useEffect in LandingPageClient.tsx
unconditionally calls localStorage.setItem(LANDING_KEY, String(Date.now())) on
every mount; decide whether you want first-visit-only behavior and, if so,
change the effect to check localStorage.getItem(LANDING_KEY) before setting it
(i.e., only write the timestamp if the key is missing); otherwise leave the
useEffect as-is if overwriting on every visit is intentional.

In `@src/app/layout.tsx`:
- Line 25: The metadataBase value in src/app/layout.tsx is hardcoded to
"https://www.checkmo.co.kr"; update metadataBase to read from an environment
variable (e.g. process.env.NEXT_PUBLIC_BASE_URL) with a safe fallback to the
existing production URL so different deployments (dev/staging/prod) can provide
their own base URL; locate the metadataBase assignment in layout.tsx and replace
the literal new URL(...) with logic that constructs the URL from the env var (or
falls back) before passing it to metadataBase.

In `@src/app/robots.ts`:
- Line 24: The robots configuration hardcodes sitemap:
"https://www.checkmo.co.kr/sitemap.xml", which ties it to production; update the
sitemap value to derive from the app's runtime base URL (e.g., reuse
metadataBase from the root layout or construct from an environment/runtime
variable) so it adapts for dev/staging; locate the sitemap property in
src/app/robots.ts and replace the literal URL with a dynamic value built from
the app's metadataBase or process/env variable (ensuring a trailing / and
appending "sitemap.xml") to produce the correct full URL at runtime.
- Around line 9-21: The disallow array in the MetadataRoute.Robots configuration
(the disallow property in src/app/robots.ts) contains literal wildcard patterns
like "/groups/*/admin" and "/stories/*/edit" which Next.js will emit verbatim
into robots.txt and may not be honored by crawlers; update the disallow entries
to only use concrete path prefixes or validated patterns supported by your
target crawlers (e.g., replace "/groups/*/admin" with "/groups/" or explicit
paths, or generate multiple concrete Disallow entries), or add a comment and
tests explaining which crawlers will interpret "*" if you intend to keep them;
locate and modify the disallow array in the MetadataRoute.Robots definition to
apply the chosen approach.
🪄 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: ec016563-5c9f-482d-a92a-90ed59c65def

📥 Commits

Reviewing files that changed from the base of the PR and between c69e530 and 77c1540.

⛔ Files ignored due to path filters (1)
  • public/og.png is excluded by !**/*.png
📒 Files selected for processing (85)
  • src/app/(main)/books/[id]/BookDetailClient.tsx
  • src/app/(main)/books/[id]/page.tsx
  • src/app/(main)/groups/GroupsPageClient.tsx
  • src/app/(main)/groups/[id]/GroupDetailClient.tsx
  • src/app/(main)/groups/[id]/bookcase/BookcasePageClient.tsx
  • src/app/(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx
  • src/app/(main)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsx
  • src/app/(main)/groups/[id]/bookcase/[bookId]/meeting/page.tsx
  • src/app/(main)/groups/[id]/bookcase/[bookId]/page.tsx
  • src/app/(main)/groups/[id]/bookcase/page.tsx
  • src/app/(main)/groups/[id]/notice/NoticePageClient.tsx
  • src/app/(main)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsx
  • src/app/(main)/groups/[id]/notice/[noticeId]/page.tsx
  • src/app/(main)/groups/[id]/notice/page.tsx
  • src/app/(main)/groups/[id]/page.tsx
  • src/app/(main)/groups/create/CreateGroupPageClient.tsx
  • src/app/(main)/groups/create/page.tsx
  • src/app/(main)/groups/page.tsx
  • src/app/(main)/home/HomePageClient.tsx
  • src/app/(main)/home/page.tsx
  • src/app/(main)/news/NewsPageClient.tsx
  • src/app/(main)/news/[id]/NewsDetailClient.tsx
  • src/app/(main)/news/[id]/page.tsx
  • src/app/(main)/news/page.tsx
  • src/app/(main)/profile/[nickname]/ProfileClient.tsx
  • src/app/(main)/profile/[nickname]/follows/NicknameFollowsClient.tsx
  • src/app/(main)/profile/[nickname]/follows/page.tsx
  • src/app/(main)/profile/[nickname]/page.tsx
  • src/app/(main)/profile/mypage/MypageClient.tsx
  • src/app/(main)/profile/mypage/follows/MypageFollowsClient.tsx
  • src/app/(main)/profile/mypage/follows/page.tsx
  • src/app/(main)/profile/mypage/layout.tsx
  • src/app/(main)/profile/mypage/page.tsx
  • src/app/(main)/search/SearchPageClient.tsx
  • src/app/(main)/search/page.tsx
  • src/app/(main)/setting/SettingsLayoutClient.tsx
  • src/app/(main)/setting/SettingsPageClient.tsx
  • src/app/(main)/setting/account-status/AccountStatusPageClient.tsx
  • src/app/(main)/setting/account-status/page.tsx
  • src/app/(main)/setting/block/BlockPageClient.tsx
  • src/app/(main)/setting/block/page.tsx
  • src/app/(main)/setting/email/EmailChangePageClient.tsx
  • src/app/(main)/setting/email/page.tsx
  • src/app/(main)/setting/layout.tsx
  • src/app/(main)/setting/logout/LogoutPageClient.tsx
  • src/app/(main)/setting/logout/page.tsx
  • src/app/(main)/setting/news/MyNewsPageClient.tsx
  • src/app/(main)/setting/news/page.tsx
  • src/app/(main)/setting/notifications/NotificationPageClient.tsx
  • src/app/(main)/setting/notifications/page.tsx
  • src/app/(main)/setting/page.tsx
  • src/app/(main)/setting/password/PasswordChangePageClient.tsx
  • src/app/(main)/setting/password/page.tsx
  • src/app/(main)/setting/profile/ProfileEditPageClient.tsx
  • src/app/(main)/setting/profile/page.tsx
  • src/app/(main)/setting/report/ReportPageClient.tsx
  • src/app/(main)/setting/report/page.tsx
  • src/app/(main)/setting/support/SupportPageClient.tsx
  • src/app/(main)/setting/support/page.tsx
  • src/app/(main)/setting/terms/page.tsx
  • src/app/(main)/setting/version/page.tsx
  • src/app/(main)/stories/StoriesPageClient.tsx
  • src/app/(main)/stories/[id]/StoryDetailClient.tsx
  • src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx
  • src/app/(main)/stories/[id]/edit/page.tsx
  • src/app/(main)/stories/[id]/page.tsx
  • src/app/(main)/stories/new/StoryNewPageClient.tsx
  • src/app/(main)/stories/new/page.tsx
  • src/app/(main)/stories/page.tsx
  • src/app/(public)/find-account/FindAccountPageClient.tsx
  • src/app/(public)/find-account/page.tsx
  • src/app/(public)/find-account/result/FindAccountResultPageClient.tsx
  • src/app/(public)/find-account/result/page.tsx
  • src/app/(public)/find-password/FindPasswordPageClient.tsx
  • src/app/(public)/find-password/page.tsx
  • src/app/(public)/signup/[step]/SignupStepPageClient.tsx
  • src/app/(public)/signup/[step]/page.tsx
  • src/app/(public)/signup/page.tsx
  • src/app/landing/LandingPageClient.tsx
  • src/app/landing/page.tsx
  • src/app/layout.tsx
  • src/app/robots.ts
  • src/components/base-ui/Landing/LandingChatSection.tsx
  • src/components/base-ui/Landing/LandingHero.tsx
  • src/components/base-ui/Landing/LandingNav.tsx

Comment on lines +12 to +14
if (!res.ok) return { title: "도서" };
const data = await res.json();
if (!data?.result) return { title: "도서" };

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
set -euo pipefail

# Inspect the target file and relevant surrounding code
FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,120p'

# Extract any generateMetadata / notFound usage in this file
echo
echo "== generateMetadata usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true

# Show any other notFound usage in related files under same route
echo
echo "== Related route files (if any) =="
ls -la "src/app/(main)/books/[id]" || true
for f in src/app/(main)/books/[id]/page.tsx src/app/(main)/books/[id]/layout.tsx src/app/(main)/books/[id]/loading.tsx src/app/(main)/books/[id]/error.tsx 2>/dev/null; do
  if [ -f "$f" ]; then
    echo
    echo "---- $f ----"
    rg -n "generateMetadata|notFound\\(" "$f" || true
  fi
done

Repository: checkmo2025/FE

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,200p'

echo
echo "== generateMetadata / notFound usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true

echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || true

Repository: checkmo2025/FE

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/books/[id]/page.tsx"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE" >&2
  exit 1
fi

echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,200p'

echo
echo "== generateMetadata / notFound usage =="
rg -n "generateMetadata|notFound\\(" "$FILE" || true

echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || true

Repository: checkmo2025/FE

Length of output: 205


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/books/[id]/page.tsx"

echo "== Basic checks =="
ls -la "$FILE"
wc -l "$FILE"

echo
echo "== File contents (with line numbers) =="
awk '{printf "%4d %s\n", NR, $0}' "$FILE" | sed -n '1,200p'

echo
echo "== Route directory listing =="
ls -la "src/app/(main)/books/[id]" || true

echo
echo "== Search for generateMetadata / notFound in this route =="
rg -n "generateMetadata|notFound\\(" "src/app/(main)/books/[id]" || true

Repository: checkmo2025/FE

Length of output: 1803


🌐 Web query:

Next.js App Router notFound() inside generateMetadata support

💡 Result:

Yes—next.js App Router supports calling notFound inside generateMetadata. 1) What the official docs say The generateMetadata API reference explicitly states that Next.js “redirect and notFound Next.js methods can also be used inside generateMetadata.” [1] 2) Practical implication (what you’ll see) When you trigger notFound from generateMetadata, Next.js can still resolve metadata/streaming behavior in a way that may not always produce the HTTP 404 status in every streaming configuration. Community discussions/issues report scenarios where a 200 response is returned when streaming boundaries/loader components are present. [2] (So “notFound inside generateMetadata is supported,” but “it will always produce an HTTP 404 status code” is not guaranteed in all cases.) [2] 3) If you’re trying to put metadata on not-found pages There’s an additional nuance: exporting metadata/generateMetadata from nested not-found boundaries was addressed as a feature in the Next.js repo (“support metadata/generateMetadata in nested not-found pages”). [3] Recommended pattern - Use notFound inside generateMetadata when the metadata-producing data doesn’t exist. [1] - If you require strict 404 HTTP semantics for SEO/crawlers, verify the response status in your specific routing/layout/loading setup (especially when streaming is involved). [2] If you share your app structure (where the dynamic segment is, and where loading.tsx/layout.tsx are), I can tell you what behavior to expect for HTTP status vs metadata rendering in that exact setup.

Citations:


Return a real 404 for missing books.
src/app/(main)/books/[id]/page.tsx’s generateMetadata currently returns { title: "도서" } for any !res.ok and for empty !data?.result, so missing/invalid /books/[id] won’t trigger Next.js’s not-found flow until the client handles the error. Call notFound() for explicit res.status === 404 and for empty/absent results, and keep the fallback for non-404 and transport failures. (nextjs.org)

Suggested direction
+import { notFound } from "next/navigation";
 import type { Metadata } from "next";
 import BookDetailClient from "./BookDetailClient";
@@
   try {
     const res = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/books/${id}`, {
       next: { revalidate: 3600 },
     });
-    if (!res.ok) return { title: "도서" };
+    if (res.status === 404) notFound();
+    if (!res.ok) return { title: "도서" };
     const data = await res.json();
-    if (!data?.result) return { title: "도서" };
+    if (!data?.result) notFound();
🤖 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)/books/[id]/page.tsx around lines 12 - 14, generateMetadata
currently returns a generic fallback title for all failures; update the
generateMetadata function in src/app/(main)/books/[id]/page.tsx to call
notFound() (imported from next/navigation) when the response status is 404
(res.status === 404) and also when the parsed payload has no result
(!data?.result), while preserving the existing fallback behavior only for
non-404 transport or server errors (res.ok === false && res.status !== 404) so
those still return { title: "도서" }.

Comment on lines +245 to +247
} catch (e: any) {
const msg = e?.message ?? "";
toast.error(msg || "신고 접수에 실패했습니다.");

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

Drop the explicit any in the report error path.

This is the ESLint error already flagged for the file and can block merge if lint runs in CI. Catch as unknown and narrow before reading message.

🧰 Tools
🪛 ESLint

[error] 245-245: 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/app/`(main)/groups/[id]/bookcase/[bookId]/BookDetailPageClient.tsx around
lines 245 - 247, The catch block in BookDetailPageClient.tsx currently uses
catch (e: any) and reads e.message; change it to catch (e: unknown) and narrow
the type before accessing message: check instanceof Error to extract e.message
(or handle string/other types safely, e.g. fallback to String(e) or
JSON.stringify) and then pass that safe msg into toast.error; update the catch
block around the report/error path where the current const msg = e?.message ??
"" is defined.

Comment on lines +213 to +220
const selectedTeamMembers = useMemo(() => {
if (!meetingData || selectedTeamId === null) return [];

const matchedGroup = meetingData.teamMembers.find(
(group) => group.teamKey.teamId === selectedTeamId
);

return matchedGroup?.members ?? [];

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

Guard teamMembers before calling .find().

Earlier in this component meetingData.teamMembers is treated as optional, but Line 216 assumes it is always defined. A response without teamMembers will crash the page here instead of showing the empty-team fallback.

🤖 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)/groups/[id]/bookcase/[bookId]/meeting/MeetingPageClient.tsx
around lines 213 - 220, The memo selector selectedTeamMembers assumes
meetingData.teamMembers exists and calls .find() directly; change the guard to
verify teamMembers is present before using .find (e.g., check meetingData &&
meetingData.teamMembers or use meetingData.teamMembers?.find) so that when
teamMembers is undefined you return [] early; update the logic around
selectedTeamId, matchedGroup and members to safely handle the absent teamMembers
case (matchedGroup?.members ?? []) to avoid runtime crashes.

Comment on lines +38 to +40
const { data: meData } = useClubMeQuery(clubId);

const isAdmin = !!meData?.staff;

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

Use the same admin check as the notice list page.

Here isAdmin only looks at meData?.staff, but NoticePageClient also falls back to me.myStatus === "STAFF". If this hook returns only myStatus, admins lose edit/comment privileges on the detail page while still seeing admin UI on the list page.

🤖 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)/groups/[id]/notice/[noticeId]/NoticeDetailPageClient.tsx
around lines 38 - 40, isAdmin currently only checks meData?.staff which can miss
users whose club info returns only a status; update the isAdmin computation
(where useClubMeQuery is used and the isAdmin constant is defined) to match
NoticePageClient by treating either meData?.staff truthy OR meData?.myStatus ===
"STAFF" as admin, so admin UI/privileges remain consistent between the list and
detail pages.

Comment on lines +34 to +41
const noticesQuery = useClubNoticesQuery(clubId, currentPage);
const data = noticesQuery.data;

const pinnedNotices = data?.pinnedNotices ?? [];
const notices = data?.normalNotices.notices ?? [];
const totalPages = Math.max(data?.normalNotices.totalPages ?? 0, 1);

const isEmpty = pinnedNotices.length === 0 && notices.length === 0;

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

Don't render the empty state before the query settles.

data is undefined during loading and on errors, so isEmpty becomes true and the page shows “아직 등록된 공지사항이 없습니다.” even when notices are still loading or the request failed. Gate the empty state on a successful fetch and add an explicit error state.

Also applies to: 74-80

🤖 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)/groups/[id]/notice/NoticePageClient.tsx around lines 34 - 41,
The empty-state is computed from potentially undefined data, causing isEmpty to
be true while useClubNoticesQuery (noticesQuery) is loading or errored; update
the logic to only compute pinnedNotices, notices and totalPages when
noticesQuery.isSuccess is true (or check noticesQuery.status === 'success') and
otherwise treat the list as unknown (do not show the empty UI), and add explicit
handling for noticesQuery.isLoading (show loader) and noticesQuery.isError (show
error UI); adjust where isEmpty is used so it only triggers when
noticesQuery.isSuccess is true, referencing useClubNoticesQuery, noticesQuery,
pinnedNotices, notices, totalPages and isEmpty to locate the changes.


{/* 세부 메뉴 리스트 (Frame 2087328520) */}
<div className="flex flex-col items-start gap-[2px] self-stretch">
{group.items.map((item: any) => {

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
set -euo pipefail

# Locate the target file and show the exact line(s) around 46
FILE="src/app/(main)/setting/SettingsPageClient.tsx"
echo "== File exists? =="
ls -l "$FILE" || true
echo

echo "== Context around line 46 =="
nl -ba "$FILE" | sed -n '35,65p'
echo

# Find definition of SETTINGS_MENU (and any related types) in the repo
echo "== Find SETTINGS_MENU definition/usages =="
rg -n "SETTINGS_MENU" -S . --hidden || true
echo

# Show the file(s) where SETTINGS_MENU is defined (best effort: first matches)
# If multiple definitions, show the first few contexts.
matches=$(rg -n "SETTINGS_MENU" -S . --hidden | head -n 20 || true)
echo "== First SETTINGS_MENU matches (preview) =="
echo "$matches"
echo

# Also check whether the lint rule is configured
echo "== Check for `@typescript-eslint/no-explicit-any` configuration =="
rg -n "no-explicit-any" -S . --hidden || true
echo

# If ESLint config files exist, print them (lightly) for rule context
for f in .eslintrc* eslint.config.* package.json .*/eslint.config.*; do
  if [ -f "$f" ]; then echo "== $f =="; nl -ba "$f" | head -n 120; echo; fi
done

Repository: checkmo2025/FE

Length of output: 264


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/setting/SettingsPageClient.tsx"

echo "== Context around line 46 (using cat -n) =="
# show a small window around the referenced line
cat -n "$FILE" | sed -n '35,65p'
echo

echo "== Find SETTINGS_MENU definition/usages =="
rg -n "SETTINGS_MENU" -S . --hidden || true
echo

echo "== Check for `@typescript-eslint/no-explicit-any` configuration =="
rg -n "no-explicit-any" -S . --hidden || true
echo

echo "== Look for ESLint config files (light preview) =="
for f in .eslintrc* eslint.config.* package.json .*/eslint.config.*; do
  if [ -f "$f" ]; then
    echo "== $f =="
    cat -n "$f" | head -n 120
    echo
  fi
done

Repository: checkmo2025/FE

Length of output: 4733


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# 1) Inspect SETTINGS_MENU definition
FILE_CONST="src/constants/setting/setting.ts"
echo "== SETTINGS_MENU definition =="
wc -l "$FILE_CONST"
sed -n '1,220p' "$FILE_CONST"
echo

# 2) Inspect SettingsPageClient around imports + the mapping block
FILE_PAGE="src/app/(main)/setting/SettingsPageClient.tsx"
echo "== SettingsPageClient imports + surrounding context =="
wc -l "$FILE_PAGE"
sed -n '1,120p' "$FILE_PAGE"
echo

# 3) Search for other explicit-any in repo to gauge lint usage
echo "== Search for ': any' occurrences =="
rg -n --hidden ":\s*any\b" src | head -n 50 || true
echo

# 4) Check whether this file (or project) disables the rule anywhere
echo "== Search for eslint-disable-next-line `@typescript-eslint/no-explicit-any` =="
rg -n --hidden "eslint-disable-next-line\s+@typescript-eslint/no-explicit-any" src || true

Repository: checkmo2025/FE

Length of output: 8302


🌐 Web query:

eslint-config-next typescript no-explicit-any severity

💡 Result:

In eslint-config-next’s TypeScript setup, the TypeScript ESLint rule that controls “no-explicit-any” is @typescript-eslint/no-explicit-any. The rule’s ESLint severity is defined by how it’s configured in the active config (e.g., next/typescript extends plugin:@typescript-eslint/recommended). The typescript-eslint docs describe the rule and show it being set to "error" as an example configuration: "@typescript-eslint/no-explicit-any": "error" [1]. So, the severity you will see when using eslint-config-next/typescript (the default template behavior) is "error" for @typescript-eslint/no-explicit-any, because the underlying rules come from plugin:@typescript-eslint/recommended [2].

Citations:


Remove explicit any from settings menu item mapping

In src/app/(main)/setting/SettingsPageClient.tsx line 46, group.items.map((item: any) => bypasses the SETTINGS_MENU as const typing and trips @typescript-eslint/no-explicit-any.

Proposed fix
-              {group.items.map((item: any) => {
+              {group.items.map((item) => {
+type SettingMenuItem = (typeof SETTINGS_MENU)[number]["items"][number];
...
-              {group.items.map((item) => {
+              {group.items.map((item: SettingMenuItem) => {
📝 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
{group.items.map((item: any) => {
{group.items.map((item) => {
🧰 Tools
🪛 ESLint

[error] 46-46: 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/app/`(main)/setting/SettingsPageClient.tsx at line 46, The map callback
uses an explicit any (group.items.map((item: any) => ...)), bypassing the
SETTINGS_MENU as const typing; replace the any with the concrete inferred type
from SETTINGS_MENU, e.g. declare a type alias like SettingItem = (typeof
SETTINGS_MENU)[number]['items'][number] (or inline: (item: (typeof
SETTINGS_MENU)[number]['items'][number]) => ...) and update the map signature to
use that type so TypeScript preserves the literal types and satisfies
`@typescript-eslint/no-explicit-any`.

Comment on lines +23 to +33
const [description, setDescription] = useState("");
const [isDescriptionInitialized, setIsDescriptionInitialized] = useState(false);

// 초기 description 세팅 (데이터 로드 후 한 번만)
useEffect(() => {
if (story && !isDescriptionInitialized) {
setDescription(story.description);
setIsDescriptionInitialized(true);
}
}, [story, isDescriptionInitialized]);

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
set -euo pipefail

# Locate and print the relevant file section
FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
if [ ! -f "$FILE" ]; then
  echo "Missing file: $FILE"
  exit 1
fi

echo "== File: $FILE =="
wc -l "$FILE"
nl -ba "$FILE" | sed -n '1,120p'

Repository: checkmo2025/FE

Length of output: 250


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
echo "== File: $FILE =="
wc -l "$FILE"

echo "== Lines 1-120 =="
sed -n '1,120p' "$FILE" | cat -n | head -n 120

echo "== Lines 15-60 =="
sed -n '15,60p' "$FILE" | cat -n | head -n 60

Repository: checkmo2025/FE

Length of output: 6854


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "setDescription|onChange=.*setDescription|description\b" "src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"

Repository: checkmo2025/FE

Length of output: 477


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "set-state-in-effect" . || true
rg -n "react-hooks" .eslintrc* package.json */.eslintrc* 2>/dev/null || true
ls -la | head

Repository: checkmo2025/FE

Length of output: 1765


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Search lint rule definitions
rg -n "set-state-in-effect|react-hooks" eslint.config.mjs package.json . || true

# Show relevant config sections
echo "== eslint.config.mjs (top 200 lines) =="
sed -n '1,200p' eslint.config.mjs | cat -n

Repository: checkmo2025/FE

Length of output: 2284


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx"
echo "== Relevant input usage (lines 120-190) =="
sed -n '120,210p' "$FILE" | cat -n | head -n 120

# Find and show useStoryDetailQuery implementation
echo "== Locating useStoryDetailQuery =="
rg -n "useStoryDetailQuery" -S src | head -n 20

echo "== Showing hook implementation =="
HOOK_FILE="$(rg -n "export function useStoryDetailQuery|export const useStoryDetailQuery|function useStoryDetailQuery|useStoryDetailQuery\s*=" -S src -g'*.ts' -g'*.tsx' | head -n 1 | cut -d: -f1 || true)"
echo "Hook file: $HOOK_FILE"
if [ -n "${HOOK_FILE:-}" ] && [ -f "$HOOK_FILE" ]; then
  sed -n '1,200p' "$HOOK_FILE" | cat -n
fi

Repository: checkmo2025/FE

Length of output: 9890


Remove setDescription initialization inside useEffect in StoryEditPageClient

src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx initializes description via useEffect (setDescription(story.description)), triggering react-hooks/set-state-in-effect and adding an avoidable extra render.

Suggested fix
-  const [description, setDescription] = useState("");
-  const [isDescriptionInitialized, setIsDescriptionInitialized] = useState(false);
-
-  // 초기 description 세팅 (데이터 로드 후 한 번만)
-  useEffect(() => {
-    if (story && !isDescriptionInitialized) {
-      setDescription(story.description);
-      setIsDescriptionInitialized(true);
-    }
-  }, [story, isDescriptionInitialized]);
+  const [editedDescription, setEditedDescription] = useState<string | null>(null);
+  const description = editedDescription ?? story?.description ?? "";
-              onChange={(e) => setDescription(e.target.value)}
+              onChange={(e) => setEditedDescription(e.target.value)}
🧰 Tools
🪛 ESLint

[error] 29-29: Error: Calling setState synchronously within an effect can trigger cascading renders

Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:

  • Update external systems with the latest state from React.
  • Subscribe for updates from some external system, calling setState in a callback function when external state changes.

Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect).

/home/jailuser/git/src/app/(main)/stories/[id]/edit/StoryEditPageClient.tsx:29:7
27 | useEffect(() => {
28 | if (story && !isDescriptionInitialized) {

29 | setDescription(story.description);
| ^^^^^^^^^^^^^^ Avoid calling setState() directly within an effect
30 | setIsDescriptionInitialized(true);
31 | }
32 | }, [story, isDescriptionInitialized]);

(react-hooks/set-state-in-effect)

🤖 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/[id]/edit/StoryEditPageClient.tsx around lines 23 -
33, The useEffect in StoryEditPageClient sets description after mount causing an
extra render; instead initialize the description state directly from the loaded
story and remove the initialization effect and the isDescriptionInitialized
flag. Replace the useState for description to derive its initial value from
story (e.g., useState(() => story?.description ?? "")) and delete the useEffect
and isDescriptionInitialized/setIsDescriptionInitialized usage so only the
description and setDescription remain.

Comment on lines +32 to +47
if (!id || isNaN(storyId)) {
return (
<div className="flex flex-col items-center justify-center min-h-[400px]">
<h2 className="text-2xl font-bold text-Gray-7 mb-4">잘못된 접근</h2>
<p className="text-Gray-5">올바른 경로로 접근해주세요.</p>
</div>
);
}

const { data: story, isLoading, isError, error } = useStoryDetailQuery(storyId);
const { mutate: toggleLike } = useToggleStoryLikeMutation();
const { mutate: deleteStory, isPending: isDeletePending } = useDeleteBookStoryMutation();
const { mutate: toggleFollow } = useToggleFollowMutation();
const { isLoggedIn, openLoginModal } = useAuthStore();

const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);

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

Fix React hook order violation caused by early return.
In StoryDetailClient, the return inside if (!id || isNaN(storyId)) (around Lines 32–37) happens before useStoryDetailQuery, mutation hooks (useToggleStoryLikeMutation, useDeleteBookStoryMutation, useToggleFollowMutation), useAuthStore, and useState—so those hooks are skipped on some renders, breaking React’s rules of hooks.

Suggested refactor
export default function StoryDetailClient() {
  const params = useParams();
  const id = params?.id as string;
  const storyId = Number(id);

  if (!id || isNaN(storyId)) {
    return (
      <div className="flex flex-col items-center justify-center min-h-[400px]">
        <h2 className="text-2xl font-bold text-Gray-7 mb-4">잘못된 접근</h2>
        <p className="text-Gray-5">올바른 경로로 접근해주세요.</p>
      </div>
    );
  }

  return <StoryDetailContent storyId={storyId} />;
}

function StoryDetailContent({ storyId }: { storyId: number }) {
  const { data: story, isLoading, isError, error } = useStoryDetailQuery(storyId);
  const { mutate: toggleLike } = useToggleStoryLikeMutation();
  const { mutate: deleteStory, isPending: isDeletePending } = useDeleteBookStoryMutation();
  const { mutate: toggleFollow } = useToggleFollowMutation();
  const { isLoggedIn, openLoginModal } = useAuthStore();

  const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);

  // ...rest of the component
}
🧰 Tools
🪛 ESLint

[error] 41-41: React Hook "useStoryDetailQuery" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)


[error] 42-42: React Hook "useToggleStoryLikeMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)


[error] 43-43: React Hook "useDeleteBookStoryMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)


[error] 44-44: React Hook "useToggleFollowMutation" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)


[error] 45-45: React Hook "useAuthStore" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)


[error] 47-47: React Hook "useState" is called conditionally. React Hooks must be called in the exact same order in every component render. Did you accidentally call a React Hook after an early return?

(react-hooks/rules-of-hooks)

🤖 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/[id]/StoryDetailClient.tsx around lines 32 - 47, The
early return in StoryDetailClient causes a hooks-order violation because hooks
(useStoryDetailQuery, useToggleStoryLikeMutation, useDeleteBookStoryMutation,
useToggleFollowMutation, useAuthStore, useState) are declared after the
conditional return; fix it by moving all hook usage and related state into a new
child component (e.g., StoryDetailContent) that accepts storyId: number, keep
StoryDetailClient responsible only for extracting params/id and doing the
early-return check, and then render <StoryDetailContent storyId={storyId} />
when id is valid; ensure the new component contains the referenced symbols
(useStoryDetailQuery, mutate: toggleLike, deleteStory/isDeletePending,
toggleFollow, isLoggedIn/openLoginModal, isDeleteModalOpen/setIsDeleteModalOpen)
and the rest of the UI logic.

Comment on lines +29 to +34
findEmail(
{ name, phoneNumber: phone.replace(/-/g, "") },
{
onSuccess: (data) => {
router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`);
}

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

Avoid exposing recovered email in URL query params.

Passing data.email in the URL at Line 33 leaks PII through browser history, referrer headers, and server/access logs. Route to the result page without the email in the query, and pass it via ephemeral client state (or a backend-issued opaque token) instead.

Suggested direction
- router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`);
+ sessionStorage.setItem("foundEmail", data.email);
+ router.push("/find-account/result");

And in the result page client, read foundEmail from sessionStorage with a safe fallback.

📝 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
findEmail(
{ name, phoneNumber: phone.replace(/-/g, "") },
{
onSuccess: (data) => {
router.push(`/find-account/result?email=${encodeURIComponent(data.email)}`);
}
findEmail(
{ name, phoneNumber: phone.replace(/-/g, "") },
{
onSuccess: (data) => {
sessionStorage.setItem("foundEmail", data.email);
router.push("/find-account/result");
}
🤖 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/`(public)/find-account/FindAccountPageClient.tsx around lines 29 -
34, The code in FindAccountPageClient is leaking PII by putting data.email into
router.push query; instead, before calling router.push in the findEmail
onSuccess handler, store the recovered email in ephemeral client state (e.g.,
sessionStorage under a key like "foundEmail" or request an opaque backend token)
and then navigate to the result page without including email in the URL (change
the router.push call in findEmail to route to "/find-account/result" with no
email param). Update the result page client to read the email from
sessionStorage (with a safe fallback if absent) or exchange the opaque token for
the email, and remove any encodeURIComponent(data.email) usage in
FindAccountPageClient.

Comment on lines +22 to +49
useEffect(() => {
const isSocialParam = searchParams.get("isSocial");
const urlIsSocial = isSocialParam === "true";
const urlIsSocialFalse = isSocialParam === "false";

if (urlIsSocialFalse) {
router.replace("/");
return;
}

const storeIsSocial = isLoggedIn && user && !user.nickname;
const shouldBeSocial = urlIsSocial || storeIsSocial;

if (shouldBeSocial) {
if (!isSocial) {
setIsSocial(true);
}

const newEmail = user?.email ?? searchParams.get("email");
if (newEmail && newEmail !== email) {
setEmail(newEmail);
}

if (step === "email" || step === "password") {
router.replace(`/signup/profile?isSocial=true`);
}
}
}, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]);

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

Reset social-signup state when social conditions are no longer met.

Current logic only turns isSocial on. If context keeps a stale isSocial=true, normal signup can be routed as social and skip email/password unexpectedly.

Proposed fix
         if (shouldBeSocial) {
             if (!isSocial) {
                 setIsSocial(true);
             }

             const newEmail = user?.email ?? searchParams.get("email");
             if (newEmail && newEmail !== email) {
                 setEmail(newEmail);
             }

             if (step === "email" || step === "password") {
                 router.replace(`/signup/profile?isSocial=true`);
             }
+        } else if (isSocial) {
+            setIsSocial(false);
+            setEmail("");
         }
📝 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
useEffect(() => {
const isSocialParam = searchParams.get("isSocial");
const urlIsSocial = isSocialParam === "true";
const urlIsSocialFalse = isSocialParam === "false";
if (urlIsSocialFalse) {
router.replace("/");
return;
}
const storeIsSocial = isLoggedIn && user && !user.nickname;
const shouldBeSocial = urlIsSocial || storeIsSocial;
if (shouldBeSocial) {
if (!isSocial) {
setIsSocial(true);
}
const newEmail = user?.email ?? searchParams.get("email");
if (newEmail && newEmail !== email) {
setEmail(newEmail);
}
if (step === "email" || step === "password") {
router.replace(`/signup/profile?isSocial=true`);
}
}
}, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]);
useEffect(() => {
const isSocialParam = searchParams.get("isSocial");
const urlIsSocial = isSocialParam === "true";
const urlIsSocialFalse = isSocialParam === "false";
if (urlIsSocialFalse) {
router.replace("/");
return;
}
const storeIsSocial = isLoggedIn && user && !user.nickname;
const shouldBeSocial = urlIsSocial || storeIsSocial;
if (shouldBeSocial) {
if (!isSocial) {
setIsSocial(true);
}
const newEmail = user?.email ?? searchParams.get("email");
if (newEmail && newEmail !== email) {
setEmail(newEmail);
}
if (step === "email" || step === "password") {
router.replace(`/signup/profile?isSocial=true`);
}
} else if (isSocial) {
setIsSocial(false);
setEmail("");
}
}, [searchParams, user, isLoggedIn, isSocial, email, setIsSocial, setEmail, step, router]);
🤖 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/`(public)/signup/[step]/SignupStepPageClient.tsx around lines 22 -
49, The effect in SignupStepPageClient.tsx only sets isSocial true and never
resets it, causing stale social-signup state; update the useEffect (which reads
searchParams.get("isSocial"), computes urlIsSocial/urlIsSocialFalse,
storeIsSocial and shouldBeSocial) to explicitly setIsSocial(false) when
shouldBeSocial is false (and clear any social-derived email via setEmail if it
came from user?.email or searchParams.get("email") to avoid carrying a social
email into normal signup), and keep the existing router.replace logic for
urlIsSocialFalse and profile redirects when shouldBeSocial is true.

@yhi9839
yhi9839 merged commit e0bb4a6 into main May 23, 2026
3 checks passed
@coderabbitai coderabbitai Bot mentioned this pull request Jun 2, 2026

This branch was successfully deployed

1 active deployment
Preview — 77c15400 Deployed May 22, 2026 by vercel[bot]
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.

1 participant