Skip to content

[FEAT]: 홈/카테고리 상품 카드 찜하기 기능 확장#68

Merged
Seoje1405 merged 6 commits intoIT-Cotato:developfrom
Seoje1405:refactor/home-wishlist
Feb 17, 2026
Merged

[FEAT]: 홈/카테고리 상품 카드 찜하기 기능 확장#68
Seoje1405 merged 6 commits intoIT-Cotato:developfrom
Seoje1405:refactor/home-wishlist

Conversation

@Seoje1405
Copy link
Copy Markdown
Contributor

📝 개요

홈 추천(상품/브랜드)과 하위 카테고리 상품 카드에 찜하기 버튼 및 연동 로직을 추가한 작업입니다.

기존에는 일부 카드에서만 찜하기가 가능했고, 홈/카테고리 목록에서는 동일한 사용자 행동을 제공하지 못했습니다.
이번 PR은 카드 단위 찜 경험 확장 + 초기 찜 상태 일치 + 기존 흐름(상세/찜목록)과의 일관성 유지를 목표로 반영했습니다.

관련 이슈 번호: #이슈번호

Closes #이슈번호

🎯 변경 의도

  • 홈/카테고리 목록에서도 상세와 동일한 찜 액션 제공
  • 카드 최초 렌더 시 실제 찜 상태(isLiked, wishlistId)를 반영해 UI 신뢰도 확보
  • 공통 찜 버튼(WishlistButton, useWishlist) 재사용으로 유지보수성 확보
  • 페이지별 적용 범위 제어로 사이드이펙트 최소화

🚀 주요 변경 사항

1) 홈 추천 상품 카드 찜 연동

  • 추천 상품 조회 시 내 찜 목록을 함께 조회
  • 상품별 isLiked, wishlistId 매핑 후 카드에 전달
  • 카드에서 WishlistButton에 초기 상태값 주입

2) 랜덤 브랜드 추천 카드 찜 연동

  • 브랜드 추천 데이터에도 찜 상태 매핑 적용
  • 브랜드 상품 그리드 카드 우상단에 WishlistButton 추가
  • 클라이언트 타입 보강(브랜드 상품 + 찜 상태 필드)

3) 하위 카테고리 상품 카드 찜 연동

  • 하위 카테고리 상품 목록 조회 시 찜 목록 병렬 조회
  • ProductListContainer에서 상품별 찜 상태 매핑
  • ProductListshowWishlistButton 옵션 추가
  • ProductCard에서 옵션 활성 시 찜 버튼 렌더링

4) 구조/안정성 보완

  • 브랜드 추천 클라이언트 컴포넌트의 Hook 호출 순서 정리(lint 규칙 대응)
  • 기존 상세/찜목록에서 사용하던 찜 토글 훅 로직 재사용

🧪 테스트/검증 포인트

  • 추천 상품 카드에서 찜/해제 동작 확인
  • 랜덤 브랜드 추천 카드에서 찜/해제 동작 확인
  • 카드 클릭(상세 이동)과 찜 버튼 클릭 이벤트 충돌 없는지 확인

하위 카테고리

  • 상품 카드에 찜 버튼 노출 확인
  • 찜/해제 후 아이콘 상태 즉시 반영(optimistic) 확인
  • 정렬/페이지 변경 시 초기 찜 상태가 실제 데이터와 일치하는지 확인

공통

  • 비로그인/세션 만료 상황에서 오류 처리(실패 시 롤백) 확인
  • eslint 대상 파일 검사 통과 확인

✅ 체크리스트

  • 코드 컨벤션 준수
  • 기능 단위 커밋 분리
  • 린트 최종 확인
  • 주요 사용자 플로우 수동 점검 (홈 추천/브랜드 추천/하위 카테고리 찜 버튼)

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 17, 2026

Caution

Review failed

The pull request is closed.

Walkthrough

제품/추천 컴포넌트 전반에 위시리스트 통합이 추가되었습니다. 서버에서 제품과 위시리스트를 병렬 조회해 각 제품에 isLiked/wishlistId를 주입하고, 카드 컴포넌트들이 WishlistButton 오버레이와 새로운 타입/프롭(showWishlistButton)을 사용하도록 변경되었습니다.

Changes

Cohort / File(s) Summary
Product UI
src/components/product/product-card.tsx, src/components/product/product-list.tsx, src/components/product/product-list-container.tsx, src/components/product/product-detail-view.tsx
도메인 타입을 ProductWithWishlist로 변경. ProductList/ProductCardshowWishlistButton?: boolean 추가·전파. 컨테이너에서 제품+위시리스트 병렬 조회 후 isLiked/wishlistId 주입. 이미지 영역에 WishlistButton 오버레이 추가.
Recommend Brand UI
src/components/recommend-brand/recommended-brand-client.tsx, src/components/recommend-brand/recommended-brand-container.tsx, src/components/recommend-brand/recommended-brand-grid-card.tsx
브랜드 제품을 ProductWithWishlist[]로 변경. 컨테이너에서 브랜드 데이터와 위시리스트를 병렬 조회(Promise.allSettled)하고 실패 처리, 각 상품에 isLiked/wishlistId 주입. GridCard에 WishlistButton 오버레이 추가.
Recommend Carousel
src/components/recommend-carousel/recommend-product-container.tsx, src/components/recommend-carousel/recommended-product-card.tsx
제품과 위시리스트 병렬 조회 및 wishlistByProductId 맵 생성. 제품에 isLiked/wishlistId 병합. RecommendedProductCard가 ProductWithWishlist를 소비하고 WishlistButton에 initialWishlistId 전달.
Types
src/types/domain/product.ts
새로운 export 타입 ProductWithWishlist = Product & { isLiked?: boolean; wishlistId?: number } 추가 — 공개 API 변경.
Wishlist Hook / Actions
src/hooks/use-wishlist.ts, src/app/actions/wishlist
use-wishlistcommittedState 도입해 낙관적/커밋 상태 분리. add/delete 흐름에서 wishlistId 관리 방식 변경. 컨테이너/액션에서 getMyWishlist 사용 추가.
Layout / Minor
src/components/layout/main-nav-bar.tsx
네비게이션 컨테이너에 z-50 클래스 추가(스타일 우선순위 조정).

Sequence Diagram

sequenceDiagram
    autonumber
    participant Client as Client (페이지/컴포넌트)
    participant API as Products API
    participant WishlistAPI as getMyWishlist()
    participant Mapper as Mapper (wishlistByProductId)
    participant List as ProductList / BrandList
    participant Card as ProductCard / RecommendedCard
    participant Button as WishlistButton

    Client->>API: fetch products
    Client->>WishlistAPI: fetch wishlist (병렬)
    API-->>Client: products[]
    WishlistAPI-->>Client: wishlist[]
    Client->>Mapper: map wishlist by productId (productId -> wishlistId)
    Mapper-->>Client: products augmented with isLiked,wishlistId
    Client->>List: render list with ProductWithWishlist[]
    List->>Card: render card with initialIsLiked & initialWishlistId
    Card->>Button: mount with initial props
    Button-->>Card: user toggles add/remove (optimistic UI -> use-wishlist)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

♻️ REFACTOR

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed PR 제목이 홈/카테고리 상품 카드 찜하기 기능 확장이라는 핵심 변경사항을 정확히 반영하고 있습니다.
Description check ✅ Passed PR 설명이 변경 의도, 주요 변경 사항, 테스트 포인트를 구조적으로 명시하여 변경사항과 충분히 관련이 있습니다.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

Copy link
Copy Markdown
Contributor

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

🧹 Nitpick comments (4)
src/components/recommend-carousel/recommend-product-container.tsx (1)

17-25: Promise.allSettled 사용 — 좋습니다. 다만 products 실패 시 조용히 빈 배열 처리되는 점 확인.

추천 캐러셀 특성상 graceful degradation으로 적절하지만, products 조회 실패 시 로깅 없이 빈 캐러셀이 렌더링됩니다. 디버깅 편의를 위해 rejected 시 서버 로그 추가를 고려해보세요.

♻️ 에러 로깅 추가
  const products =
    productsResult.status === 'fulfilled' ? productsResult.value : [];
+ if (productsResult.status === 'rejected') {
+   console.error('Failed to fetch recommended products:', productsResult.reason);
+ }
  const wishlistItems =
    wishlistResult.status === 'fulfilled' ? wishlistResult.value : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-carousel/recommend-product-container.tsx` around
lines 17 - 25, The productsResult from Promise.allSettled is currently converted
silently to an empty array on rejection; update the Promise.allSettled handling
in recommend-product-container.tsx so that when productsResult.status ===
'rejected' you log the rejection reason (include error.message/stack) via your
server/logger (or console.error) along with context like the endpoint and that
it was during api.get<Product>(endpoint); do the same for wishlistResult
(getMyWishlist) to aid debugging while still falling back to an empty array for
graceful degradation.
src/components/recommend-brand/recommended-brand-grid-card.tsx (2)

29-34: initialIsLiked?? 사용 권장.

product.isLikedboolean | undefined이므로 ||?? 모두 동작하지만, ??가 의도(nullish coalescing)를 명확히 표현합니다. 이 패턴은 product-card.tsx(Line 40), recommended-product-card.tsx(Line 75)에도 동일하게 적용됩니다.

♻️ diff
-            initialIsLiked={product.isLiked || false}
+            initialIsLiked={product.isLiked ?? false}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-grid-card.tsx` around lines
29 - 34, Replace the boolean fallback using || with nullish coalescing to
clearly express intent: change usages where product.isLiked is passed (e.g., the
WishlistButton prop initialIsLiked in RecommendedBrandGridCard component) from
product.isLiked || false to product.isLiked ?? false; apply the same change in
the similar spots noted (product-card.tsx at the product card component and
recommended-product-card.tsx where initialIsLiked is set) so undefined/null
values default to false without affecting falsey booleans.

6-9: BrandProduct 타입이 recommended-brand-client.tsx에도 중복 정의됨.

Product & { isLiked?: boolean; wishlistId?: number } 패턴이 product-card.tsx, product-list.tsx, 이 파일, recommended-brand-client.tsx 등 최소 4곳에 반복됩니다. 공용 타입(ProductWithWishlist)을 src/types/domain/product.ts에 한 번 정의하고 재사용하면 변경 시 누락을 방지할 수 있습니다.

♻️ 제안: 공용 타입을 domain 파일로 추출

src/types/domain/product.ts에 추가:

export type ProductWithWishlist = Product & {
  isLiked?: boolean;
  wishlistId?: number;
};

각 파일에서 로컬 정의 대신 import:

-type BrandProduct = Product & {
-  isLiked?: boolean;
-  wishlistId?: number;
-};
+import { ProductWithWishlist } from '@/types/domain/product';
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-grid-card.tsx` around lines
6 - 9, The BrandProduct local type (Product & { isLiked?: boolean; wishlistId?:
number }) is duplicated across multiple components; extract a shared type named
ProductWithWishlist into src/types/domain/product.ts and update each usage
(BrandProduct in recommended-brand-grid-card.tsx, and similar definitions in
product-card.tsx, product-list.tsx, recommended-brand-client.tsx) to import
ProductWithWishlist instead of redefining it; ensure you export the new type
(export type ProductWithWishlist = Product & { isLiked?: boolean; wishlistId?:
number }) and replace local type names/usages with the imported
ProductWithWishlist to keep signatures consistent.
src/components/recommend-brand/recommended-brand-container.tsx (1)

6-32: 위시리스트 조회가 초기 렌더링을 차단할 수 있음 — Suspense 분리 고려.

현재 getMyWishlist()가 완료될 때까지 전체 컴포넌트가 대기합니다. 브랜드 추천은 캐싱되어 빠르게 응답하지만, 위시리스트 API 지연 시 전체 섹션 렌더링이 블로킹됩니다.

위시리스트 상태를 별도 Client Component에서 클라이언트 사이드로 가져오거나, 위시리스트 없이 먼저 렌더링한 뒤 hydration 시 채우는 패턴을 고려해볼 수 있습니다. 다만 현재 구조에서 큰 변경이 필요하므로, 향후 최적화 항목으로 남겨두셔도 됩니다.

Based on learnings: "Use Server Components for data fetching whenever possible. Prefetch critical data but wrap secondary/dynamic data in Suspense to avoid blocking the initial shell."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-container.tsx` around lines
6 - 32, RecommendedBrandContainer currently awaits getMyWishlist() on the server
which blocks initial render; instead, stop fetching wishlist in the server
component (RecommendedBrandContainer) and only fetch brands there, then move
wishlist fetching to a client-side component (e.g., inside
RecommendedBrandClient or a new WishlistClient) where you fetch getMyWishlist()
in an effect or async client hook and merge wishlist info into products on the
client; alternatively wrap the client wishlist fetch in a Suspense boundary so
brands render immediately and wishlist state hydrates later — locate
RecommendedBrandContainer, remove getMyWishlist() usage
(wishlistResult/wishlistByProductId/brandsWithWishlist mapping), pass raw brands
to RecommendedBrandClient, and implement client-side logic in
RecommendedBrandClient (or a new component) to call getMyWishlist(), compute
isLiked/wishlistId per product, and update UI.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/components/product/product-list-container.tsx`:
- Around line 39-49: The current Promise.all call in product-list-container.tsx
(const [result, wishlistItems] = await Promise.all([...])) can fail if
getMyWishlist rejects; change to Promise.allSettled to match other containers
and make wishlist errors non-fatal. Replace the Promise.all with
Promise.allSettled, then extract the product response from the first settled
result (e.g., results[0].value) and handle the wishlist result by checking
results[1].status — use the wishlist value when status === "fulfilled" and fall
back to an empty list (or previous behavior) when rejected, while still
rethrowing Next.js redirect/notFound errors propagated by getMyWishlist
(rethrowNextError) if needed.

---

Duplicate comments:
In `@src/components/product/product-card.tsx`:
- Around line 5-8: ProductWithWishlist is duplicated; extract a single shared
type and import it where needed: create/export a shared type (e.g.,
ProductWithWishlist) in a central types module (or extend the existing Product
type in the shared types file), then remove the local duplicate in
product-card.tsx and update its references to import the shared
ProductWithWishlist; also update the other consumer (e.g.,
recommended-brand-grid-card) to import the same shared type so both components
use the single exported ProductWithWishlist.

In `@src/components/recommend-brand/recommended-brand-client.tsx`:
- Around line 10-17: Remove the duplicated BrandProduct type and simplify
BrandWithWishlistProducts to reuse the shared ProductWithWishlist type
(extracted from recommended-brand-grid-card.tsx); specifically, delete the
BrandProduct declaration and update the BrandWithWishlistProducts type to use
ProductWithWishlist in its products array so the file references the common
ProductWithWishlist type instead of redefining isLiked/wishlistId locally.

---

Nitpick comments:
In `@src/components/recommend-brand/recommended-brand-container.tsx`:
- Around line 6-32: RecommendedBrandContainer currently awaits getMyWishlist()
on the server which blocks initial render; instead, stop fetching wishlist in
the server component (RecommendedBrandContainer) and only fetch brands there,
then move wishlist fetching to a client-side component (e.g., inside
RecommendedBrandClient or a new WishlistClient) where you fetch getMyWishlist()
in an effect or async client hook and merge wishlist info into products on the
client; alternatively wrap the client wishlist fetch in a Suspense boundary so
brands render immediately and wishlist state hydrates later — locate
RecommendedBrandContainer, remove getMyWishlist() usage
(wishlistResult/wishlistByProductId/brandsWithWishlist mapping), pass raw brands
to RecommendedBrandClient, and implement client-side logic in
RecommendedBrandClient (or a new component) to call getMyWishlist(), compute
isLiked/wishlistId per product, and update UI.

In `@src/components/recommend-brand/recommended-brand-grid-card.tsx`:
- Around line 29-34: Replace the boolean fallback using || with nullish
coalescing to clearly express intent: change usages where product.isLiked is
passed (e.g., the WishlistButton prop initialIsLiked in RecommendedBrandGridCard
component) from product.isLiked || false to product.isLiked ?? false; apply the
same change in the similar spots noted (product-card.tsx at the product card
component and recommended-product-card.tsx where initialIsLiked is set) so
undefined/null values default to false without affecting falsey booleans.
- Around line 6-9: The BrandProduct local type (Product & { isLiked?: boolean;
wishlistId?: number }) is duplicated across multiple components; extract a
shared type named ProductWithWishlist into src/types/domain/product.ts and
update each usage (BrandProduct in recommended-brand-grid-card.tsx, and similar
definitions in product-card.tsx, product-list.tsx, recommended-brand-client.tsx)
to import ProductWithWishlist instead of redefining it; ensure you export the
new type (export type ProductWithWishlist = Product & { isLiked?: boolean;
wishlistId?: number }) and replace local type names/usages with the imported
ProductWithWishlist to keep signatures consistent.

In `@src/components/recommend-carousel/recommend-product-container.tsx`:
- Around line 17-25: The productsResult from Promise.allSettled is currently
converted silently to an empty array on rejection; update the Promise.allSettled
handling in recommend-product-container.tsx so that when productsResult.status
=== 'rejected' you log the rejection reason (include error.message/stack) via
your server/logger (or console.error) along with context like the endpoint and
that it was during api.get<Product>(endpoint); do the same for wishlistResult
(getMyWishlist) to aid debugging while still falling back to an empty array for
graceful degradation.

Comment on lines +39 to +49
const [result, wishlistItems] = await Promise.all([
api.get<ProductSearchResult>('/products', {
params: {
categoryId: safeCategoryId,
sortType: safeSortType,
page: safePage,
size: 20,
},
}),
getMyWishlist(),
]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Promise.allPromise.allSettled 변경 권장 — 다른 컨테이너와 일관성 및 탄력성 확보.

recommend-product-container.tsxrecommended-brand-container.tsxPromise.allSettled를 사용하여 위시리스트 조회 실패 시에도 상품 목록을 정상 표시합니다. 이 파일만 Promise.all을 사용하면, 위시리스트 API 장애(또는 비로그인 시 인증 에러)가 상품 목록 전체를 깨뜨릴 수 있습니다.

getMyWishlist 내부에 try/catch가 있지만, rethrowNextError가 Next.js redirect/notFound 에러를 다시 던지므로 Promise.all에서 전파될 수 있습니다.

🛡️ Promise.allSettled 적용
-  const [result, wishlistItems] = await Promise.all([
-    api.get<ProductSearchResult>('/products', {
-      params: {
-        categoryId: safeCategoryId,
-        sortType: safeSortType,
-        page: safePage,
-        size: 20,
-      },
-    }),
-    getMyWishlist(),
-  ]);
+  const [productsResult, wishlistResult] = await Promise.allSettled([
+    api.get<ProductSearchResult>('/products', {
+      params: {
+        categoryId: safeCategoryId,
+        sortType: safeSortType,
+        page: safePage,
+        size: 20,
+      },
+    }),
+    getMyWishlist(),
+  ]);
+
+  if (productsResult.status === 'rejected') {
+    throw productsResult.reason;
+  }
+
+  const result = productsResult.value;
+  const wishlistItems =
+    wishlistResult.status === 'fulfilled' ? wishlistResult.value : [];
📝 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 [result, wishlistItems] = await Promise.all([
api.get<ProductSearchResult>('/products', {
params: {
categoryId: safeCategoryId,
sortType: safeSortType,
page: safePage,
size: 20,
},
}),
getMyWishlist(),
]);
const [productsResult, wishlistResult] = await Promise.allSettled([
api.get<ProductSearchResult>('/products', {
params: {
categoryId: safeCategoryId,
sortType: safeSortType,
page: safePage,
size: 20,
},
}),
getMyWishlist(),
]);
if (productsResult.status === 'rejected') {
throw productsResult.reason;
}
const result = productsResult.value;
const wishlistItems =
wishlistResult.status === 'fulfilled' ? wishlistResult.value : [];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/product/product-list-container.tsx` around lines 39 - 49, The
current Promise.all call in product-list-container.tsx (const [result,
wishlistItems] = await Promise.all([...])) can fail if getMyWishlist rejects;
change to Promise.allSettled to match other containers and make wishlist errors
non-fatal. Replace the Promise.all with Promise.allSettled, then extract the
product response from the first settled result (e.g., results[0].value) and
handle the wishlist result by checking results[1].status — use the wishlist
value when status === "fulfilled" and fall back to an empty list (or previous
behavior) when rejected, while still rethrowing Next.js redirect/notFound errors
propagated by getMyWishlist (rethrowNextError) if needed.

Copy link
Copy Markdown
Contributor

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

Caution

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

⚠️ Outside diff range comments (1)
src/hooks/use-wishlist.ts (1)

39-63: ⚠️ Potential issue | 🔴 Critical

useWishlist 훅에서 prop 변경 시 상태 동기화 누락

use-wishlist.tsuseState는 마운트 시에만 초기값을 적용하고, prop 변경을 감지하는 useEffect가 없습니다. 부모에서 initialIsLikedinitialWishlistId가 변경되어도 committedState가 업데이트되지 않아 stale 상태가 유지됩니다.

추가로 src/components/wishlist/wishlist-grid.tsx 40번 줄에서 key={item.wishlistId}로 설정되어 있는데, 리스트 아이템의 identity는 productId여야 합니다. wishlistId는 서버 상태에 따라 변경될 수 있기 때문에 React의 key로 적합하지 않습니다.

수정이 필요한 두 가지
  1. use-wishlist.ts: useEffect를 추가하여 initialIsLiked/initialWishlistId prop 변경 시 상태 동기화
  2. wishlist-grid.tsx 40번 줄: key={item.wishlistId}key={item.productId}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-wishlist.ts` around lines 39 - 63, The hook useWishlist
currently initializes committedState from initialIsLiked/initialWishlistId only
on mount and therefore can go stale; add a useEffect inside useWishlist that
watches initialIsLiked and initialWishlistId and updates committedState (and
optimistic state if needed) when those props change so committedState stays in
sync; update the effect to call setCommittedState({ isLiked: initialIsLiked,
wishlistId: initialWishlistId }) to mirror prop changes. Also in
wishlist-grid.tsx replace the unstable React key usage key={item.wishlistId}
with the stable identity key={item.productId} so list reconciliation uses
productId rather than wishlistId.
🧹 Nitpick comments (3)
src/components/recommend-brand/recommended-brand-client.tsx (2)

23-30: useState(initialBrands)에서 setter 미사용 — 불필요한 state 래핑입니다.

brands는 업데이트되지 않으므로 useState가 불필요합니다. props를 직접 사용하면 됩니다. 만약 향후 클라이언트에서 brands를 변경할 계획이 아니라면, 불필요한 state는 initialBrands prop 변경 시 동기화 문제를 유발합니다.

제안
 export default function RecommendedBrandClient({
-  brands: initialBrands,
+  brands,
 }: RecommendedBrandClientProps) {
-  const [brands] = useState(initialBrands);
   const [selectedIndex, setSelectedIndex] = useState<number>(0);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-client.tsx` around lines 23
- 30, The component unnecessarily wraps the incoming prop initialBrands in local
state via useState and never uses its setter; update RecommendedBrandClient to
use the prop directly by removing the useState call for brands (remove the const
[brands] = useState(initialBrands) line) and reference initialBrands (or rename
to brands in the function signature) wherever brands is used (including the
empty-array check and rendering logic) to avoid stale-prop synchronization
issues; leave selectedIndex state intact.

10-17: Product & { isLiked?: boolean; wishlistId?: number } 패턴이 6개 이상의 컴포넌트에서 중복 정의되고 있습니다.

src/components/product/product-list.tsx, product-card.tsx, product-detail-view.tsxsrc/components/recommend-brand/, recommend-carousel/ 계층의 컴포넌트들에서 모두 동일한 타입을 로컬 정의하고 있습니다. 공통 타입 ProductWithWishlistsrc/types/domain/product.ts에 정의하고 재사용하면 타입 안정성과 유지보수성이 향상됩니다.

제안 예시

src/types/domain/product.ts에 추가:

export type ProductWithWishlist = Product & {
  isLiked?: boolean;
  wishlistId?: number;
};

그러면 이 파일에서:

-import { Product } from '@/types/domain/product';
-
-type BrandProduct = Product & {
-  isLiked?: boolean;
-  wishlistId?: number;
-};
-
-type BrandWithWishlistProducts = Omit<BrandWithProducts, 'products'> & {
-  products: BrandProduct[];
-};
+import { ProductWithWishlist } from '@/types/domain/product';
+
+type BrandWithWishlistProducts = Omit<BrandWithProducts, 'products'> & {
+  products: ProductWithWishlist[];
+};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-client.tsx` around lines 10
- 17, Introduce a shared exported type ProductWithWishlist (equivalent to
Product & { isLiked?: boolean; wishlistId?: number }) in the central product
domain types module, then replace local definitions: update BrandProduct to use
ProductWithWishlist and change BrandWithWishlistProducts to be
Omit<BrandWithProducts, 'products'> & { products: ProductWithWishlist[] };
import and reuse ProductWithWishlist in all components currently redefining the
pattern (e.g., product-list, product-card, product-detail-view, recommend-brand,
recommend-carousel) to eliminate duplication and ensure type consistency.
src/components/recommend-brand/recommended-brand-container.tsx (1)

7-10: api.get 호출에 cache / next.revalidate 옵션 부재 — 의도 확인 필요.

Next.js 15+에서 GET Route Handler 및 fetch의 기본 캐싱이 비활성화되었습니다. /brands/recommend는 매 요청마다 호출됩니다. 추천 데이터가 자주 변하지 않는다면 { next: { revalidate: 60 } } 등 ISR 옵션을 고려해볼 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-container.tsx` around lines
7 - 10, The api.get call for '/brands/recommend' is missing cache/revalidate
options so it runs on every request; decide desired caching and update the
api.get call used when building brandsResult (the Promise in Promise.allSettled
alongside getMyWishlist) to include Next.js fetch options (e.g., pass { next: {
revalidate: 60 } } for ISR or { cache: 'force-cache' }/appropriate option your
api wrapper supports) so recommendations are cached per your intended TTL;
ensure the change is applied to the api.get('/brands/recommend') invocation in
recommended-brand-container (brandsResult).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/use-wishlist.ts`:
- Around line 47-58: Change the falsy check for wishlistId to a null/undefined
check so a valid 0 id isn't ignored: when handling addToWishlist's response in
the block that calls setCommittedState/onAddSuccess, replace
response.data?.wishlistId truthiness check with a != null (or !== undefined &&
!== null) check against response.data.wishlistId; likewise, in the delete path
guard, avoid silently skipping unlike when committedState.wishlistId is
missing—add a warning log (e.g., via console.warn or the module logger) if
committedState.wishlistId is null/undefined before calling deleteFromWishlist,
and only call deleteFromWishlist when committedState.wishlistId != null; keep
using addToWishlist, deleteFromWishlist, setCommittedState, committedState, and
onAddSuccess identifiers.

---

Outside diff comments:
In `@src/hooks/use-wishlist.ts`:
- Around line 39-63: The hook useWishlist currently initializes committedState
from initialIsLiked/initialWishlistId only on mount and therefore can go stale;
add a useEffect inside useWishlist that watches initialIsLiked and
initialWishlistId and updates committedState (and optimistic state if needed)
when those props change so committedState stays in sync; update the effect to
call setCommittedState({ isLiked: initialIsLiked, wishlistId: initialWishlistId
}) to mirror prop changes. Also in wishlist-grid.tsx replace the unstable React
key usage key={item.wishlistId} with the stable identity key={item.productId} so
list reconciliation uses productId rather than wishlistId.

---

Nitpick comments:
In `@src/components/recommend-brand/recommended-brand-client.tsx`:
- Around line 23-30: The component unnecessarily wraps the incoming prop
initialBrands in local state via useState and never uses its setter; update
RecommendedBrandClient to use the prop directly by removing the useState call
for brands (remove the const [brands] = useState(initialBrands) line) and
reference initialBrands (or rename to brands in the function signature) wherever
brands is used (including the empty-array check and rendering logic) to avoid
stale-prop synchronization issues; leave selectedIndex state intact.
- Around line 10-17: Introduce a shared exported type ProductWithWishlist
(equivalent to Product & { isLiked?: boolean; wishlistId?: number }) in the
central product domain types module, then replace local definitions: update
BrandProduct to use ProductWithWishlist and change BrandWithWishlistProducts to
be Omit<BrandWithProducts, 'products'> & { products: ProductWithWishlist[] };
import and reuse ProductWithWishlist in all components currently redefining the
pattern (e.g., product-list, product-card, product-detail-view, recommend-brand,
recommend-carousel) to eliminate duplication and ensure type consistency.

In `@src/components/recommend-brand/recommended-brand-container.tsx`:
- Around line 7-10: The api.get call for '/brands/recommend' is missing
cache/revalidate options so it runs on every request; decide desired caching and
update the api.get call used when building brandsResult (the Promise in
Promise.allSettled alongside getMyWishlist) to include Next.js fetch options
(e.g., pass { next: { revalidate: 60 } } for ISR or { cache: 'force-cache'
}/appropriate option your api wrapper supports) so recommendations are cached
per your intended TTL; ensure the change is applied to the
api.get('/brands/recommend') invocation in recommended-brand-container
(brandsResult).

Comment thread src/hooks/use-wishlist.ts
Comment on lines 47 to 58
if (nextIsLiked) {
const response = await addToWishlist(productId);
if (response.success && response.data?.wishlistId) {
setOptimisticState({
isLiked: true,
wishlistId: response.data.wishlistId,
});
setCommittedState({ isLiked: true, wishlistId: response.data.wishlistId });
onAddSuccess?.(response.data.wishlistId);
} else {
setOptimisticState(previousState);
}
} else if (previousState.wishlistId) {
const response = await deleteFromWishlist(previousState.wishlistId);
if (!response.success) {
setOptimisticState(previousState);
} else if (committedState.wishlistId) {
const response = await deleteFromWishlist(committedState.wishlistId);
if (response.success) {
setCommittedState({ isLiked: false, wishlistId: undefined });
}
} else {
setOptimisticState(previousState);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

addToWishlist 성공 시 wishlistId가 falsy(0 등)이면 committed state가 갱신되지 않습니다.

Line 49의 response.data?.wishlistIdwishlistId === 0일 때 falsy로 평가됩니다. 실제로 0이 반환될 가능성은 낮지만, 방어적으로 != null 체크가 더 안전합니다.

또한 Line 53에서 committedState.wishlistId가 없으면 (예: 서버에서 add 성공 응답은 왔지만 wishlistId 누락) unlike 경로가 무시됩니다. 이 경우 optimistic UI는 자동 롤백되어 UX 문제는 없으나, 디버깅 시 혼란을 줄 수 있으므로 경고 로그 추가를 고려해보세요.

제안
-          if (response.success && response.data?.wishlistId) {
+          if (response.success && response.data?.wishlistId != null) {
-        } else if (committedState.wishlistId) {
+        } else if (committedState.wishlistId != null) {
📝 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
if (nextIsLiked) {
const response = await addToWishlist(productId);
if (response.success && response.data?.wishlistId) {
setOptimisticState({
isLiked: true,
wishlistId: response.data.wishlistId,
});
setCommittedState({ isLiked: true, wishlistId: response.data.wishlistId });
onAddSuccess?.(response.data.wishlistId);
} else {
setOptimisticState(previousState);
}
} else if (previousState.wishlistId) {
const response = await deleteFromWishlist(previousState.wishlistId);
if (!response.success) {
setOptimisticState(previousState);
} else if (committedState.wishlistId) {
const response = await deleteFromWishlist(committedState.wishlistId);
if (response.success) {
setCommittedState({ isLiked: false, wishlistId: undefined });
}
} else {
setOptimisticState(previousState);
}
if (nextIsLiked) {
const response = await addToWishlist(productId);
if (response.success && response.data?.wishlistId != null) {
setCommittedState({ isLiked: true, wishlistId: response.data.wishlistId });
onAddSuccess?.(response.data.wishlistId);
}
} else if (committedState.wishlistId != null) {
const response = await deleteFromWishlist(committedState.wishlistId);
if (response.success) {
setCommittedState({ isLiked: false, wishlistId: undefined });
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/use-wishlist.ts` around lines 47 - 58, Change the falsy check for
wishlistId to a null/undefined check so a valid 0 id isn't ignored: when
handling addToWishlist's response in the block that calls
setCommittedState/onAddSuccess, replace response.data?.wishlistId truthiness
check with a != null (or !== undefined && !== null) check against
response.data.wishlistId; likewise, in the delete path guard, avoid silently
skipping unlike when committedState.wishlistId is missing—add a warning log
(e.g., via console.warn or the module logger) if committedState.wishlistId is
null/undefined before calling deleteFromWishlist, and only call
deleteFromWishlist when committedState.wishlistId != null; keep using
addToWishlist, deleteFromWishlist, setCommittedState, committedState, and
onAddSuccess identifiers.

Copy link
Copy Markdown
Contributor

@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.

🧹 Nitpick comments (3)
src/components/recommend-brand/recommended-brand-grid-card.tsx (1)

24-29: || 대신 ?? 사용 권장 (nullish coalescing)

isLikedboolean | undefined 타입이므로, ||보다 ??가 의미적으로 정확합니다. 현재 isLikedfalse일 때도 false || false로 동일하게 동작하지만, 향후 유지보수 시 혼동을 줄일 수 있습니다. 이 패턴이 product-card.tsx, recommended-product-card.tsx에도 동일하게 적용되어 있으니 일괄 수정을 고려해 주세요.

♻️ 제안
          <WishlistButton
            productId={product.id}
-           initialIsLiked={product.isLiked || false}
+           initialIsLiked={product.isLiked ?? false}
            initialWishlistId={product.wishlistId}
            className="absolute top-2 right-2"
          />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-grid-card.tsx` around lines
24 - 29, Replace the use of the logical OR fallback with nullish coalescing for
the WishlistButton's initialIsLiked prop: change occurrences where
product.isLiked is defaulted using "||" to use "??" instead (e.g., in
RecommendedBrandGridCard's WishlistButton - prop initialIsLiked={product.isLiked
|| false} should be initialIsLiked={product.isLiked ?? false}); apply the same
change in product-card.tsx and recommended-product-card.tsx so the
initialIsLiked prop only falls back when isLiked is null or undefined, not when
it's explicitly false.
src/components/recommend-brand/recommended-brand-container.tsx (1)

9-10: cache: 'force-cache'next: { revalidate: 60 } 조합 확인 필요

Next.js 15에서 force-cache + revalidate는 ISR 패턴으로 동작하지만, Next.js 공식 문서에서는 fetch 옵션으로 cache를 명시하기보다 next.revalidate만 사용하는 것을 권장하는 경우가 있습니다. force-cache를 제거하고 revalidate만 남겨도 동일한 ISR 동작을 얻을 수 있습니다.

♻️ 간소화 제안
     api.get<BrandWithProducts[]>('/brands/recommend', {
-      cache: 'force-cache',
       next: { revalidate: 60 },
     }),
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/recommend-brand/recommended-brand-container.tsx` around lines
9 - 10, 현재 fetch 옵션에서 cache: 'force-cache'와 next: { revalidate: 60 }를 함께 사용하고 있어
중복된 ISR 설정이 됩니다; 수정하려면 fetch 호출에서 cache: 'force-cache'를 제거하고 next: { revalidate:
60 }만 남겨 RecommendedBrandContainer 또는 해당 fetch 옵션이 적용된 부분이 Next.js의 권장 ISR 동작을
따르도록 하세요 (즉, 찾아야 할 식별자: cache, next: { revalidate: 60 },
RecommendedBrandContainer / recommended-brand-container.tsx).
src/components/product/product-card.tsx (1)

25-30: 네이티브 <img> 대신 next/imageImage 사용 권장

RecommendedBrandGridCard에서는 next/imageImage를 사용하고 있으나 이 컴포넌트는 네이티브 <img>를 사용합니다. 상품 썸네일은 사용자에게 가장 먼저 보이는 요소이므로, lazy loading · WebP/AVIF 자동 변환 · 반응형 사이즈 등 next/image의 최적화 혜택을 받는 것이 성능과 LCP에 유리합니다.

♻️ 제안
+import Image from 'next/image';
 import Link from 'next/link';
-          <img
+          <Image
             src={product.thumbnailImageUrl}
             alt={product.name}
             width={164}
             height={170}
             className="h-[170px] w-[164px] object-cover"
           />
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/product/product-card.tsx` around lines 25 - 30, Replace the
native <img> in the product-card component with Next.js's Image: import Image
from 'next/image' at the top of src/components/product/product-card.tsx and swap
the <img src={product.thumbnailImageUrl} alt={product.name} width={164}
height={170} className="…" /> element for <Image src={product.thumbnailImageUrl}
alt={product.name} width={164} height={170} className="…" /> (preserve
product.thumbnailImageUrl, product.name, width/height and className). If this
thumbnail is above-the-fold/LCP-critical, add the priority prop; otherwise rely
on default lazy loading. Ensure the Image src is a valid string/URL or handle
fallback accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/components/product/product-card.tsx`:
- Around line 25-30: Replace the native <img> in the product-card component with
Next.js's Image: import Image from 'next/image' at the top of
src/components/product/product-card.tsx and swap the <img
src={product.thumbnailImageUrl} alt={product.name} width={164} height={170}
className="…" /> element for <Image src={product.thumbnailImageUrl}
alt={product.name} width={164} height={170} className="…" /> (preserve
product.thumbnailImageUrl, product.name, width/height and className). If this
thumbnail is above-the-fold/LCP-critical, add the priority prop; otherwise rely
on default lazy loading. Ensure the Image src is a valid string/URL or handle
fallback accordingly.

In `@src/components/recommend-brand/recommended-brand-container.tsx`:
- Around line 9-10: 현재 fetch 옵션에서 cache: 'force-cache'와 next: { revalidate: 60
}를 함께 사용하고 있어 중복된 ISR 설정이 됩니다; 수정하려면 fetch 호출에서 cache: 'force-cache'를 제거하고 next:
{ revalidate: 60 }만 남겨 RecommendedBrandContainer 또는 해당 fetch 옵션이 적용된 부분이
Next.js의 권장 ISR 동작을 따르도록 하세요 (즉, 찾아야 할 식별자: cache, next: { revalidate: 60 },
RecommendedBrandContainer / recommended-brand-container.tsx).

In `@src/components/recommend-brand/recommended-brand-grid-card.tsx`:
- Around line 24-29: Replace the use of the logical OR fallback with nullish
coalescing for the WishlistButton's initialIsLiked prop: change occurrences
where product.isLiked is defaulted using "||" to use "??" instead (e.g., in
RecommendedBrandGridCard's WishlistButton - prop initialIsLiked={product.isLiked
|| false} should be initialIsLiked={product.isLiked ?? false}); apply the same
change in product-card.tsx and recommended-product-card.tsx so the
initialIsLiked prop only falls back when isLiked is null or undefined, not when
it's explicitly false.

@Seoje1405 Seoje1405 merged commit 6126b3d into IT-Cotato:develop Feb 17, 2026
@Seoje1405 Seoje1405 deleted the refactor/home-wishlist branch February 18, 2026 17:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant