Skip to content

Comments

code rabbit test#367

Closed
Hyeonjun0527 wants to merge 11 commits intorelease/study-ranking-nofrom
release/study-ranking-no-2
Closed

code rabbit test#367
Hyeonjun0527 wants to merge 11 commits intorelease/study-ranking-nofrom
release/study-ranking-no-2

Conversation

@Hyeonjun0527
Copy link
Member

@Hyeonjun0527 Hyeonjun0527 commented Feb 3, 2026

🌱 연관된 이슈

☘️ 작업 내용

code rabbit test

🍀 참고사항

스크린샷 (선택)

Summary by CodeRabbit

  • New Features

    • Added tabbed interface to home page with Study, Archive, Community, Rankings, and History sections.
    • Introduced archive library with grid/list views, search, and sorting capabilities.
    • New community voting interface with creation, filtering, and infinite scrolling.
    • Added Hall of Fame rankings/leaderboard with member filtering and search.
    • Introduced study history tracking with list and calendar view modes.
    • Added user profile modals for author interactions across cards and discussions.
  • Improvements

    • Enhanced voting detail page with streamlined unified view.
    • Simplified library interface with focused interactions.

@Hyeonjun0527 Hyeonjun0527 self-assigned this Feb 3, 2026
@vercel
Copy link

vercel bot commented Feb 3, 2026

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

Project Deployment Actions Updated (UTC)
study-platform-client-dev Error Error Feb 3, 2026 0:46am

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

  • ✅ Full review completed - (🔄 Check again to review again)
✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch release/study-ranking-no-2

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

@Hyeonjun0527
Copy link
Member Author

Hyeonjun0527 commented Feb 3, 2026

@coderabbitai help

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

CodeRabbit commands

These commands are invoked using PR/Issue comments.

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai evaluate custom pre-merge check --instructions <custom-checks-instructions> --name <custom-checks-title> [--mode <error|warning>] to validate the custom pre-merge checks instructions. Defaults to error when --mode is omitted.
    • @coderabbitai ignore pre-merge checks to override pre-merge checks and get an approval on PR.
    • @coderabbitai run pre-merge checks to run pre-merge checks on the pull request.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai generate sequence diagram to generate a sequence diagram of the changes in this PR.
  • @coderabbitai generate unit tests to generate unit tests for this PR.
  • @coderabbitai resolve to resolve all the CodeRabbit review comments.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai generate configuration to create a PR that adds the current resolved configuration as .coderabbit.yaml (or show it if already present).
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore or @coderabbit ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit configuration file (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • You can also validate your configuration using the online YAML validator.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Status, support, documentation and community

  • Visit our status page to check the current availability of CodeRabbit.
  • Create a ticket on our support page for assistance with any issues or questions.
  • Visit our documentation site for detailed information on how to use CodeRabbit.
  • Join our Discord community to connect with other users and get help from the community.
  • Follow us on X/Twitter for updates and announcements.

@coderabbitai
Copy link

coderabbitai bot commented Feb 3, 2026

Tip

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

@Hyeonjun0527 Here are the available commands and usage tips for CodeRabbit:

📋 Available Commands

Review Commands

  • @coderabbitai review - Trigger a review of the PR
  • @coderabbitai full review - Conduct a comprehensive review of all PR changes
  • @coderabbitai summary - Regenerate the PR summary

PR Management

  • @coderabbitai pause - Pause automatic reviews for this PR
  • @coderabbitai resume - Resume automatic reviews for this PR
  • @coderabbitai resolve - Resolve all review comments made by CodeRabbit

Code Generation

  • @coderabbitai generate unit tests - Generate unit tests for the changes (beta)
    • You can specify to create a PR, post in a comment, or commit to the current branch

Configuration & Help

  • @coderabbitai help - Display this help message
  • @coderabbitai configuration - Show current CodeRabbit configuration

Learning & Memory

  • Ask me to "remember" or mention "learning" to save preferences and tribal knowledge for future reviews
  • Example: "Remember that we validate transactions in the parent function"

💡 Usage Tips

  1. Inline Discussions: You can ask me questions about specific code sections by commenting on any line
  2. Code Changes: Request modifications by describing what you want changed (e.g., "Refactor this function to use async/await")
  3. Analysis: Ask for deeper analysis with commands like "explain this pattern" or "find all usages"
  4. Web Search: I can search for latest documentation and information when needed
  5. Shell Scripts: I can run commands to analyze the codebase more deeply

🎯 Quick Examples

  • "Explain how this function works"
  • "Generate unit tests for this component"
  • "Find all usages of this method"
  • "What are the security implications here?"
  • "Remember to always use TypeScript strict mode in this project"

Feel free to interact naturally - I'll understand your intent and help accordingly!

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 14

Caution

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

⚠️ Outside diff range comments (1)
src/app/(service)/one-on-one/page.tsx (1)

640-651: ⚠️ Potential issue | 🟠 Major

Use Next.js <Link> for internal navigation.

This internal route uses <a> instead of <Link>, which triggers a full reload and violates the Next.js no-html-link-for-pages lint rule.

🔧 Suggested fix
-            <a
-              href="/insights/weekly"
-              className="text-left px-200 py-150 rounded-100 font-designer-16m transition-all flex items-center gap-150 text-text-subtle hover:text-text-strong hover:bg-fill-neutral-subtle-hover"
-            >
+            <Link
+              href="/insights/weekly"
+              className="text-left px-200 py-150 rounded-100 font-designer-16m transition-all flex items-center gap-150 text-text-subtle hover:text-text-strong hover:bg-fill-neutral-subtle-hover"
+            >
               <MessageSquareText className="w-5 h-5" />
               <span className="flex items-center gap-100">
                 밸런스게임
                 <span className="animate-pulse text-[10px] bg-gradient-to-r from-[`#6366f1`] to-[`#8b5cf6`] text-white px-100 py-25 rounded-full font-bold">
                   NEW
                 </span>
               </span>
-            </a>
+            </Link>
// add near the top of the file
import Link from 'next/link';
🤖 Fix all issues with AI agents
In `@src/app/`(service)/insights/weekly/[id]/page.tsx:
- Around line 8-16: Validate the dynamic id before rendering VotingDetailView:
read params via useParams(), ensure params.id is defined, convert it to a number
and check Number.isFinite/Number.isNaN (or Number.isInteger) to detect invalid
numeric values; if the id is missing or invalid, render a fallback UI (e.g., a
not-found message/component) or call router.back()/notFound() instead of passing
NaN into VotingDetailView; update the code around useParams, useRouter, votingId
and the VotingDetailView return to perform this guard.

In `@src/app/`(service)/one-on-one/page.tsx:
- Around line 389-410: LibraryCard and LibraryRow use clickable <div>s that are
not keyboard-accessible and the view handler opens a new tab without
noopener/noreferrer; update both components (LibraryCard and LibraryRow) to add
role="link", tabIndex={0}, a descriptive aria-label, and an onKeyDown that
triggers the same action on Enter and Space keys so keyboard users can focus and
activate the element, and change the view handler (handleView) to call
window.open(link, '_blank', 'noopener,noreferrer') and then, if a window object
is returned, set newWindow.opener = null for reverse-tabnabbing protection.

In `@src/components/discussion/discussion-detail-modal.tsx`:
- Around line 78-95: Replace the non-focusable trigger div passed to
UserProfileModal's trigger prop with a semantic, keyboard-focusable button:
change the inner element used in trigger (currently the <div className="flex
items-center ..."> with UserAvatar and nickname) to a <button type="button">
keeping the same className and styles, add an accessible name (aria-label e.g.
`${discussion.author.nickname} profile` or ensure visible text remains), and
ensure click propagation is still stopped by keeping the outer onClick={(e) =>
e.stopPropagation()} wrapper; this makes UserProfileModal's trigger
keyboard-operable without changing visual styling.

In `@src/components/home/tabs/archive-tab.tsx`:
- Around line 363-365: The handleView function opens external links with
window.open(link, '_blank') which leaves window.opener available; update
handleView to open the link in a way that prevents the opened page from
accessing window.opener and removes referrer info — either create an anchor
element with target="_blank" and rel="noopener noreferrer" and click it, or
capture the returned window from window.open and set newWindow.opener = null
(and include 'noreferrer' if using feature string) before returning; locate and
modify the handleView function to implement one of these secure patterns.
- Around line 115-128: The module-level Array.from mock data in archive-tab.tsx
uses Math.random() (for isLiked/isBookmarked) which causes SSR/client hydration
mismatches; remove Math.random from the module scope and either (A) make the
mock values deterministic (e.g., derive isLiked/isBookmarked from the index like
i % 2 === 0) in the Array.from expression, or (B) keep deterministic module data
and perform client-only randomization inside the ArchiveTab component using
useState + useEffect to set isLiked/isBookmarked on mount (so randomization runs
only on the client). Target the Array.from({...}) generation in archive-tab.tsx
and the ArchiveTab component state initialization when applying the fix.
- Around line 394-398: The sort uses new Date(...) on non-ISO strings which can
be invalid for formats like "2025.01.10"; update the sorting to parse dates
explicitly instead of relying on Date parsing: inside the sortedLibrary creation
(which uses filteredLibrary and librarySort) replace the new Date(b.date)/new
Date(a.date) calls with a deterministic parser (e.g., split the "YYYY.MM.DD"
string into year, month, day and construct a Date(year, monthIndex, day) or
convert dots to hyphens to form an ISO string) so the date comparison always
produces correct timestamps.

In `@src/components/home/tabs/community-tab.tsx`:
- Around line 69-89: The code uses Date.now() to assign newVoting.id and option
ids which can collide when created in the same millisecond, and directly calls
JSON.parse(customVotings) which will throw on malformed data; update the
creation in the newVoting block to use a stronger unique id generator (e.g.,
crypto.randomUUID() or a uuid helper) for newVoting.id and each option.id (or
append a per-option counter) and replace direct JSON.parse usage with a safe
parse (wrap JSON.parse(customVotings) in try/catch or use a safeParse helper
that returns [] on error) wherever customVotings is read so malformed
localStorage won’t crash the app; refer to the newVoting constant, its id and
options fields, and the localStorage parsing site to locate changes.

In `@src/components/home/tabs/hall-of-fame-tab.tsx`:
- Around line 58-95: generateMockRankers uses Math.random() (studyTime, streak,
major, change, changeValue) which causes SSR/client hydration mismatches;
replace all Math.random() calls in generateMockRankers with deterministic values
derived from the index (i) so the output is identical on server and client —
e.g., compute studyTime from i ((i % 31) + 10), streak from i ((i % 50) + 1),
pick major as JOBS[i % JOBS.length], derive change using a deterministic pattern
based on i (e.g., i % 10 thresholds for 'up'/'down'/'same'), and set changeValue
to i % 5; keep lastActive logic as-is since it already depends on i.

In `@src/components/study-history/study-calendar.tsx`:
- Around line 47-62: The component currently renders all dayItems via
dayItems.map(...) while also showing an overflow indicator when dayItems.length
> 2; change the rendering to only display the first two items (use
dayItems.slice(0, 2) or compute a displayedItems array) in the map (the JSX that
uses key={item.id}, title, and role/subject rendering) and keep the existing
overflow indicator using dayItems.length - displayedItems.length so "+N개 더"
correctly reflects hidden items.
- Around line 8-9: StudyCalendar currently hardcodes currentDate to new
Date(2025, 0, 1); change initialization so currentDate defaults to either the
most recent date present in the items prop (map items by their date/timestamp
and pick the max) or, if items is empty/undefined, the current date (new
Date()); set the initial state using a lazy initializer (useState(() => ...))
and also update currentDate when items change (useEffect that computes the
latest item date and calls setCurrentDate) to ensure the calendar starts on a
relevant date. Reference: StudyCalendar, currentDate, setCurrentDate, items,
StudyHistoryItem.

In `@src/components/study-history/study-history-row.tsx`:
- Around line 51-60: The external-link anchor rendering when item.link is
present lacks an accessible name; update the anchor that wraps ExternalLink (the
<a> element in StudyHistoryRow / where item.link is used) to include an
accessible label — either add an aria-label (e.g. aria-label={`Open
${item.title} in new tab`} or similar) or include a visually-hidden text node (a
<span className="sr-only">Open {item.title} in new tab</span>) inside the anchor
so screen readers announce the purpose of the link while preserving the
icon-only visual.

In `@src/widgets/home/home-dashboard.tsx`:
- Around line 19-43: getSincerityPresetByLevelName can return undefined, so
update the code that assigns temperPreset (used in the JSX: temperPreset.bgClass
and temperPreset.textClass) to ensure a safe default object is used when
undefined; e.g., after calling
getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) coalesce to a
fallback preset with safe bgClass and textClass values (or destructure with
defaults) so the JSX rendering of the profile badge always has valid class
strings.
- Line 176: The FeedbackLink usage is passing unsupported props
(variant="compact", className="w-full") which the component doesn't accept; fix
by either removing those props from the JSX in the home dashboard (replace
<FeedbackLink variant="compact" className="w-full" /> with <FeedbackLink />) or
update the FeedbackLink component signature (function FeedbackLink) in
feedback-link.tsx to accept and apply props (e.g., props: { variant?: string;
className?: string } and forward className/variant to the rendered element);
choose one approach and ensure types are updated so the props are consumed or
not passed.
- Around line 16-21: The code currently casts the cookie value returned by
getServerCookie('memberId') into a Number (memberId) without validating it, so
if memberIdStr is missing or not numeric you end up calling
getUserProfileInServer(NaN); add a guard: check memberIdStr exists and that
Number.isInteger(Number(memberIdStr)) (or parseInt and validate) and if invalid
perform the proper redirect/early return before calling getUserProfileInServer
and getSincerityPresetByLevelName; update logic around memberIdStr, memberId,
getUserProfileInServer, and getSincerityPresetByLevelName to only run when
memberId is a valid integer.
🧹 Nitpick comments (14)
src/components/voting/voting-detail-modal.tsx (1)

67-86: Use a button for the profile trigger (keyboard a11y).

Line 72 uses a clickable <div>; it won’t be focusable by keyboard. Use a <button> as the trigger and keep stopPropagation.

♿ Suggested fix
-            <div className="mb-200" onClick={(e) => e.stopPropagation()}>
+            <div className="mb-200">
               <UserProfileModal
                 memberId={voting.author.id}
                 trigger={
-                  <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default">
+                  <button
+                    type="button"
+                    onClick={(e) => e.stopPropagation()}
+                    className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"
+                    aria-label={`${voting.author.nickname} 프로필 보기`}
+                  >
                     <div>
                       <UserAvatar 
                         size={32} 
                         image={voting.author.avatar}
                         className="relative z-10"
                       />
                     </div>
                     <span className="font-designer-13b text-text-default">
                       {voting.author.nickname}
                     </span>
-                  </div>
+                  </button>
                 }
               />
             </div>
src/components/discussion/comment-list.tsx (1)

67-71: Use a button trigger for the avatar modal (keyboard a11y).

Line 70 uses UserAvatar directly as the trigger; if it renders a <div>, keyboard users can’t open the modal. Wrap it in a button with an aria-label.

♿ Suggested fix
-                <div onClick={(e) => e.stopPropagation()}>
-                  <UserProfileModal
-                    memberId={comment.author.id}
-                    trigger={<UserAvatar size={28} image={comment.author.avatar} />}
-                  />
-                </div>
+                <div>
+                  <UserProfileModal
+                    memberId={comment.author.id}
+                    trigger={
+                      <button
+                        type="button"
+                        onClick={(e) => e.stopPropagation()}
+                        aria-label={`${comment.author.nickname} 프로필 보기`}
+                        className="rounded-full"
+                      >
+                        <UserAvatar size={28} image={comment.author.avatar} />
+                      </button>
+                    }
+                  />
+                </div>
src/components/home/tab-navigation.tsx (1)

66-78: Add type="button" to tab buttons.
Prevents accidental form submission if this nav is ever rendered inside a <form>.

♻️ Suggested tweak
             <button
               key={tab.id}
+              type="button"
               onClick={() => handleTabChange(tab.id)}
src/app/(service)/home/page.tsx (1)

22-32: Remove the duplicate Suspense boundary from the page wrapper.

HomeContent already wraps its content in a Suspense boundary with an identical fallback (Banner/StudyCard). Nesting two Suspense boundaries with the same fallback is redundant—React will always show the outermost fallback, making the inner one unreachable. Per React 19 and Next.js 15 guidance, avoid stacking identical fallbacks. The inner Suspense boundary within HomeContentInner (for tab-specific streaming) is still valuable and should be kept.

♻️ Optional cleanup
-        <Suspense 
-          fallback={
-            <>
-              <Banner />
-              <StudyCard />
-            </>
-          }
-        >
-          <HomeContent />
-        </Suspense>
+        <HomeContent />
src/types/study-history.ts (1)

1-11: Remove the 'use client' directive from this type-only module.

Per Next.js best practices, type-only files should remain neutral without directives. This allows the types to be safely imported from both server and client components. Adding 'use client' to a type-only module unnecessarily marks it as part of the client bundle.

♻️ Suggested change
-'use client';
-
 export interface StudyHistoryItem {
src/components/home/tabs/study-history-tab.tsx (1)

138-145: Add blank line before return statement to satisfy ESLint rule.

Static analysis flagged a missing blank line before the return statement.

🔧 Proposed fix
   useEffect(() => {
     setIsLoading(true);
     const timer = setTimeout(() => {
       setHistoryItems(MOCK_HISTORY_DATA);
       setIsLoading(false);
     }, 300);
+
     return () => clearTimeout(timer);
   }, []);
src/components/study-history/study-calendar.tsx (1)

16-23: Add blank line before return statement to satisfy ESLint rule.

Static analysis flagged a missing blank line.

🔧 Proposed fix
   const itemsByDate = items.reduce((acc, item) => {
     const match = item.date.match(/(\d{4})\.(\d{2})\.(\d{2})/);
     if (match) {
       const key = `${match[1]}.${match[2]}.${match[3]}`;
       acc[key] = acc[key] ? [...acc[key], item] : [item];
     }
+
     return acc;
   }, {} as Record<string, StudyHistoryItem[]>);
src/components/voting/voting-detail-view.tsx (2)

108-116: Add blank line before return statement to satisfy ESLint rule.

Static analysis flagged missing blank lines in the setVoting callback.

🔧 Proposed fix
     setVoting((prev) => {
       if (!prev) return prev;
+
       return {
         ...prev,
         comments: [...prev.comments, newComment],
         commentCount: prev.commentCount + 1,
       };
     });

119-128: Add blank line before return statement to satisfy ESLint rule.

Similar formatting issue in the delete handler.

🔧 Proposed fix
   const handleDeleteComment = (commentId: number) => {
     setVoting((prev) => {
       if (!prev) return prev;
+
       return {
         ...prev,
         comments: prev.comments.filter((c) => c.id !== commentId),
         commentCount: prev.commentCount - 1,
       };
     });
   };
src/components/home/tabs/archive-tab.tsx (1)

419-427: Admin toggle should be removed or properly secured for production.

The admin toggle is implemented as a hidden UI element that becomes visible on hover. While the comment indicates it's a "dev feature," this pattern could inadvertently expose admin functionality to users in production. Consider using environment variables or feature flags to conditionally render this in development only.

♻️ Suggested approach using environment check
+const isDev = process.env.NODE_ENV === 'development';
+
 {/* Admin Toggle (Hidden/Dev feature) */}
+{isDev && (
   <button 
     onClick={() => setIsAdmin(!isAdmin)}
     className={cn(
       "px-200 py-100 rounded-100 text-xs font-mono transition-colors",
       isAdmin ? "bg-red-100 text-red-600" : "bg-transparent text-transparent hover:text-gray-300"
     )}
   >
     {isAdmin ? 'Admin Mode ON' : 'Admin'}
   </button>
+)}
src/components/home/tabs/hall-of-fame-tab.tsx (2)

287-305: Large mock dataset (2000 items) regenerated on every tab change may impact performance.

The effect generates 2000 ranker objects (100 × 20) on every rankingType change. While this is mock data, it creates unnecessary memory churn. Consider reducing the dataset size or memoizing the generation.

♻️ Consider reducing mock data size
 const timer = setTimeout(() => {
-  const data = Array.from({ length: 100 }, (_, i) => {
+  const data = Array.from({ length: 10 }, (_, i) => {
     return generateMockRankers(rankingType).map((r) => ({
       ...r,
       rank: r.rank + i * 20,
     }));
   })

This would generate 200 items which is still sufficient for demonstrating pagination.


316-325: Edge case: Empty ranking table when all results are in top 3.

When filteredRankers.length is exactly 3, currentRankers will be empty (since .slice(3) returns []), resulting in an empty table body. The top 3 section will display correctly, but the table header will still render with no rows beneath it.

This may be intentional, but consider hiding the table entirely when there are no additional rankers beyond the top 3.

♻️ Hide table when no additional rankers
+{currentRankers.length > 0 && (
   {/* Ranking Table */}
   <div className="bg-background-default rounded-200 border border-border-subtle overflow-hidden shadow-1">
     ...
   </div>
+)}
src/components/home/tabs/community-tab.tsx (2)

113-135: IntersectionObserver re-created on every page increment.

The effect's dependency on page causes the observer to be disconnected and reconnected every time a new page is loaded. While functionally correct, this is inefficient. Consider removing page from dependencies and using a ref to track it.

♻️ Use ref for page tracking
+const pageRef = useRef(page);
+pageRef.current = page;

 useEffect(() => {
   const observer = new IntersectionObserver(
     (entries) => {
       if (entries[0].isIntersecting && hasMore && !isLoadingMore && !isLoading) {
-        const nextPage = page + 1;
+        const nextPage = pageRef.current + 1;
         setPage(nextPage);
         loadVotings(nextPage, false);
       }
     },
     { threshold: 0.1 },
   );

   const currentTarget = observerTarget.current;
   if (currentTarget) {
     observer.observe(currentTarget);
   }

   return () => {
     if (currentTarget) {
       observer.unobserve(currentTarget);
     }
   };
-}, [hasMore, isLoadingMore, isLoading, page, loadVotings]);
+}, [hasMore, isLoadingMore, isLoading, loadVotings]);

147-157: Consider preserving scroll position when returning from detail view.

When navigating back from VotingDetailView, the list will re-render from the beginning. For better UX, consider preserving the scroll position or using a more sophisticated navigation pattern.

Comment on lines 8 to +16
const params = useParams();
const router = useRouter();
const votingId = Number(params.id);

const [voting, setVoting] = useState<Voting | null>(null);
const [selectedOption, setSelectedOption] = useState<number | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDescription, setShowDescription] = useState(false);
const [error, setError] = useState<string | null>(null);

// 데이터 로드
useEffect(() => {
const loadVoting = async () => {
try {
setIsLoading(true);
const data = await mockFetchVotingDetail(votingId);
if (data) {
setVoting(data);
setSelectedOption(data.myVote);
} else {
setError('투표를 찾을 수 없습니다.');
}
} catch (err) {
setError('데이터를 불러오는데 실패했습니다.');
console.error(err);
} finally {
setIsLoading(false);
}
};

loadVoting();
}, [votingId]);

// 투표 핸들러
const handleVote = async () => {
if (!selectedOption || !voting?.isActive) return;

setIsSubmitting(true);
try {
// Mock API 호출 (실제로는 서버에 요청)
await new Promise((resolve) => setTimeout(resolve, 500));

// 로컬 상태 업데이트
setVoting((prev) => {
if (!prev) return prev;

const oldVote = prev.myVote;
const updatedOptions = prev.options.map((opt) => {
let newVoteCount = opt.voteCount;

if (oldVote === opt.id) {
newVoteCount--;
}
if (opt.id === selectedOption) {
newVoteCount++;
}

return { ...opt, voteCount: newVoteCount };
});

const newTotalVotes = oldVote ? prev.totalVotes : prev.totalVotes + 1;
const optionsWithPercentage = updatedOptions.map((opt) => ({
...opt,
percentage: (opt.voteCount / newTotalVotes) * 100,
}));

return {
...prev,
myVote: selectedOption,
options: optionsWithPercentage,
totalVotes: newTotalVotes,
};
});
} finally {
setIsSubmitting(false);
}
};

// 댓글 추가 핸들러
const handleAddComment = async (data: CommentFormData) => {
if (!voting) return;

const newComment: VotingComment = {
id: Date.now(),
author: { id: 999, nickname: '나' },
content: data.content,
createdAt: new Date().toISOString(),
isAuthor: true,
votedOption: voting.options.find((opt) => opt.id === voting.myVote)?.label,
};

setVoting((prev) => {
if (!prev) return prev;
return {
...prev,
comments: [...prev.comments, newComment],
commentCount: prev.commentCount + 1,
};
});
};

// 댓글 삭제 핸들러
const handleDeleteComment = (commentId: number) => {
setVoting((prev) => {
if (!prev) return prev;
return {
...prev,
comments: prev.comments.filter((c) => c.id !== commentId),
commentCount: prev.commentCount - 1,
};
});
};

// 로딩 상태
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background-alternative">
<div className="flex flex-col items-center gap-400">
<Loader2 className="h-8 w-8 animate-spin text-text-brand" />
<p className="font-designer-16m text-text-subtle">투표를 불러오는 중...</p>
</div>
</div>
);
}

// 에러 상태
if (error || !voting) {
return (
<div className="flex min-h-screen items-center justify-center bg-background-alternative">
<div className="flex flex-col items-center gap-400">
<p className="font-designer-16m text-text-subtle">{error || '투표를 찾을 수 없습니다.'}</p>
<button
onClick={() => router.push('/insights/weekly')}
className="rounded-100 bg-fill-brand-default-default px-400 py-200 font-designer-14b text-text-inverse transition-colors hover:bg-fill-brand-default-hover"
>
목록으로 돌아가기
</button>
</div>
</div>
);
}

const hasVoted = voting.myVote !== undefined;

return (
<div className="min-h-screen bg-background-alternative">
<div className="mx-auto w-full max-w-screen-lg px-400 py-600">
{/* 뒤로가기 버튼 */}
<button
onClick={() => router.back()}
className="mb-400 flex items-center gap-100 font-designer-14m text-text-subtle transition-colors hover:text-text-strong"
>
<ArrowLeft className="h-4 w-4" />
돌아가기
</button>

{/* 헤더 */}
<div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
{/* 라운드 & 상태 */}
<div className="mb-300 flex items-center gap-200">
<div className="flex items-center gap-100 rounded-100 bg-fill-brand-default-default px-250 py-100">
<TrendingUp className="h-4 w-4 text-text-inverse" />
<span className="font-designer-13b text-text-inverse">{voting.round} 라운드</span>
</div>
<VoteTimer endsAt={voting.endsAt} isActive={voting.isActive} />
</div>

{/* 제목 */}
<h1 className="mb-200 font-bold-h3 text-text-strong">{voting.title}</h1>

{/* 설명 토글 */}
{voting.description && (
<>
<button
onClick={() => setShowDescription(!showDescription)}
className="mb-200 flex items-center gap-100 rounded-100 border border-border-subtle px-200 py-100 font-designer-12r text-text-subtle transition-colors hover:border-border-brand hover:text-text-brand"
>
<Info className="h-3.5 w-3.5" />
주제 설명 {showDescription ? '숨기기' : '보기'}
</button>

{showDescription && (
<p className="mb-200 rounded-100 border border-border-subtle bg-background-alternative p-300 font-designer-14r text-text-default">
{voting.description}
</p>
)}
</>
)}

{/* 태그 */}
{voting.tags.length > 0 && (
<div className="flex flex-wrap gap-100">
{voting.tags.map((tag) => (
<span
key={tag}
className="rounded-100 bg-fill-neutral-subtle-default px-150 py-50 font-designer-12r text-text-subtle"
>
#{tag}
</span>
))}
</div>
)}
</div>

{/* 투표 섹션 */}
<div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
{!hasVoted && voting.isActive ? (
<>
{/* 헤더 */}
<div className="mb-400 flex items-center justify-between">
<h2 className="font-designer-18b text-text-strong">투표해주세요</h2>

{/* 현재 투표 참여 인원 */}
<div className="flex items-center gap-200 rounded-100 border border-border-subtle bg-background-alternative px-300 py-150">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-fill-brand-default-default">
<TrendingUp className="h-4 w-4 text-text-inverse" />
</div>
<div className="flex flex-col">
<span className="font-designer-11r text-text-subtle">현재 참여</span>
<span className="font-designer-14b text-text-strong">
{voting.totalVotes.toLocaleString()}명
</span>
</div>
</div>
</div>

{/* 선택지 */}
<div className="mb-400 flex flex-col gap-300">
{voting.options.map((option, index) => {
const isSelected = selectedOption === option.id;
const colors = [
{ border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500' },
{ border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500' },
{ border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500' },
{ border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500' },
{ border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500' },
];
const color = colors[index % colors.length];

return (
<button
key={option.id}
onClick={() => setSelectedOption(option.id)}
disabled={isSubmitting}
className={cn(
'group relative rounded-200 border-2 p-300 text-left transition-all duration-200',
isSelected
? cn('shadow-lg', color.border, color.bg)
: 'border-border-subtle bg-background-default hover:border-border-brand hover:shadow-1',
isSubmitting && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center justify-between">
{/* 선택지 텍스트 */}
<div className="flex items-center gap-200">
{/* 번호 배지 */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-bold transition-all',
isSelected
? cn('text-white', color.border.replace('border-', 'bg-'))
: 'bg-fill-neutral-subtle-default text-text-subtle group-hover:bg-fill-neutral-strong-default group-hover:text-text-inverse',
)}
>
{index + 1}
</div>

<span
className={cn(
'font-designer-15b transition-colors',
isSelected ? color.text : 'text-text-default',
)}
>
{option.label}
</span>
</div>

{/* 선택 체크 표시 */}
{isSelected && (
<div className={cn('flex h-7 w-7 items-center justify-center rounded-full', color.border.replace('border-', 'bg-'))}>
<Check className="h-4 w-4 text-white" />
</div>
)}
</div>
</button>
);
})}
</div>

{/* 투표하기 버튼 */}
<button
onClick={handleVote}
disabled={!selectedOption || isSubmitting}
className={cn(
'w-full rounded-100 py-300 font-designer-15b text-text-inverse shadow-lg transition-all duration-200',
'bg-gradient-to-r from-fill-brand-default-default to-fill-brand-default-hover',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none',
'hover:scale-[1.02] hover:shadow-xl',
)}
>
{isSubmitting ? (
<div className="flex items-center justify-center gap-200">
<Loader2 className="h-5 w-5 animate-spin" />
투표 중...
</div>
) : (
'투표하기'
)}
</button>
</>
) : (
<>
<h2 className="mb-400 font-designer-18b text-text-strong">투표 결과</h2>
<VoteResultsChart
options={voting.options}
myVote={voting.myVote}
totalVotes={voting.totalVotes}
/>
</>
)}
</div>

{/* 일별 통계 (투표 후에만 표시) */}
{hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && (
<div className="mb-500">
<DailyStatsChart
dailyStats={voting.dailyStats}
options={voting.options}
myVote={voting.myVote}
/>
</div>
)}

{/* 댓글 섹션 */}
<div className="rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
<div className="mb-400 flex items-center gap-100 font-designer-16b text-text-strong">
<MessageCircle className="h-5 w-5" />
<span>댓글 {voting.commentCount}</span>
</div>

{/* 댓글 목록 (항상 표시) */}
<div className="mb-400">
<CommentList
comments={voting.comments}
onDelete={handleDeleteComment}
votingOptions={voting.options}
/>
</div>

{/* 댓글 작성 폼 */}
{voting.isActive && (
<>
{!hasVoted ? (
/* 투표 안 했으면 댓글 작성 불가 안내 */
<div className="rounded-200 border border-border-subtle bg-background-alternative p-400 text-center">
<p className="font-designer-14m text-text-subtle">
투표 후 댓글을 작성할 수 있습니다
</p>
</div>
) : (
/* 투표 했으면 댓글 작성 폼 */
<div className="rounded-200 border border-border-subtle bg-background-alternative p-300">
<CommentForm onSubmit={handleAddComment} />
</div>
)}
</>
)}
</div>
</div>
</div>
<VotingDetailView
votingId={votingId}
onBack={() => router.back()}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

Next.js 15 useParams return type dynamic segments [id] documentation

💡 Result:

In Next.js 15 (App Router), useParams() returns an object whose keys are the active dynamic segment names (e.g. [id] → id) and whose values are:

  • string for a single dynamic segment like [id]
  • string[] for a catch-all segment like [...slug]
    If there are no dynamic params, it returns {}. [1]

TypeScript typing (recommended)

You can provide a generic to get a precise return type:

'use client'
import { useParams } from 'next/navigation'

export default function Page() {
  const { id } = useParams<{ id: string }>()   // route: /product/[id]
  // id: string
}

Catch-all example:

const { slug } = useParams<{ slug: string[] }>() // route: /docs/[...slug]
// slug: string[]

[1]

Note: In the Pages Router, useParams() can be null on the initial render before the router is ready. [2]

Sources: Next.js docs for useParams (App Router) [1] and (Pages Router) [2].


🏁 Script executed:

# Check if there's any relevant type information in the repository
fd "page.tsx|useParams" src/app --type f | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 1042


🏁 Script executed:

# Look at the actual file to understand the context better
cat -n src/app/\(service\)/insights/weekly/\[id\]/page.tsx | head -30

Repository: code-zero-to-one/study-platform-client

Length of output: 632


Validate the numeric ID before rendering.
For the dynamic route [id], useParams() returns { id: string }. While params.id is always a string (not an array), Number(params.id) can still produce NaN if the param is non-numeric or empty. Consider validating and rendering a fallback on invalid IDs.

✅ Suggested guard
  const votingId = Number(params.id);
+  if (!Number.isFinite(votingId)) {
+    return <div className="p-400 text-text-subtle">잘못된 접근입니다.</div>;
+  }
📝 Committable suggestion

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

Suggested change
const params = useParams();
const router = useRouter();
const votingId = Number(params.id);
const [voting, setVoting] = useState<Voting | null>(null);
const [selectedOption, setSelectedOption] = useState<number | undefined>();
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [showDescription, setShowDescription] = useState(false);
const [error, setError] = useState<string | null>(null);
// 데이터 로드
useEffect(() => {
const loadVoting = async () => {
try {
setIsLoading(true);
const data = await mockFetchVotingDetail(votingId);
if (data) {
setVoting(data);
setSelectedOption(data.myVote);
} else {
setError('투표를 찾을 수 없습니다.');
}
} catch (err) {
setError('데이터를 불러오는데 실패했습니다.');
console.error(err);
} finally {
setIsLoading(false);
}
};
loadVoting();
}, [votingId]);
// 투표 핸들러
const handleVote = async () => {
if (!selectedOption || !voting?.isActive) return;
setIsSubmitting(true);
try {
// Mock API 호출 (실제로는 서버에 요청)
await new Promise((resolve) => setTimeout(resolve, 500));
// 로컬 상태 업데이트
setVoting((prev) => {
if (!prev) return prev;
const oldVote = prev.myVote;
const updatedOptions = prev.options.map((opt) => {
let newVoteCount = opt.voteCount;
if (oldVote === opt.id) {
newVoteCount--;
}
if (opt.id === selectedOption) {
newVoteCount++;
}
return { ...opt, voteCount: newVoteCount };
});
const newTotalVotes = oldVote ? prev.totalVotes : prev.totalVotes + 1;
const optionsWithPercentage = updatedOptions.map((opt) => ({
...opt,
percentage: (opt.voteCount / newTotalVotes) * 100,
}));
return {
...prev,
myVote: selectedOption,
options: optionsWithPercentage,
totalVotes: newTotalVotes,
};
});
} finally {
setIsSubmitting(false);
}
};
// 댓글 추가 핸들러
const handleAddComment = async (data: CommentFormData) => {
if (!voting) return;
const newComment: VotingComment = {
id: Date.now(),
author: { id: 999, nickname: '나' },
content: data.content,
createdAt: new Date().toISOString(),
isAuthor: true,
votedOption: voting.options.find((opt) => opt.id === voting.myVote)?.label,
};
setVoting((prev) => {
if (!prev) return prev;
return {
...prev,
comments: [...prev.comments, newComment],
commentCount: prev.commentCount + 1,
};
});
};
// 댓글 삭제 핸들러
const handleDeleteComment = (commentId: number) => {
setVoting((prev) => {
if (!prev) return prev;
return {
...prev,
comments: prev.comments.filter((c) => c.id !== commentId),
commentCount: prev.commentCount - 1,
};
});
};
// 로딩 상태
if (isLoading) {
return (
<div className="flex min-h-screen items-center justify-center bg-background-alternative">
<div className="flex flex-col items-center gap-400">
<Loader2 className="h-8 w-8 animate-spin text-text-brand" />
<p className="font-designer-16m text-text-subtle">투표를 불러오는 중...</p>
</div>
</div>
);
}
// 에러 상태
if (error || !voting) {
return (
<div className="flex min-h-screen items-center justify-center bg-background-alternative">
<div className="flex flex-col items-center gap-400">
<p className="font-designer-16m text-text-subtle">{error || '투표를 찾을 수 없습니다.'}</p>
<button
onClick={() => router.push('/insights/weekly')}
className="rounded-100 bg-fill-brand-default-default px-400 py-200 font-designer-14b text-text-inverse transition-colors hover:bg-fill-brand-default-hover"
>
목록으로 돌아가기
</button>
</div>
</div>
);
}
const hasVoted = voting.myVote !== undefined;
return (
<div className="min-h-screen bg-background-alternative">
<div className="mx-auto w-full max-w-screen-lg px-400 py-600">
{/* 뒤로가기 버튼 */}
<button
onClick={() => router.back()}
className="mb-400 flex items-center gap-100 font-designer-14m text-text-subtle transition-colors hover:text-text-strong"
>
<ArrowLeft className="h-4 w-4" />
돌아가기
</button>
{/* 헤더 */}
<div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
{/* 라운드 & 상태 */}
<div className="mb-300 flex items-center gap-200">
<div className="flex items-center gap-100 rounded-100 bg-fill-brand-default-default px-250 py-100">
<TrendingUp className="h-4 w-4 text-text-inverse" />
<span className="font-designer-13b text-text-inverse">{voting.round} 라운드</span>
</div>
<VoteTimer endsAt={voting.endsAt} isActive={voting.isActive} />
</div>
{/* 제목 */}
<h1 className="mb-200 font-bold-h3 text-text-strong">{voting.title}</h1>
{/* 설명 토글 */}
{voting.description && (
<>
<button
onClick={() => setShowDescription(!showDescription)}
className="mb-200 flex items-center gap-100 rounded-100 border border-border-subtle px-200 py-100 font-designer-12r text-text-subtle transition-colors hover:border-border-brand hover:text-text-brand"
>
<Info className="h-3.5 w-3.5" />
주제 설명 {showDescription ? '숨기기' : '보기'}
</button>
{showDescription && (
<p className="mb-200 rounded-100 border border-border-subtle bg-background-alternative p-300 font-designer-14r text-text-default">
{voting.description}
</p>
)}
</>
)}
{/* 태그 */}
{voting.tags.length > 0 && (
<div className="flex flex-wrap gap-100">
{voting.tags.map((tag) => (
<span
key={tag}
className="rounded-100 bg-fill-neutral-subtle-default px-150 py-50 font-designer-12r text-text-subtle"
>
#{tag}
</span>
))}
</div>
)}
</div>
{/* 투표 섹션 */}
<div className="mb-500 rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
{!hasVoted && voting.isActive ? (
<>
{/* 헤더 */}
<div className="mb-400 flex items-center justify-between">
<h2 className="font-designer-18b text-text-strong">투표해주세요</h2>
{/* 현재 투표 참여 인원 */}
<div className="flex items-center gap-200 rounded-100 border border-border-subtle bg-background-alternative px-300 py-150">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-fill-brand-default-default">
<TrendingUp className="h-4 w-4 text-text-inverse" />
</div>
<div className="flex flex-col">
<span className="font-designer-11r text-text-subtle">현재 참여</span>
<span className="font-designer-14b text-text-strong">
{voting.totalVotes.toLocaleString()}
</span>
</div>
</div>
</div>
{/* 선택지 */}
<div className="mb-400 flex flex-col gap-300">
{voting.options.map((option, index) => {
const isSelected = selectedOption === option.id;
const colors = [
{ border: 'border-blue-500', bg: 'bg-blue-50', text: 'text-blue-600', ring: 'ring-blue-500' },
{ border: 'border-green-500', bg: 'bg-green-50', text: 'text-green-600', ring: 'ring-green-500' },
{ border: 'border-purple-500', bg: 'bg-purple-50', text: 'text-purple-600', ring: 'ring-purple-500' },
{ border: 'border-orange-500', bg: 'bg-orange-50', text: 'text-orange-600', ring: 'ring-orange-500' },
{ border: 'border-pink-500', bg: 'bg-pink-50', text: 'text-pink-600', ring: 'ring-pink-500' },
];
const color = colors[index % colors.length];
return (
<button
key={option.id}
onClick={() => setSelectedOption(option.id)}
disabled={isSubmitting}
className={cn(
'group relative rounded-200 border-2 p-300 text-left transition-all duration-200',
isSelected
? cn('shadow-lg', color.border, color.bg)
: 'border-border-subtle bg-background-default hover:border-border-brand hover:shadow-1',
isSubmitting && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex items-center justify-between">
{/* 선택지 텍스트 */}
<div className="flex items-center gap-200">
{/* 번호 배지 */}
<div
className={cn(
'flex h-8 w-8 shrink-0 items-center justify-center rounded-full font-bold transition-all',
isSelected
? cn('text-white', color.border.replace('border-', 'bg-'))
: 'bg-fill-neutral-subtle-default text-text-subtle group-hover:bg-fill-neutral-strong-default group-hover:text-text-inverse',
)}
>
{index + 1}
</div>
<span
className={cn(
'font-designer-15b transition-colors',
isSelected ? color.text : 'text-text-default',
)}
>
{option.label}
</span>
</div>
{/* 선택 체크 표시 */}
{isSelected && (
<div className={cn('flex h-7 w-7 items-center justify-center rounded-full', color.border.replace('border-', 'bg-'))}>
<Check className="h-4 w-4 text-white" />
</div>
)}
</div>
</button>
);
})}
</div>
{/* 투표하기 버튼 */}
<button
onClick={handleVote}
disabled={!selectedOption || isSubmitting}
className={cn(
'w-full rounded-100 py-300 font-designer-15b text-text-inverse shadow-lg transition-all duration-200',
'bg-gradient-to-r from-fill-brand-default-default to-fill-brand-default-hover',
'disabled:cursor-not-allowed disabled:opacity-50 disabled:shadow-none',
'hover:scale-[1.02] hover:shadow-xl',
)}
>
{isSubmitting ? (
<div className="flex items-center justify-center gap-200">
<Loader2 className="h-5 w-5 animate-spin" />
투표 ...
</div>
) : (
'투표하기'
)}
</button>
</>
) : (
<>
<h2 className="mb-400 font-designer-18b text-text-strong">투표 결과</h2>
<VoteResultsChart
options={voting.options}
myVote={voting.myVote}
totalVotes={voting.totalVotes}
/>
</>
)}
</div>
{/* 일별 통계 (투표 후에만 표시) */}
{hasVoted && voting.dailyStats && voting.dailyStats.length > 0 && (
<div className="mb-500">
<DailyStatsChart
dailyStats={voting.dailyStats}
options={voting.options}
myVote={voting.myVote}
/>
</div>
)}
{/* 댓글 섹션 */}
<div className="rounded-200 border border-border-subtle bg-background-default p-500 shadow-1">
<div className="mb-400 flex items-center gap-100 font-designer-16b text-text-strong">
<MessageCircle className="h-5 w-5" />
<span>댓글 {voting.commentCount}</span>
</div>
{/* 댓글 목록 (항상 표시) */}
<div className="mb-400">
<CommentList
comments={voting.comments}
onDelete={handleDeleteComment}
votingOptions={voting.options}
/>
</div>
{/* 댓글 작성 폼 */}
{voting.isActive && (
<>
{!hasVoted ? (
/* 투표 안 했으면 댓글 작성 불가 안내 */
<div className="rounded-200 border border-border-subtle bg-background-alternative p-400 text-center">
<p className="font-designer-14m text-text-subtle">
투표 댓글을 작성할 있습니다
</p>
</div>
) : (
/* 투표 했으면 댓글 작성 폼 */
<div className="rounded-200 border border-border-subtle bg-background-alternative p-300">
<CommentForm onSubmit={handleAddComment} />
</div>
)}
</>
)}
</div>
</div>
</div>
<VotingDetailView
votingId={votingId}
onBack={() => router.back()}
/>
const params = useParams();
const router = useRouter();
const votingId = Number(params.id);
if (!Number.isFinite(votingId)) {
return <div className="p-400 text-text-subtle">잘못된 접근입니다.</div>;
}
return (
<VotingDetailView
votingId={votingId}
onBack={() => router.back()}
/>
🤖 Prompt for AI Agents
In `@src/app/`(service)/insights/weekly/[id]/page.tsx around lines 8 - 16,
Validate the dynamic id before rendering VotingDetailView: read params via
useParams(), ensure params.id is defined, convert it to a number and check
Number.isFinite/Number.isNaN (or Number.isInteger) to detect invalid numeric
values; if the id is missing or invalid, render a fallback UI (e.g., a not-found
message/component) or call router.back()/notFound() instead of passing NaN into
VotingDetailView; update the code around useParams, useRouter, votingId and the
VotingDetailView return to perform this guard.

Comment on lines 389 to +410
const LibraryCard = ({
item,
onLike,
onView,
onBookmark,
onSimilar,
onView
}: {
item: LibraryItem;
onLike: (e: React.MouseEvent, id: number) => void;
onView: (link: string) => void;
onBookmark: (e: React.MouseEvent, id: number) => void;
onSimilar: (e: React.MouseEvent, tag: string) => void;
}) => {
return (
<div className="flex h-full flex-col gap-250 rounded-200 border border-border-subtle bg-background-default p-400 shadow-1 transition-all hover:-translate-y-50 hover:shadow-2">
<div className="flex items-start justify-between gap-200">
<div className="flex flex-wrap items-center gap-100">
<span className="rounded-100 border border-border-subtle px-200 py-50 font-designer-12m text-text-subtle">
{CURATION_LABELS.level[item.level]}
</span>
{item.isRecommended && (
<span className="flex items-center gap-50 rounded-100 bg-fill-brand-subtle-default px-200 py-50 font-designer-12b text-text-brand">
<Sparkles className="h-3.5 w-3.5" />
추천
</span>
)}
</div>
<button
onClick={(e) => onBookmark(e, item.id)}
className={cn(
'flex items-center gap-50 rounded-100 px-150 py-50 font-designer-12m transition-colors',
item.isBookmarked
? 'bg-fill-neutral-strong-default text-text-inverse'
: 'bg-background-alternative text-text-subtle hover:bg-fill-neutral-subtle-hover',
)}
>
<Bookmark className="h-3.5 w-3.5" />
{item.isBookmarked ? '저장됨' : '저장'}
</button>
<div
onClick={() => onView(item.link)}
className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default"
>
<div className="flex items-center justify-between">
<span className="font-designer-13m text-text-disabled">{item.date}</span>
<ExternalLink className="w-4 h-4 text-text-subtlest opacity-0 group-hover:opacity-100 transition-opacity" />
</div>

<div className="flex flex-col gap-150">
<h3 className="font-bold-h5 text-text-strong line-clamp-2">
{item.title}
</h3>
<p className="font-designer-13r text-text-subtle line-clamp-2">
{item.description}
</p>
</div>

<div className="flex flex-wrap items-center gap-100">
{item.tags.map((tag) => (
<button
key={tag}
onClick={(e) => onSimilar(e, tag)}
className="rounded-100 bg-fill-neutral-subtle-default px-200 py-50 font-designer-12r text-text-subtle hover:bg-fill-neutral-subtle-hover"
>
#{tag}
</button>
))}
</div>
<h3 className="font-bold-h5 text-text-strong group-hover:text-text-information line-clamp-2 h-[60px] transition-colors leading-tight">
{item.title}
</h3>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Check if the file exists and get its size
stat src/app/\(service\)/one-on-one/page.tsx 2>/dev/null || ls -la src/app/*/one-on-one/page.tsx 2>/dev/null || find src -name "page.tsx" -path "*one-on-one*" 2>/dev/null

Repository: code-zero-to-one/study-platform-client

Length of output: 169


🏁 Script executed:

# List files in src/app to understand the directory structure
find src/app -type f -name "page.tsx" | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 1042


🏁 Script executed:

# Read the specific line ranges mentioned in the review
sed -n '389,410p' src/app/\(service\)/one-on-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 978


🏁 Script executed:

# Also read the second location mentioned (441-468)
sed -n '441,468p' src/app/\(service\)/one-on-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 1153


🏁 Script executed:

# Search for the onView function implementation to understand what it does
rg -A 10 "const onView" src/app/\(service\)/one-on-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 64


🏁 Script executed:

# Search for where onView is defined or used
rg "onView\s*=" src/app/\(service\)/one-on-one/page.tsx | head -20

Repository: code-zero-to-one/study-platform-client

Length of output: 177


🏁 Script executed:

# Look for the component that uses LibraryCard and LibraryRow to see onView definition
rg -B 5 "LibraryCard|LibraryRow" src/app/\(service\)/one-on-one/page.tsx | head -50

Repository: code-zero-to-one/study-platform-client

Length of output: 877


🏁 Script executed:

# Search for handleView function definition
rg -B 3 -A 10 "const handleView|function handleView" src/app/\(service\)/one-on-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 446


🏁 Script executed:

# Also check if there's any import related to opening links/windows
rg "window.open|target.*blank" src/app/\(service\)/one-on-one/page.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 114


Make the clickable card/row keyboard-accessible and add security hardening for new-tab opens.

Both the LibraryCard and LibraryRow components use clickable <div> elements that lack keyboard accessibility features. Keyboard users cannot focus or activate these elements. Additionally, the handleView function opens links with window.open(link, '_blank') without noopener or noreferrer protection, exposing the site to reverse-tabnabbing attacks.

Add role="link", tabIndex={0}, aria-label, and an onKeyDown handler for Enter/Space keys to both components:

♿️ Suggested fixes
-    <div 
-      onClick={() => onView(item.link)}
-      className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default"
-    >
+    <div
+      role="link"
+      tabIndex={0}
+      aria-label={`Open ${item.title}`}
+      onClick={() => onView(item.link)}
+      onKeyDown={(e) => {
+        if (e.key === 'Enter' || e.key === ' ') {
+          e.preventDefault();
+          onView(item.link);
+        }
+      }}
+      className="group flex cursor-pointer flex-col gap-200 rounded-200 border border-border-subtle bg-background-default p-400 transition-all hover:-translate-y-50 hover:shadow-2 hover:border-border-default"
+    >

Apply the same changes to LibraryRow (lines 441–468).

Update the handler to include security hardening:

const handleView = (link: string) => {
  const newWindow = window.open(link, '_blank', 'noopener,noreferrer');
  if (newWindow) newWindow.opener = null;
};
🤖 Prompt for AI Agents
In `@src/app/`(service)/one-on-one/page.tsx around lines 389 - 410, LibraryCard
and LibraryRow use clickable <div>s that are not keyboard-accessible and the
view handler opens a new tab without noopener/noreferrer; update both components
(LibraryCard and LibraryRow) to add role="link", tabIndex={0}, a descriptive
aria-label, and an onKeyDown that triggers the same action on Enter and Space
keys so keyboard users can focus and activate the element, and change the view
handler (handleView) to call window.open(link, '_blank', 'noopener,noreferrer')
and then, if a window object is returned, set newWindow.opener = null for
reverse-tabnabbing protection.

Comment on lines +78 to +95
<div onClick={(e) => e.stopPropagation()}>
<UserProfileModal
memberId={discussion.author.id}
trigger={
<div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default">
<div>
<UserAvatar
size={32}
image={discussion.author.avatar}
className="relative z-10"
/>
</div>
<span className="font-designer-13b text-text-default">
{discussion.author.nickname}
</span>
</div>
}
/>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Make the profile trigger keyboard-accessible.
The modal trigger is a plain <div>, which isn’t focusable or operable by keyboard users. Use a <button> (or add role/tabIndex/keydown handlers) to avoid an accessibility blocker.

♿ Suggested fix (button trigger)
-              <div onClick={(e) => e.stopPropagation()}>
-                <UserProfileModal
-                  memberId={discussion.author.id}
-                  trigger={
-                    <div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default">
-                      <div>
-                        <UserAvatar 
-                          size={32} 
-                          image={discussion.author.avatar}
-                          className="relative z-10"
-                        />
-                      </div>
-                      <span className="font-designer-13b text-text-default">
-                        {discussion.author.nickname}
-                      </span>
-                    </div>
-                  }
-                />
-              </div>
+              <UserProfileModal
+                memberId={discussion.author.id}
+                trigger={
+                  <button
+                    type="button"
+                    onClick={(e) => e.stopPropagation()}
+                    className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"
+                  >
+                    <div>
+                      <UserAvatar
+                        size={32}
+                        image={discussion.author.avatar}
+                        className="relative z-10"
+                      />
+                    </div>
+                    <span className="font-designer-13b text-text-default">
+                      {discussion.author.nickname}
+                    </span>
+                  </button>
+                }
+              />
📝 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
<div onClick={(e) => e.stopPropagation()}>
<UserProfileModal
memberId={discussion.author.id}
trigger={
<div className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default">
<div>
<UserAvatar
size={32}
image={discussion.author.avatar}
className="relative z-10"
/>
</div>
<span className="font-designer-13b text-text-default">
{discussion.author.nickname}
</span>
</div>
}
/>
<UserProfileModal
memberId={discussion.author.id}
trigger={
<button
type="button"
onClick={(e) => e.stopPropagation()}
className="flex items-center gap-200 cursor-pointer rounded-full px-200 py-100 transition-shadow duration-100 ring-1 ring-inset ring-transparent hover:ring-fill-brand-default-default"
>
<div>
<UserAvatar
size={32}
image={discussion.author.avatar}
className="relative z-10"
/>
</div>
<span className="font-designer-13b text-text-default">
{discussion.author.nickname}
</span>
</button>
}
/>
🤖 Prompt for AI Agents
In `@src/components/discussion/discussion-detail-modal.tsx` around lines 78 - 95,
Replace the non-focusable trigger div passed to UserProfileModal's trigger prop
with a semantic, keyboard-focusable button: change the inner element used in
trigger (currently the <div className="flex items-center ..."> with UserAvatar
and nickname) to a <button type="button"> keeping the same className and styles,
add an accessible name (aria-label e.g. `${discussion.author.nickname} profile`
or ensure visible text remains), and ensure click propagation is still stopped
by keeping the outer onClick={(e) => e.stopPropagation()} wrapper; this makes
UserProfileModal's trigger keyboard-operable without changing visual styling.

Comment on lines +115 to +128
...Array.from({ length: 10 }, (_, i): LibraryItem => ({
id: 10 + i,
title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.',
author: 'Admin',
date: `2024.12.${20 - i}`,
views: 100 + i * 10,
likes: 10 + i,
link: 'https://google.com',
isLiked: Math.random() > 0.5,
level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED',
tags: ['면접', '자료구조', 'CS'],
isBookmarked: Math.random() > 0.7,
})),
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Math.random() in module-level mock data causes SSR hydration mismatch.

Since this is a 'use client' component, the mock data with Math.random() is evaluated at module load time. When SSR renders on the server and then hydrates on the client, the random values will differ, causing React hydration warnings.

Consider using deterministic values for mock data or moving the randomization inside a useEffect to ensure it only runs on the client.

🛠️ Suggested fix using deterministic mock data
 ...Array.from({ length: 10 }, (_, i): LibraryItem => ({
   id: 10 + i,
   title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
   description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.',
   author: 'Admin',
   date: `2024.12.${20 - i}`,
   views: 100 + i * 10,
   likes: 10 + i,
   link: 'https://google.com',
-  isLiked: Math.random() > 0.5,
+  isLiked: i % 2 === 0,
   level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED',
   tags: ['면접', '자료구조', 'CS'],
-  isBookmarked: Math.random() > 0.7,
+  isBookmarked: i % 4 === 0,
 })),
📝 Committable suggestion

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

Suggested change
...Array.from({ length: 10 }, (_, i): LibraryItem => ({
id: 10 + i,
title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.',
author: 'Admin',
date: `2024.12.${20 - i}`,
views: 100 + i * 10,
likes: 10 + i,
link: 'https://google.com',
isLiked: Math.random() > 0.5,
level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED',
tags: ['면접', '자료구조', 'CS'],
isBookmarked: Math.random() > 0.7,
})),
...Array.from({ length: 10 }, (_, i): LibraryItem => ({
id: 10 + i,
title: `개발자 면접 대비 - 자료구조 핵심 질문 ${i + 1}탄`,
description: '자주 출제되는 자료구조 면접 질문과 모범 답변을 정리했습니다.',
author: 'Admin',
date: `2024.12.${20 - i}`,
views: 100 + i * 10,
likes: 10 + i,
link: 'https://google.com',
isLiked: i % 2 === 0,
level: i % 3 === 0 ? 'BEGINNER' : i % 3 === 1 ? 'INTERMEDIATE' : 'ADVANCED',
tags: ['면접', '자료구조', 'CS'],
isBookmarked: i % 4 === 0,
})),
🤖 Prompt for AI Agents
In `@src/components/home/tabs/archive-tab.tsx` around lines 115 - 128, The
module-level Array.from mock data in archive-tab.tsx uses Math.random() (for
isLiked/isBookmarked) which causes SSR/client hydration mismatches; remove
Math.random from the module scope and either (A) make the mock values
deterministic (e.g., derive isLiked/isBookmarked from the index like i % 2 ===
0) in the Array.from expression, or (B) keep deterministic module data and
perform client-only randomization inside the ArchiveTab component using useState
+ useEffect to set isLiked/isBookmarked on mount (so randomization runs only on
the client). Target the Array.from({...}) generation in archive-tab.tsx and the
ArchiveTab component state initialization when applying the fix.

Comment on lines +363 to +365
const handleView = (link: string) => {
window.open(link, '_blank');
};
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add security attributes to window.open call.

Opening external links without noopener,noreferrer can expose the application to security risks (e.g., the opened page can access window.opener).

🔒 Proposed fix
 const handleView = (link: string) => {
-  window.open(link, '_blank');
+  window.open(link, '_blank', 'noopener,noreferrer');
 };
📝 Committable suggestion

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

Suggested change
const handleView = (link: string) => {
window.open(link, '_blank');
};
const handleView = (link: string) => {
window.open(link, '_blank', 'noopener,noreferrer');
};
🤖 Prompt for AI Agents
In `@src/components/home/tabs/archive-tab.tsx` around lines 363 - 365, The
handleView function opens external links with window.open(link, '_blank') which
leaves window.opener available; update handleView to open the link in a way that
prevents the opened page from accessing window.opener and removes referrer info
— either create an anchor element with target="_blank" and rel="noopener
noreferrer" and click it, or capture the returned window from window.open and
set newWindow.opener = null (and include 'noreferrer' if using feature string)
before returning; locate and modify the handleView function to implement one of
these secure patterns.

Comment on lines +47 to +62
{dayItems.map((item) => (
<div
key={item.id}
className={cn(
'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium',
item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success',
item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning',
)}
title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`}
>
{item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}...
</div>
))}
{dayItems.length > 2 && (
<div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div>
)}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Inconsistent item display logic - shows all items but also "+N more" indicator.

Currently, all dayItems are rendered (line 47), but a "+N개 더" message still appears when there are more than 2 items (line 60). This results in showing all items and indicating there are more items, which is confusing.

Either limit the displayed items to 2 and show the overflow count, or remove the overflow indicator.

🔧 Proposed fix - limit to first 2 items
-        {dayItems.map((item) => (
+        {dayItems.slice(0, 2).map((item) => (
           <div
             key={item.id}
             className={cn(
📝 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
{dayItems.map((item) => (
<div
key={item.id}
className={cn(
'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium',
item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success',
item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning',
)}
title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`}
>
{item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}...
</div>
))}
{dayItems.length > 2 && (
<div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div>
)}
{dayItems.slice(0, 2).map((item) => (
<div
key={item.id}
className={cn(
'text-[10px] px-50 py-25 rounded-25 mb-25 truncate font-medium',
item.attendance === 'ATTENDED' && 'bg-fill-success-subtle-default text-text-success',
item.attendance === 'NOT_STARTED' && 'bg-fill-warning-subtle-default text-text-warning',
)}
title={`${item.subject} - ${item.role === 'INTERVIEWER' ? '면접자' : '답변자'}`}
>
{item.role === 'INTERVIEWER' ? '🎤' : '🙋'} {item.subject.slice(0, 8)}...
</div>
))}
{dayItems.length > 2 && (
<div className="text-[9px] text-text-subtle font-bold">+{dayItems.length - 2}개 더</div>
)}
🤖 Prompt for AI Agents
In `@src/components/study-history/study-calendar.tsx` around lines 47 - 62, The
component currently renders all dayItems via dayItems.map(...) while also
showing an overflow indicator when dayItems.length > 2; change the rendering to
only display the first two items (use dayItems.slice(0, 2) or compute a
displayedItems array) in the map (the JSX that uses key={item.id}, title, and
role/subject rendering) and keep the existing overflow indicator using
dayItems.length - displayedItems.length so "+N개 더" correctly reflects hidden
items.

Comment on lines +51 to +60
<div className="col-span-1 flex justify-center">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default"
>
<ExternalLink className="w-4 h-4" />
</a>
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Add an accessible label to the external-link icon.
The icon-only link lacks an accessible name; screen readers will announce it as a generic “link.”

♿ Suggested fix
         {item.link ? (
           <a
             href={item.link}
             target="_blank"
             rel="noopener noreferrer"
+            aria-label="외부 링크 열기"
             className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default"
           >
             <ExternalLink className="w-4 h-4" />
           </a>
📝 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
<div className="col-span-1 flex justify-center">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default"
>
<ExternalLink className="w-4 h-4" />
</a>
<div className="col-span-1 flex justify-center">
{item.link ? (
<a
href={item.link}
target="_blank"
rel="noopener noreferrer"
aria-label="외부 링크 열기"
className="text-text-subtle hover:text-text-brand transition-colors p-100 rounded-full hover:bg-fill-neutral-default-default"
>
<ExternalLink className="w-4 h-4" />
</a>
🤖 Prompt for AI Agents
In `@src/components/study-history/study-history-row.tsx` around lines 51 - 60, The
external-link anchor rendering when item.link is present lacks an accessible
name; update the anchor that wraps ExternalLink (the <a> element in
StudyHistoryRow / where item.link is used) to include an accessible label —
either add an aria-label (e.g. aria-label={`Open ${item.title} in new tab`} or
similar) or include a visually-hidden text node (a <span
className="sr-only">Open {item.title} in new tab</span>) inside the anchor so
screen readers announce the purpose of the link while preserving the icon-only
visual.

Comment on lines +16 to +21
const memberIdStr = await getServerCookie('memberId');
const memberId = Number(memberIdStr);

const userProfile = await getUserProfileInServer(memberId);
const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);

Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Guard against invalid memberId from cookies.

Line 17 converts the cookie to a number without validation; NaN will call /members/NaN/profile and likely throw. Add a guard/redirect before fetching.

🔧 Suggested fix
 import { getServerCookie } from '@/utils/server-cookie';
+import { redirect } from 'next/navigation';
 
 export default async function HomeDashboard() {
   const memberIdStr = await getServerCookie('memberId');
   const memberId = Number(memberIdStr);
+  if (!Number.isFinite(memberId)) {
+    redirect('/login');
+  }
📝 Committable suggestion

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

Suggested change
const memberIdStr = await getServerCookie('memberId');
const memberId = Number(memberIdStr);
const userProfile = await getUserProfileInServer(memberId);
const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);
const memberIdStr = await getServerCookie('memberId');
const memberId = Number(memberIdStr);
if (!Number.isFinite(memberId)) {
redirect('/login');
}
const userProfile = await getUserProfileInServer(memberId);
const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);
🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` around lines 16 - 21, The code currently
casts the cookie value returned by getServerCookie('memberId') into a Number
(memberId) without validating it, so if memberIdStr is missing or not numeric
you end up calling getUserProfileInServer(NaN); add a guard: check memberIdStr
exists and that Number.isInteger(Number(memberIdStr)) (or parseInt and validate)
and if invalid perform the proper redirect/early return before calling
getUserProfileInServer and getSincerityPresetByLevelName; update logic around
memberIdStr, memberId, getUserProfileInServer, and getSincerityPresetByLevelName
to only run when memberId is a valid integer.

Comment on lines +19 to +43
const userProfile = await getUserProfileInServer(memberId);
const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);

// 데이터 가공
const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정';
const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능';
const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정';
const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3);

return (
<div className="w-full space-y-300">
{/* 상단 프로필 바 */}
<div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm">
<div className="flex items-center gap-300">
<div className="relative">
<UserAvatar
size={64}
image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''}
/>
<div
className={cn(
'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm',
temperPreset.bgClass,
temperPreset.textClass,
)}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Add a fallback when sincerity preset is missing.

getSincerityPresetByLevelName() can return undefined for unexpected or new levels, causing temperPreset.bgClass / textClass to throw. Add a safe default.

🛡️ Suggested fix
-  const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);
+  const temperPreset =
+    getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) ?? {
+      bgClass: 'bg-fill-neutral-subtle-default',
+      textClass: 'text-text-subtle',
+    };
📝 Committable suggestion

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

Suggested change
const userProfile = await getUserProfileInServer(memberId);
const temperPreset = getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName);
// 데이터 가공
const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정';
const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능';
const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정';
const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3);
return (
<div className="w-full space-y-300">
{/* 상단 프로필 바 */}
<div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm">
<div className="flex items-center gap-300">
<div className="relative">
<UserAvatar
size={64}
image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''}
/>
<div
className={cn(
'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm',
temperPreset.bgClass,
temperPreset.textClass,
)}
const userProfile = await getUserProfileInServer(memberId);
const temperPreset =
getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) ?? {
bgClass: 'bg-fill-neutral-subtle-default',
textClass: 'text-text-subtle',
};
// 데이터 가공
const subject = userProfile?.memberInfo.preferredStudySubject?.name || '미설정';
const timeSlots = userProfile?.memberInfo.availableStudyTimes?.map(t => t.label).join(', ') || '시간 협의 가능';
const techStacks = userProfile?.memberProfile.techStacks?.slice(0, 3).map(t => t.techStackName).join(' · ') || '미설정';
const extraTechCount = Math.max(0, (userProfile?.memberProfile.techStacks?.length || 0) - 3);
return (
<div className="w-full space-y-300">
{/* 상단 프로필 바 */}
<div className="flex items-center justify-between rounded-200 border border-border-subtle bg-gradient-to-r from-background-default to-background-alternative px-400 py-300 shadow-sm">
<div className="flex items-center gap-300">
<div className="relative">
<UserAvatar
size={64}
image={userProfile?.memberProfile?.profileImage?.resizedImages[0]?.resizedImageUrl || ''}
/>
<div
className={cn(
'absolute -bottom-50 -right-50 flex h-[28px] items-center justify-center rounded-full px-100 font-designer-11b shadow-sm',
temperPreset.bgClass,
temperPreset.textClass,
)}
🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` around lines 19 - 43,
getSincerityPresetByLevelName can return undefined, so update the code that
assigns temperPreset (used in the JSX: temperPreset.bgClass and
temperPreset.textClass) to ensure a safe default object is used when undefined;
e.g., after calling
getSincerityPresetByLevelName(userProfile.sincerityTemp.levelName) coalesce to a
fallback preset with safe bgClass and textClass values (or destructure with
defaults) so the JSX rendering of the profile badge always has valid class
strings.

<h3 className="font-designer-15b text-text-default">의견 보내기</h3>
</div>

<FeedbackLink variant="compact" className="w-full" />
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/widgets/home/feedback-link.tsx

Repository: code-zero-to-one/study-platform-client

Length of output: 1310


Remove unsupported props from FeedbackLink component.

The FeedbackLink component at src/widgets/home/feedback-link.tsx does not accept any props—it's defined as function FeedbackLink() with no parameters. Passing variant="compact" and className="w-full" will fail type-checking and the props will be ignored at runtime. Either remove these props or update the FeedbackLink component signature to accept and use them.

🤖 Prompt for AI Agents
In `@src/widgets/home/home-dashboard.tsx` at line 176, The FeedbackLink usage is
passing unsupported props (variant="compact", className="w-full") which the
component doesn't accept; fix by either removing those props from the JSX in the
home dashboard (replace <FeedbackLink variant="compact" className="w-full" />
with <FeedbackLink />) or update the FeedbackLink component signature (function
FeedbackLink) in feedback-link.tsx to accept and apply props (e.g., props: {
variant?: string; className?: string } and forward className/variant to the
rendered element); choose one approach and ensure types are updated so the props
are consumed or not passed.

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