Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
📝 WalkthroughWalkthrough이번 PR은 추천 상품 기능을 더미 데이터에서 실제 API 기반으로 전환합니다. React Query 훅( Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes 🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/hooks/summary/useSummaryList.ts (1)
32-41: 키워드 파싱 로직 일관성 확인 필요이 파일에서는 JSON 파싱 실패 시
[]를 반환하지만,useBookmarkedSummaryList.ts에서는[item.keywords]를 반환합니다. 동일한 데이터 소스를 처리하므로 일관된 동작이 필요합니다.🔧 일관성 수정 제안
keywords: typeof item.keywords === 'string' ? (() => { try { return JSON.parse(item.keywords); } catch { - return []; + return [item.keywords]; } })() : (item.keywords ?? []),src/app/(private)/summary/bookmarks/page.tsx (1)
107-120: 훅의 타입 계약을 신뢰하고 불필요한 변환 제거하기
useBookmarkedSummaryList훅의BookmarkedSummaryListItem인터페이스에서keywords: string[]로 명확히 정의하고 있으며, 훅의 구현부(queryFn)에서 이미 모든 경우의 수를 처리하여 항상string[]을 반환합니다. 따라서 page.tsx line 108-109의typeof summary.keywords === 'string'체크는 불필요합니다.// 현재 코드 (불필요한 중복 체크) const badges = typeof summary.keywords === 'string' ? [summary.keywords] : summary.keywords; // 개선 코드 (훅의 타입 계약 신뢰) const badges = summary.keywords;이유:
- 단일 책임 원칙: keywords 정규화는 훅의 책임이며, page에서 재검증하면 관심사가 분산됩니다
- 타입 안정성: TypeScript의 타입 시스템을 완전히 활용하면 런타임 체크 불필요
- 가독성: 불필요한 조건부 로직이 제거되어 코드가 간결해집니다
🤖 Fix all issues with AI agents
In `@src/app/`(private)/recommend/page.tsx:
- Around line 32-39: When !isLoading && !isError and categories is empty the
page currently renders nothing; update the render logic in page.tsx around the
block using categories.map(...) to detect categories.length === 0 and render a
concise empty-state UI (e.g., an EmptyState component or a message/card with
icon and a call-to-action) instead of mapping; keep the existing ProductList
rendering for the non-empty case and reuse products.filter((p) => p.categoryId
=== i.id) as before.
In `@src/app/`(private)/summary/_components/SummarySuccessPage.tsx:
- Around line 156-163: The error UI currently renders when recommendError is
true but calls refetchRecommend() without handling its returned Promise and the
retry button lacks accessibility labeling; update the onClick handler in
SummarySuccessPage to call refetchRecommend and handle the Promise (e.g., await
in an async handler or use .catch to handle errors and optionally set loading
state), and add an appropriate aria-label to the retry button (e.g.,
aria-label="Retry loading recommended products") so screen readers can describe
the action.
In `@src/app/`(private)/summary/[summaryId]/recommended-products/page.tsx:
- Around line 25-35: Wrap the status messages rendered in the conditional blocks
for isLoading, isError, and empty products (the JSX shown under the isLoading,
isError, and products.length === 0 checks in page.tsx) in accessible live
regions by adding appropriate ARIA attributes: use role="status" with
aria-live="polite" (and aria-atomic="true") for non-critical info like the
loading and empty messages, and use role="alert" with aria-live="assertive" for
the error block so screen readers announce it immediately; update the
corresponding div elements (the ones containing "추천 상품 불러오는 중...", the error
message with the retry button, and "추천 상품이 없습니다.") to include these attributes.
- Around line 11-20: Currently safeId defaults to 0 which triggers
useSummaryDetail and useRecommendSummary to call APIs for id 0; change the logic
to treat invalid summaryId as disabled by deriving a boolean like isValidId =
Number.isFinite(summaryId) && summaryId > 0 and pass that to the hooks (e.g.,
via query options or an "enabled" prop) so neither useSummaryDetail(safeId) nor
useRecommendSummary(safeId) runs when isValidId is false, and render an
immediate UX fallback (redirect, error message, or NotFound) when isValidId is
false instead of letting the hooks fetch with id 0; update references to rawId,
summaryId, safeId, useSummaryDetail and useRecommendSummary accordingly.
In `@src/components/counseling-recommend/recommend-card/index.tsx`:
- Around line 7-11: Remove the unused is_monthly prop from the RecommendCard
API: delete is_monthly from the RecommendCardProps type and remove it from the
destructuring in the RecommendCard component (where props are unpacked) so the
component no longer declares or expects it; then remove is_monthly={true} from
the parent component recommend-cardlist where RecommendCard is instantiated.
Also search for any other references to is_monthly in this component and the
parent and delete or refactor them if present to ensure no dangling references
remain.
In `@src/components/recommend/product-goods/index.tsx`:
- Around line 11-12: Replace the unconditional Link wrapper around the product
card with conditional rendering based on product.link: when product.link is
truthy, render the Link (preserving target="_blank" and rel="noopener
noreferrer") wrapping the card markup; when product.link is null/undefined,
render the same product card markup without Link and apply a
disabled/visually-distinct style and ARIA hint (e.g., aria-disabled or role) to
the outer div so the card remains accessible—update the component using the
existing Link usage and the product.link check around the card container.
In `@src/components/recommend/product-plans/index.tsx`:
- Around line 9-10: The component signature exposes a monthly prop but it is
unused—either remove the monthly prop from the ProductPlans component props or
implement its intended behavior (e.g., adjust displayed price or CSS) wherever
pricing is rendered; also change how Link is rendered so when product.link is
falsy you do not set href="#" with target="_blank"/rel (use a plain non-anchor
wrapper or render Link without target/rel when product.link is missing) — locate
the Link usage and the product.link reference in the component and update
handling accordingly.
- Line 5: The ProductPlans component declares a monthly prop that is never used
and the component itself appears unused; either remove the entire ProductPlans
export to eliminate dead code (delete the function and its export) or implement
monthly into the rendering logic of ProductPlans (update the ProductPlans
function to accept { product, monthly } and use monthly to toggle monthly vs
yearly pricing display or conditional JSX where pricing is rendered), and ensure
any callers/imports are updated or the export removed; reference the
ProductPlans component and the monthly prop when making the change.
In `@src/hooks/recommend/useRecommendSummary.ts`:
- Around line 16-37: mapRecommendSummary currently calls
getCategoryInfo(item.categoryId) without guarding against unknown/new category
IDs which can throw or return invalid data; update mapRecommendSummary to
validate the result of getCategoryInfo (or wrap the call in try/catch) and skip
or provide a safe fallback when a category is missing so the page won't crash:
only call categories.set(category.id, category) when category is non-null/has an
id, and handle products for which category lookup failed (e.g., skip adding the
product, set categoryId to null/safe default, and/or log a warning). Ensure
changes are made inside mapRecommendSummary and reference getCategoryInfo,
categories (Map), and products (ProductProps[]) so runtime errors are prevented.
🧹 Nitpick comments (9)
src/types/product/mapper.ts (1)
14-23:CategoryInfo타입과 실제 반환 필드가 어긋납니다.
getCategoryInfo는...meta로isPlan/isMonthly까지 반환하지만 타입에는 없어 호출부에서 타입 안전하게 사용하기 어렵습니다. 타입을 확장하거나(아래) 반환을label만으로 축소해 일관성을 맞춰 주세요.♻️ 한 가지 정리안(타입 확장)
export type CategoryInfo = { id: CategoryId; key: CategoryKey; label: string; + isPlan: boolean; + isMonthly: boolean; };As per coding guidelines, 가독성과 안정성(에러/예외 처리)을 최우선으로 검토하고, 로직이 단순하고 직관적으로 읽히는지 확인.
src/lib/queryKeys.ts (1)
7-10:recommend.summary쿼리키에 명시적인 스코프 세그먼트를 추가해 주세요.현재
['recommend', summaryId]는 TanStack Query 공식 가이드에서 권장하는['resource', 'scope', ...identifiers]패턴을 따르지 않습니다. 같은 recommend 객체 내me()메서드가 이미['recommend', 'me']로 스코프를 명시하고 있는 만큼, summary도 동일하게['recommend', 'summary', summaryId]로 통일하면:
- 캐시 무효화 전략이 명확해집니다 —
invalidateQueries({ queryKey: ['recommend', 'summary'] })로 요약 관련 쿼리만 선택적으로 무효화 가능- 쿼리 계층 구조가 일관성 있게 유지됩니다 — 향후 새로운 스코프 추가 시 확장이 용이
- 세그먼트 의미가 분명해집니다 — summaryId가 요약의 ID임이 명확함
♻️ 제안 변경
- summary: (summaryId: number) => ['recommend', summaryId] as const, + summary: (summaryId: number) => ['recommend', 'summary', summaryId] as const,src/services/recommend/recommendApi.ts (1)
25-34: axios 응답 타입을 명시해 타입 안정성을 높이세요.
axios 타입 정의에서get<T>()제네릭을 제공하므로, 응답 스키마를 컴파일 타임에 검증할 수 있습니다.🔧 제안 변경
-export const fetchRecommendMe = async (): Promise<RecommendMeResponse> => { - const res = await apiClient.get('/api/v1/recommend/me'); +export const fetchRecommendMe = async (): Promise<RecommendMeResponse> => { + const res = await apiClient.get<RecommendMeResponse>('/api/v1/recommend/me'); return res.data; }; export const fetchRecommendSummary = async ( summaryId: number, ): Promise<RecommendSummaryResponse> => { - const res = await apiClient.get(`/api/v1/recommend/${summaryId}`); + const res = await apiClient.get<RecommendSummaryResponse>( + `/api/v1/recommend/${summaryId}`, + ); return res.data; };src/hooks/recommend/useRecommendMe.ts (1)
10-48: 매핑 로직 중복을 분리해 확장성을 높여주세요.
useRecommendMe와useRecommendSummary의 매핑 로직이 거의 동일해 변경 시 누락 위험이 큽니다. 공통 mapper로 분리하고, 카테고리/상품 매핑에 대한 간단한 단위 테스트를 추가하는 것을 권장합니다.src/app/(private)/recommend/page.tsx (1)
11-46: 컴포넌트 이름을 PascalCase로 통일해주세요.
React DevTools 가독성과 컨벤션 측면에서page보다RecommendPage같은 이름이 더 명확합니다.✏️ 제안 변경
-const page = () => { +const RecommendPage = () => { const { data, isLoading, isError, refetch } = useRecommendMe(); const categories = data?.categories ?? []; const products = data?.products ?? []; @@ -export default page; +export default RecommendPage;src/hooks/summary/useBookmarkedSummaryList.ts (1)
32-43: 키워드 파싱 로직 유틸리티 함수로 추출 권장이 키워드 파싱 IIFE가
useSummaryList.ts와src/app/(private)/bookmark/page.tsx에서도 유사하게 반복됩니다. 유지보수성과 DRY 원칙을 위해 공통 유틸리티 함수로 추출하면 좋겠습니다.♻️ 유틸리티 함수 추출 제안
// src/utils/parseKeywords.ts export const parseKeywords = (keywords: unknown): string[] => { if (Array.isArray(keywords)) return keywords; if (typeof keywords === 'string') { try { const parsed = JSON.parse(keywords); return Array.isArray(parsed) ? parsed : [keywords]; } catch { return [keywords]; } } return []; };그 후 훅에서:
- keywords: (() => { - if (Array.isArray(item.keywords)) return item.keywords; - if (typeof item.keywords === 'string') { - try { - const parsed = JSON.parse(item.keywords); - return Array.isArray(parsed) ? parsed : [item.keywords]; - } catch { - return [item.keywords]; - } - } - return []; - })(), + keywords: parseKeywords(item.keywords),src/app/(private)/summary/_components/SummarySuccessPage.tsx (1)
144-174: 추천 상품 섹션 컴포넌트 분리 고려
SummarySuccessPage가 북마크, 상담 주제, 핵심 요약, 추천 상품 등 여러 섹션을 담당하고 있습니다. Clean Architecture 관점에서 추천 상품 섹션을 별도 컴포넌트로 분리하면 테스트와 유지보수가 용이해집니다.♻️ 컴포넌트 분리 예시
// RecommendProductsSection.tsx interface RecommendProductsSectionProps { summaryId: number; } export const RecommendProductsSection = ({ summaryId }: RecommendProductsSectionProps) => { const { data, isLoading, isError, refetch } = useRecommendSummary(summaryId); const products = data?.products ?? []; return ( <div className="mt-6 px-4"> <div className="mb-2 flex items-center gap-2"> <Image src={BoxText} alt="" width={15} height={15} /> <h2 className="text-sm font-semibold">추천 상품</h2> </div> {/* ... 조건부 렌더링 로직 */} </div> ); };src/app/(private)/summary/bookmarks/page.tsx (1)
26-36: 헤더 컴포넌트 중복 제거 권장동일한 헤더 JSX가 4개의 조건부 렌더링 분기에서 반복됩니다. 헤더를 한 번만 렌더링하고 콘텐츠 영역만 조건부로 처리하면 유지보수성이 향상됩니다.
♻️ 리팩토링 제안
export default function BookmarkedSummaryPage() { const router = useRouter(); const { data: me, isLoading: meLoading, isError: meError } = useMe(); const userId = me?.id; const { data: bookmarked, isLoading: listLoading, isError: listError, } = useBookmarkedSummaryList(userId); + const renderContent = () => { + if (meLoading || listLoading) { + return <div className="py-12 text-center text-sm text-gray-400">북마크 불러오는 중...</div>; + } + if (meError || !Number.isFinite(userId)) { + return <div className="py-12 text-center text-sm text-gray-400">로그인이 필요합니다</div>; + } + if (listError) { + return <div className="py-12 text-center text-sm text-gray-400">북마크한 상담내역을 불러오지 못했습니다...</div>; + } + return ( + <div className="flex flex-col gap-6 pt-6 pb-24"> + {/* 정상 렌더링 로직 */} + </div> + ); + }; return ( <> <div className="relative flex h-14 items-center bg-white"> <button type="button" aria-label="뒤로가기" onClick={() => router.push('/mypage')} className="ml-4 flex items-center" > <GoBack /> </button> <p className="absolute left-1/2 -translate-x-1/2 text-sm font-semibold">북마크 상담</p> </div> + {renderContent()} <BottomNav /> </> ); }src/app/(private)/summary/[summaryId]/recommended-products/page.tsx (1)
22-37: 상태 분기(로딩/에러/빈/성공) 테스트가 필요합니다.UI 상태가 많아 회귀 위험이 있습니다. 대안: React Testing Library로 각 상태 스냅샷/동작 테스트를 추가하세요. 장점: 회귀 방지, 안정성 향상. 단점: 테스트 코드가 늘어납니다.
| {isError && ( | ||
| <div className="px-6 py-4 text-sm text-red-600"> | ||
| Failed to load recommendations. | ||
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | ||
| Retry | ||
| </button> | ||
| </div> |
There was a problem hiding this comment.
에러 상태 메시지 i18n + 접근성 알림을 추가해주세요.
현재 문구가 영어라 UI 언어와 불일치하고, 스크린리더 알림도 없습니다. role="alert"/aria-live 적용과 한글 문구를 권장합니다.
♿ 제안 변경
- {isError && (
- <div className="px-6 py-4 text-sm text-red-600">
- Failed to load recommendations.
- <button type="button" className="ml-2 underline" onClick={() => refetch()}>
- Retry
- </button>
- </div>
- )}
+ {isError && (
+ <div className="px-6 py-4 text-sm text-red-600" role="alert" aria-live="polite">
+ 추천 항목을 불러오지 못했습니다.
+ <button type="button" className="ml-2 underline" onClick={() => refetch()}>
+ 다시 시도
+ </button>
+ </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.
| {isError && ( | |
| <div className="px-6 py-4 text-sm text-red-600"> | |
| Failed to load recommendations. | |
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | |
| Retry | |
| </button> | |
| </div> | |
| {isError && ( | |
| <div className="px-6 py-4 text-sm text-red-600" role="alert" aria-live="polite"> | |
| 추천 항목을 불러오지 못했습니다. | |
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} |
| {!isLoading && | ||
| !isError && | ||
| categories.map((i) => ( | ||
| <ProductList | ||
| key={i.id} | ||
| category={i} | ||
| products={products.filter((p) => p.categoryId === i.id)} | ||
| /> |
There was a problem hiding this comment.
데이터가 비어 있을 때 빈 상태 UI를 추가해주세요.
현재는 categories가 비어 있으면 아무 것도 렌더링되지 않아 사용자에게 원인을 전달하기 어렵습니다.
🧭 제안 변경
- {!isLoading &&
- !isError &&
- categories.map((i) => (
+ {!isLoading && !isError && categories.length === 0 && (
+ <div className="px-6 py-4 text-sm text-gray-400">추천 항목이 없습니다.</div>
+ )}
+ {!isLoading &&
+ !isError &&
+ categories.map((i) => (
<ProductList
key={i.id}
category={i}
products={products.filter((p) => p.categoryId === i.id)}
/>
))}📝 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.
| {!isLoading && | |
| !isError && | |
| categories.map((i) => ( | |
| <ProductList | |
| key={i.id} | |
| category={i} | |
| products={products.filter((p) => p.categoryId === i.id)} | |
| /> | |
| {!isLoading && !isError && categories.length === 0 && ( | |
| <div className="px-6 py-4 text-sm text-gray-400">추천 항목이 없습니다.</div> | |
| )} | |
| {!isLoading && | |
| !isError && | |
| categories.map((i) => ( | |
| <ProductList | |
| key={i.id} | |
| category={i} | |
| products={products.filter((p) => p.categoryId === i.id)} | |
| /> |
🤖 Prompt for AI Agents
In `@src/app/`(private)/recommend/page.tsx around lines 32 - 39, When !isLoading
&& !isError and categories is empty the page currently renders nothing; update
the render logic in page.tsx around the block using categories.map(...) to
detect categories.length === 0 and render a concise empty-state UI (e.g., an
EmptyState component or a message/card with icon and a call-to-action) instead
of mapping; keep the existing ProductList rendering for the non-empty case and
reuse products.filter((p) => p.categoryId === i.id) as before.
| {recommendError && ( | ||
| <div className="rounded-2xl bg-white px-4 py-3 text-[13px] text-red-500"> | ||
| 추천 상품을 불러오지 못했습니다. | ||
| <button type="button" className="ml-2 underline" onClick={() => refetchRecommend()}> | ||
| 다시 시도 | ||
| </button> | ||
| </div> | ||
| )} |
There was a problem hiding this comment.
에러 상태 UI 개선 필요
refetchRecommend()가 Promise를 반환하지만 핸들링되지 않았습니다.- 접근성 향상을 위해 버튼에
aria-label을 추가하면 좋겠습니다.
🛠️ 개선 제안
{recommendError && (
<div className="rounded-2xl bg-white px-4 py-3 text-[13px] text-red-500">
추천 상품을 불러오지 못했습니다.
- <button type="button" className="ml-2 underline" onClick={() => refetchRecommend()}>
+ <button
+ type="button"
+ className="ml-2 underline"
+ aria-label="추천 상품 다시 불러오기"
+ onClick={() => void refetchRecommend()}
+ >
다시 시도
</button>
</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.
| {recommendError && ( | |
| <div className="rounded-2xl bg-white px-4 py-3 text-[13px] text-red-500"> | |
| 추천 상품을 불러오지 못했습니다. | |
| <button type="button" className="ml-2 underline" onClick={() => refetchRecommend()}> | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} | |
| {recommendError && ( | |
| <div className="rounded-2xl bg-white px-4 py-3 text-[13px] text-red-500"> | |
| 추천 상품을 불러오지 못했습니다. | |
| <button | |
| type="button" | |
| className="ml-2 underline" | |
| aria-label="추천 상품 다시 불러오기" | |
| onClick={() => void refetchRecommend()} | |
| > | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} |
🤖 Prompt for AI Agents
In `@src/app/`(private)/summary/_components/SummarySuccessPage.tsx around lines
156 - 163, The error UI currently renders when recommendError is true but calls
refetchRecommend() without handling its returned Promise and the retry button
lacks accessibility labeling; update the onClick handler in SummarySuccessPage
to call refetchRecommend and handle the Promise (e.g., await in an async handler
or use .catch to handle errors and optionally set loading state), and add an
appropriate aria-label to the retry button (e.g., aria-label="Retry loading
recommended products") so screen readers can describe the action.
| const params = useParams(); | ||
| const rawId = Array.isArray(params?.summaryId) ? params.summaryId[0] : params?.summaryId; | ||
| const summaryId = Number(rawId); | ||
| const safeId = Number.isFinite(summaryId) ? summaryId : 0; | ||
|
|
||
| const { data: summaryData } = useSummaryDetail(safeId); | ||
| const { data: recommendData, isLoading, isError, refetch } = useRecommendSummary(safeId); | ||
| const products = recommendData?.products ?? []; | ||
|
|
||
| const title = summaryData?.title ?? '추천 상품'; |
There was a problem hiding this comment.
유효하지 않은 summaryId가 API 호출을 유발합니다.
safeId가 0일 때 useSummaryDetail이 /api/summaries/0을 호출할 수 있어 불필요한 에러/로그가 발생합니다. 대안: 유효성 플래그로 NaN을 전달해 쿼리를 비활성화하고, 잘못된 접근은 즉시 UX로 안내하세요. 장점: 잘못된 경로에서 불필요한 네트워크/에러를 차단합니다. 단점: 초기 가드 로직이 조금 늘어납니다.
✅ 제안 수정안
const rawId = Array.isArray(params?.summaryId) ? params.summaryId[0] : params?.summaryId;
const summaryId = Number(rawId);
- const safeId = Number.isFinite(summaryId) ? summaryId : 0;
+ const isValidId = Number.isInteger(summaryId) && summaryId > 0;
+ const safeId = isValidId ? summaryId : Number.NaN;
const { data: summaryData } = useSummaryDetail(safeId);
const { data: recommendData, isLoading, isError, refetch } = useRecommendSummary(safeId);
const products = recommendData?.products ?? [];
+
+ if (!isValidId) {
+ return <div className="mx-7.5 my-4 text-sm text-red-500">잘못된 접근입니다.</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.
| const params = useParams(); | |
| const rawId = Array.isArray(params?.summaryId) ? params.summaryId[0] : params?.summaryId; | |
| const summaryId = Number(rawId); | |
| const safeId = Number.isFinite(summaryId) ? summaryId : 0; | |
| const { data: summaryData } = useSummaryDetail(safeId); | |
| const { data: recommendData, isLoading, isError, refetch } = useRecommendSummary(safeId); | |
| const products = recommendData?.products ?? []; | |
| const title = summaryData?.title ?? '추천 상품'; | |
| const params = useParams(); | |
| const rawId = Array.isArray(params?.summaryId) ? params.summaryId[0] : params?.summaryId; | |
| const summaryId = Number(rawId); | |
| const isValidId = Number.isInteger(summaryId) && summaryId > 0; | |
| const safeId = isValidId ? summaryId : Number.NaN; | |
| const { data: summaryData } = useSummaryDetail(safeId); | |
| const { data: recommendData, isLoading, isError, refetch } = useRecommendSummary(safeId); | |
| const products = recommendData?.products ?? []; | |
| if (!isValidId) { | |
| return <div className="mx-7.5 my-4 text-sm text-red-500">잘못된 접근입니다.</div>; | |
| } | |
| const title = summaryData?.title ?? '추천 상품'; |
🤖 Prompt for AI Agents
In `@src/app/`(private)/summary/[summaryId]/recommended-products/page.tsx around
lines 11 - 20, Currently safeId defaults to 0 which triggers useSummaryDetail
and useRecommendSummary to call APIs for id 0; change the logic to treat invalid
summaryId as disabled by deriving a boolean like isValidId =
Number.isFinite(summaryId) && summaryId > 0 and pass that to the hooks (e.g.,
via query options or an "enabled" prop) so neither useSummaryDetail(safeId) nor
useRecommendSummary(safeId) runs when isValidId is false, and render an
immediate UX fallback (redirect, error message, or NotFound) when isValidId is
false instead of letting the hooks fetch with id 0; update references to rawId,
summaryId, safeId, useSummaryDetail and useRecommendSummary accordingly.
| {isLoading && <div className="mt-4 text-sm text-gray-400">추천 상품 불러오는 중...</div>} | ||
| {isError && ( | ||
| <div className="mt-4 text-sm text-red-500"> | ||
| 추천 상품을 불러오지 못했습니다. | ||
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | ||
| 다시 시도 | ||
| </button> | ||
| </div> | ||
| )} | ||
| {!isLoading && !isError && products.length === 0 && ( | ||
| <div className="mt-4 text-sm text-gray-400">추천 상품이 없습니다.</div> |
There was a problem hiding this comment.
로딩/에러/빈 상태에 접근성 시그널이 부족합니다.
현재 메시지는 스크린 리더에 즉시 전달되지 않을 수 있습니다. 대안: role="status"/role="alert"와 aria-live를 추가하세요. 장점: 접근성 향상, 상태 변화 인지 개선. 단점: 마크업이 약간 길어집니다.
♿ 제안 수정안
- {isLoading && <div className="mt-4 text-sm text-gray-400">추천 상품 불러오는 중...</div>}
+ {isLoading && (
+ <div className="mt-4 text-sm text-gray-400" role="status" aria-live="polite">
+ 추천 상품 불러오는 중...
+ </div>
+ )}
- {isError && (
- <div className="mt-4 text-sm text-red-500">
+ {isError && (
+ <div className="mt-4 text-sm text-red-500" role="alert" aria-live="assertive">
추천 상품을 불러오지 못했습니다.
<button type="button" className="ml-2 underline" onClick={() => refetch()}>
다시 시도
</button>
</div>
)}
- {!isLoading && !isError && products.length === 0 && (
- <div className="mt-4 text-sm text-gray-400">추천 상품이 없습니다.</div>
- )}
+ {!isLoading && !isError && products.length === 0 && (
+ <div className="mt-4 text-sm text-gray-400" role="status" aria-live="polite">
+ 추천 상품이 없습니다.
+ </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.
| {isLoading && <div className="mt-4 text-sm text-gray-400">추천 상품 불러오는 중...</div>} | |
| {isError && ( | |
| <div className="mt-4 text-sm text-red-500"> | |
| 추천 상품을 불러오지 못했습니다. | |
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} | |
| {!isLoading && !isError && products.length === 0 && ( | |
| <div className="mt-4 text-sm text-gray-400">추천 상품이 없습니다.</div> | |
| {isLoading && ( | |
| <div className="mt-4 text-sm text-gray-400" role="status" aria-live="polite"> | |
| 추천 상품 불러오는 중... | |
| </div> | |
| )} | |
| {isError && ( | |
| <div className="mt-4 text-sm text-red-500" role="alert" aria-live="assertive"> | |
| 추천 상품을 불러오지 못했습니다. | |
| <button type="button" className="ml-2 underline" onClick={() => refetch()}> | |
| 다시 시도 | |
| </button> | |
| </div> | |
| )} | |
| {!isLoading && !isError && products.length === 0 && ( | |
| <div className="mt-4 text-sm text-gray-400" role="status" aria-live="polite"> | |
| 추천 상품이 없습니다. | |
| </div> | |
| )} |
🤖 Prompt for AI Agents
In `@src/app/`(private)/summary/[summaryId]/recommended-products/page.tsx around
lines 25 - 35, Wrap the status messages rendered in the conditional blocks for
isLoading, isError, and empty products (the JSX shown under the isLoading,
isError, and products.length === 0 checks in page.tsx) in accessible live
regions by adding appropriate ARIA attributes: use role="status" with
aria-live="polite" (and aria-atomic="true") for non-critical info like the
loading and empty messages, and use role="alert" with aria-live="assertive" for
the error block so screen readers announce it immediately; update the
corresponding div elements (the ones containing "추천 상품 불러오는 중...", the error
message with the retry button, and "추천 상품이 없습니다.") to include these attributes.
| type RecommendCardProps = { | ||
| product: ProductProps; | ||
| best: string; | ||
| }) => { | ||
| return ( | ||
| <div className={`flex w-200 max-w-200 flex-col rounded-2xl ${best}`}> | ||
| <div className="mx-10 my-5"> | ||
| <div className="text-[14px] font-semibold">{product.name}</div> | ||
| <div className="text-primary-500 mb-2 text-[20px] font-bold"> | ||
| {is_monthly ? '월 ' : null} | ||
| {product.price.toLocaleString()}원{product.is_sale ? '부터' : null} | ||
| is_monthly: boolean; | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# 1. is_monthly 사용처 전체 검색
rg -n "is_monthly" --type tsx --type ts
# 2. 해당 컴포넌트 파일의 전체 내용 확인
wc -l src/components/counseling-recommend/recommend-card/index.tsxRepository: U-nify/Unity-client
Length of output: 171
🏁 Script executed:
# 1. Search for is_monthly without type filter
rg "is_monthly" -n
# 2. Read the complete component file
cat -n src/components/counseling-recommend/recommend-card/index.tsxRepository: U-nify/Unity-client
Length of output: 1885
is_monthly 타입에만 정의되고 실제로는 사용하지 않습니다.
컴포넌트의 구조적 문제입니다. 13줄의 구조 분해(destructuring)에서 is_monthly가 빠져 있고, 컴포넌트 본문 어디에도 참조되지 않습니다. 반면 부모 컴포넌트(recommend-cardlist)에서는 이미 is_monthly={true}로 전달되고 있습니다.
이는 불필요한 props 규약이 유지되어 API가 혼란스러워지고, 향후 유지보수 시 개발자가 "이 prop이 뭐하는 건데?" 하며 불필요한 추적을 하게 됩니다.
제거 방법:
- 타입 정의에서
is_monthly필드 삭제 - 부모 컴포넌트(recommend-cardlist)에서
is_monthly={true}제거
이렇게 정리하면 API 계약이 명확해지고 재사용성이 높아집니다.
🤖 Prompt for AI Agents
In `@src/components/counseling-recommend/recommend-card/index.tsx` around lines 7
- 11, Remove the unused is_monthly prop from the RecommendCard API: delete
is_monthly from the RecommendCardProps type and remove it from the destructuring
in the RecommendCard component (where props are unpacked) so the component no
longer declares or expects it; then remove is_monthly={true} from the parent
component recommend-cardlist where RecommendCard is instantiated. Also search
for any other references to is_monthly in this component and the parent and
delete or refactor them if present to ensure no dangling references remain.
| <Link href={product.link || '#'} target="_blank" rel="noopener noreferrer"> | ||
| <div className="bg-white-light flex h-full w-full cursor-pointer flex-col items-center justify-between gap-1 rounded-[20px] p-3 shadow-[0_0_4px_rgba(0,0,0,0.25)]"> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# ProductGoods 컴포넌트 파일 확인
cat -n src/components/recommend/product-goods/index.tsxRepository: U-nify/Unity-client
Length of output: 1584
🏁 Script executed:
# product 타입 정의 찾기
rg -n "interface.*[Pp]roduct\|type.*[Pp]roduct" src --type ts --type tsx -A 5Repository: U-nify/Unity-client
Length of output: 90
🏁 Script executed:
# 파일 크기 확인
wc -l src/components/recommend/product-goods/index.tsxRepository: U-nify/Unity-client
Length of output: 114
🏁 Script executed:
# ProductProps 타입 정의 찾기
cat -n src/types/product/dto.ts 2>/dev/null || find src -name "*.ts" -o -name "*.tsx" | xargs grep -l "ProductProps" | head -1 | xargs cat -nRepository: U-nify/Unity-client
Length of output: 735
🏁 Script executed:
# ProductProps가 export된 파일 위치 정확히 찾기
find src -path "*types/product/dto*" -type fRepository: U-nify/Unity-client
Length of output: 87
🏁 Script executed:
# 만약 dto 파일이 없다면 types 디렉토리 구조 확인
find src/types -type f -name "*.ts" 2>/dev/null | head -20Repository: U-nify/Unity-client
Length of output: 279
product.link가 없을 때 #으로 폴백하는 것은 접근성과 사용자 경험에 문제가 있습니다.
link: string | null이므로 null 값이 발생할 수 있고, 현재 코드에서는 이때 #으로 새 탭을 여려고 시도합니다. 이는 스크린 리더에는 유효한 링크처럼 보이지만 실제 목적지가 없어서 사용자에게 혼란을 줍니다.
link 유무에 따라 조건부로 렌더링하세요:
// product.link가 있을 때만 Link 사용, 없으면 비활성 카드로
return product.link ? (
<Link href={product.link} target="_blank" rel="noopener noreferrer">
<ProductCard />
</Link>
) : (
<ProductCard />
);이렇게 하면:
- 접근성: 링크가 실제로 존재할 때만
<a>태그 생성 → 스크린 리더가 정확히 인식 - UX: 링크 없는 카드는 시각적으로 구분하거나 비활성 스타일 적용 가능
- 유지보수성: 데이터 부재 상황이 명시적으로 처리됨
🤖 Prompt for AI Agents
In `@src/components/recommend/product-goods/index.tsx` around lines 11 - 12,
Replace the unconditional Link wrapper around the product card with conditional
rendering based on product.link: when product.link is truthy, render the Link
(preserving target="_blank" and rel="noopener noreferrer") wrapping the card
markup; when product.link is null/undefined, render the same product card markup
without Link and apply a disabled/visually-distinct style and ARIA hint (e.g.,
aria-disabled or role) to the outer div so the card remains accessible—update
the component using the existing Link usage and the product.link check around
the card container.
| import type { ProductProps } from '@/types/product/dto'; | ||
|
|
||
| export const ProductPlans = ({ product, monthly }: { product: ProductProps; monthly: boolean }) => { | ||
| export const ProductPlans = ({ product }: { product: ProductProps; monthly: boolean }) => { |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# First, let's find and read the ProductPlans component file
find . -path "*/recommend/product-plans/index.tsx" -type fRepository: U-nify/Unity-client
Length of output: 113
🏁 Script executed:
# Read the ProductPlans component file
cat -n "./src/components/recommend/product-plans/index.tsx"Repository: U-nify/Unity-client
Length of output: 1220
🏁 Script executed:
# Search for ProductPlans usage
rg -n "ProductPlans" srcRepository: U-nify/Unity-client
Length of output: 205
🏁 Script executed:
# Check the product-plans directory structure
ls -la "src/components/recommend/product-plans/"
# Search for imports from product-plans directory
rg -n "from.*product-plans" src
# Search more broadly for any ProductPlans usage
rg -n "ProductPlans" . --include="*.tsx" --include="*.ts" --include="*.jsx" --include="*.js"Repository: U-nify/Unity-client
Length of output: 688
🏁 Script executed:
# Correct rg syntax - search for ProductPlans usage with proper type filters
rg "ProductPlans" -t tsx -t ts -t jsx -t js .Repository: U-nify/Unity-client
Length of output: 90
🏁 Script executed:
# Search for ProductPlans across all files
rg "ProductPlans" .
# Also search for the parent directory to see what uses product-plans
rg "product-plans" .Repository: U-nify/Unity-client
Length of output: 205
🏁 Script executed:
# Check the recommend directory structure
ls -la "src/components/recommend/"
# Search for any exports from product-plans or recommend folder
rg "export.*product-plans" .
rg "export.*ProductPlans" .
# Check if there's an index file that might export these components
cat -n "src/components/recommend/index.ts" 2>/dev/null || cat -n "src/components/recommend/index.tsx" 2>/dev/null || echo "No index file found"Repository: U-nify/Unity-client
Length of output: 524
🏁 Script executed:
# Check for any dynamic imports or lazy loading
rg "product-plans|ProductPlans" . --include="*.json" --include="*.config.*"
# Check if used in test files
fd -e "test.tsx" -e "test.ts" -e "spec.tsx" -e "spec.ts" . | head -20
# Search in all files more comprehensively
find . -type f \( -name "*.tsx" -o -name "*.ts" -o -name "*.jsx" -o -name "*.js" \) -exec grep -l "ProductPlans\|product-plans" {} \;Repository: U-nify/Unity-client
Length of output: 572
monthly prop을 선언했지만 사용하지 않고 있습니다.
컴포넌트 본문에서 monthly prop을 참조하는 곳이 없습니다. 추가로 확인한 결과, 이 ProductPlans 컴포넌트 자체가 현재 코드베이스 어디에서도 import되거나 사용되지 않는 상태입니다.
다음 중 하나를 진행해 주세요:
- 미사용 컴포넌트라면 제거
- 향후 사용 예정이라면,
monthlyprop을 실제 렌더링 로직에 반영하여 구현 완성
미사용 prop과 컴포넌트는 유지보수 비용을 증가시키고 혼란을 야기하므로, 명확한 의도를 가지고 정리하는 것이 확장성과 코드 품질 관점에서 중요합니다.
🤖 Prompt for AI Agents
In `@src/components/recommend/product-plans/index.tsx` at line 5, The ProductPlans
component declares a monthly prop that is never used and the component itself
appears unused; either remove the entire ProductPlans export to eliminate dead
code (delete the function and its export) or implement monthly into the
rendering logic of ProductPlans (update the ProductPlans function to accept {
product, monthly } and use monthly to toggle monthly vs yearly pricing display
or conditional JSX where pricing is rendered), and ensure any callers/imports
are updated or the export removed; reference the ProductPlans component and the
monthly prop when making the change.
| <Link href={product.link || '#'} target="_blank" rel="noopener noreferrer"> | ||
| <div className="bg-white-light border-primary-500 flex h-21.25 w-50 cursor-pointer flex-col justify-between gap-1 truncate rounded-[20px] border-2 p-3"> |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
# ProductPlans 컴포넌트 파일 확인
find src -name "*product-plans*" -o -name "*ProductPlans*" 2>/dev/null | head -20Repository: U-nify/Unity-client
Length of output: 101
🏁 Script executed:
# Link null/빈 값 확인 및 ProductPlans 사용처 검색
rg -n "product\.link" src --type tsx --type ts -B 2 -A 2Repository: U-nify/Unity-client
Length of output: 90
🏁 Script executed:
# monthly 프로퍼티 검색
rg -n "monthly" src/components/recommend/product-plans -B 2 -A 2Repository: U-nify/Unity-client
Length of output: 564
🏁 Script executed:
# 컴포넌트 파일 읽기
cat -n src/components/recommend/product-plans/index.tsx | head -50Repository: U-nify/Unity-client
Length of output: 1220
🏁 Script executed:
# ProductProps 타입 정의 확인
rg -n "type ProductProps" src --type ts -A 10Repository: U-nify/Unity-client
Length of output: 45
🏁 Script executed:
# ProductPlans 컴포넌트 사용 위치 확인
rg -n "ProductPlans" src --type ts -B 2 -A 2Repository: U-nify/Unity-client
Length of output: 564
🏁 Script executed:
# product.link 패턴 검색 (null일 수 있는지 확인)
rg -n "href.*#" src -B 2 -A 2Repository: U-nify/Unity-client
Length of output: 50378
monthly 프로퍼티가 미사용 상태입니다.
컴포넌트 시그니처에 정의되었지만 컴포넌트 본문에서 실제로 사용되지 않고 있습니다. 불필요한 프로퍼티는 향후 개발자에게 혼란을 유발할 수 있으므로, 제거하거나 실제 사용 목적이 있다면 구현해야 합니다.
링크가 없는데도 #로 새 탭이 열립니다.
product.link이 없을 때 href='#'로 폴백되지만 target="_blank"가 여전히 적용되어 사용자가 현재 페이지를 새 탭에서 다시 열게 됩니다. 링크가 없는 경우 다음과 같이 개선하는 것이 안전합니다:
✅ 개선 예시
-return (
- <Link href={product.link || '#'} target="_blank" rel="noopener noreferrer">
+const content = (
<div className="bg-white-light border-primary-500 flex h-21.25 w-50 cursor-pointer flex-col justify-between gap-1 truncate rounded-[20px] border-2 p-3">
{/* ... */}
</div>
+);
+
+return product.link ? (
+ <Link href={product.link} target="_blank" rel="noopener noreferrer">
+ {content}
</Link>
-);
+) : (
+ content
+);🤖 Prompt for AI Agents
In `@src/components/recommend/product-plans/index.tsx` around lines 9 - 10, The
component signature exposes a monthly prop but it is unused—either remove the
monthly prop from the ProductPlans component props or implement its intended
behavior (e.g., adjust displayed price or CSS) wherever pricing is rendered;
also change how Link is rendered so when product.link is falsy you do not set
href="#" with target="_blank"/rel (use a plain non-anchor wrapper or render Link
without target/rel when product.link is missing) — locate the Link usage and the
product.link reference in the component and update handling accordingly.
| const mapRecommendSummary = (data: RecommendSummaryResponse): RecommendSummaryMapped => { | ||
| const categories = new Map<number, CategoryInfo>(); | ||
| const products: ProductProps[] = []; | ||
|
|
||
| data.items.forEach((item) => { | ||
| const categoryId = item.categoryId; | ||
| const category = getCategoryInfo(categoryId); | ||
| categories.set(category.id, category); | ||
|
|
||
| products.push({ | ||
| productId: item.productId, | ||
| name: item.name, | ||
| desc: null, | ||
| categoryId, | ||
| content: null, | ||
| score: item.score ?? null, | ||
| rankNo: item.rankNo ?? null, | ||
| link: item.link, | ||
| img: item.img, | ||
| price: item.price, | ||
| }); | ||
| }); |
There was a problem hiding this comment.
예상치 못한 categoryId에 대한 방어가 필요합니다.
API가 신규 카테고리 ID를 내려주면 getCategoryInfo 내부에서 런타임 오류가 발생해 페이지가 깨질 수 있습니다. 안전한 가드/스킵 처리를 추가해 주세요.
🛡️ 예시 개선안
data.items.forEach((item) => {
const categoryId = item.categoryId;
- const category = getCategoryInfo(categoryId);
- categories.set(category.id, category);
+ let category: CategoryInfo | null = null;
+ try {
+ category = getCategoryInfo(categoryId);
+ } catch {
+ return; // invalid categoryId → skip
+ }
+ categories.set(category.id, category);📝 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.
| const mapRecommendSummary = (data: RecommendSummaryResponse): RecommendSummaryMapped => { | |
| const categories = new Map<number, CategoryInfo>(); | |
| const products: ProductProps[] = []; | |
| data.items.forEach((item) => { | |
| const categoryId = item.categoryId; | |
| const category = getCategoryInfo(categoryId); | |
| categories.set(category.id, category); | |
| products.push({ | |
| productId: item.productId, | |
| name: item.name, | |
| desc: null, | |
| categoryId, | |
| content: null, | |
| score: item.score ?? null, | |
| rankNo: item.rankNo ?? null, | |
| link: item.link, | |
| img: item.img, | |
| price: item.price, | |
| }); | |
| }); | |
| const mapRecommendSummary = (data: RecommendSummaryResponse): RecommendSummaryMapped => { | |
| const categories = new Map<number, CategoryInfo>(); | |
| const products: ProductProps[] = []; | |
| data.items.forEach((item) => { | |
| const categoryId = item.categoryId; | |
| let category: CategoryInfo | null = null; | |
| try { | |
| category = getCategoryInfo(categoryId); | |
| } catch { | |
| return; // invalid categoryId → skip | |
| } | |
| categories.set(category.id, category); | |
| products.push({ | |
| productId: item.productId, | |
| name: item.name, | |
| desc: null, | |
| categoryId, | |
| content: null, | |
| score: item.score ?? null, | |
| rankNo: item.rankNo ?? null, | |
| link: item.link, | |
| img: item.img, | |
| price: item.price, | |
| }); | |
| }); |
🤖 Prompt for AI Agents
In `@src/hooks/recommend/useRecommendSummary.ts` around lines 16 - 37,
mapRecommendSummary currently calls getCategoryInfo(item.categoryId) without
guarding against unknown/new category IDs which can throw or return invalid
data; update mapRecommendSummary to validate the result of getCategoryInfo (or
wrap the call in try/catch) and skip or provide a safe fallback when a category
is missing so the page won't crash: only call categories.set(category.id,
category) when category is non-null/has an id, and handle products for which
category lookup failed (e.g., skip adding the product, set categoryId to
null/safe default, and/or log a warning). Ensure changes are made inside
mapRecommendSummary and reference getCategoryInfo, categories (Map), and
products (ProductProps[]) so runtime errors are prevented.
Key Changes
작업 내역
📸 스크린샷
💬 공유사항 to 리뷰어
비고
PR Checklist
PR이 다음 요구 사항을 충족하는지 확인하세요.
Summary by CodeRabbit
새로운 기능
개선사항
제거
✏️ Tip: You can customize this high-level summary in your review settings.