[FEAT]: 홈/카테고리 상품 카드 찜하기 기능 확장#68
Conversation
|
Caution Review failedThe pull request is closed. Walkthrough제품/추천 컴포넌트 전반에 위시리스트 통합이 추가되었습니다. 서버에서 제품과 위시리스트를 병렬 조회해 각 제품에 isLiked/wishlistId를 주입하고, 카드 컴포넌트들이 WishlistButton 오버레이와 새로운 타입/프롭(showWishlistButton)을 사용하도록 변경되었습니다. Changes
Sequence DiagramsequenceDiagram
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)
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
Suggested labels ♻️ REFACTOR 🚥 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
🧪 Generate unit tests (beta)
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: 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.isLiked는boolean | 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
Suspenseto 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.
| const [result, wishlistItems] = await Promise.all([ | ||
| api.get<ProductSearchResult>('/products', { | ||
| params: { | ||
| categoryId: safeCategoryId, | ||
| sortType: safeSortType, | ||
| page: safePage, | ||
| size: 20, | ||
| }, | ||
| }), | ||
| getMyWishlist(), | ||
| ]); |
There was a problem hiding this comment.
Promise.all → Promise.allSettled 변경 권장 — 다른 컨테이너와 일관성 및 탄력성 확보.
recommend-product-container.tsx와 recommended-brand-container.tsx는 Promise.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.
| 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.
There was a problem hiding this comment.
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.ts의useState는 마운트 시에만 초기값을 적용하고, prop 변경을 감지하는useEffect가 없습니다. 부모에서initialIsLiked나initialWishlistId가 변경되어도committedState가 업데이트되지 않아 stale 상태가 유지됩니다.추가로
src/components/wishlist/wishlist-grid.tsx40번 줄에서key={item.wishlistId}로 설정되어 있는데, 리스트 아이템의 identity는productId여야 합니다.wishlistId는 서버 상태에 따라 변경될 수 있기 때문에 React의 key로 적합하지 않습니다.수정이 필요한 두 가지
use-wishlist.ts:useEffect를 추가하여initialIsLiked/initialWishlistIdprop 변경 시 상태 동기화wishlist-grid.tsx40번 줄: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는initialBrandsprop 변경 시 동기화 문제를 유발합니다.제안
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.tsx와src/components/recommend-brand/,recommend-carousel/계층의 컴포넌트들에서 모두 동일한 타입을 로컬 정의하고 있습니다. 공통 타입ProductWithWishlist를src/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).
| 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); | ||
| } |
There was a problem hiding this comment.
addToWishlist 성공 시 wishlistId가 falsy(0 등)이면 committed state가 갱신되지 않습니다.
Line 49의 response.data?.wishlistId는 wishlistId === 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.
| 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.
There was a problem hiding this comment.
🧹 Nitpick comments (3)
src/components/recommend-brand/recommended-brand-grid-card.tsx (1)
24-29:||대신??사용 권장 (nullish coalescing)
isLiked이boolean | undefined타입이므로,||보다??가 의미적으로 정확합니다. 현재isLiked가false일 때도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/image의Image사용 권장
RecommendedBrandGridCard에서는next/image의Image를 사용하고 있으나 이 컴포넌트는 네이티브<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.
📝 개요
홈 추천(상품/브랜드)과 하위 카테고리 상품 카드에
찜하기버튼 및 연동 로직을 추가한 작업입니다.기존에는 일부 카드에서만 찜하기가 가능했고, 홈/카테고리 목록에서는 동일한 사용자 행동을 제공하지 못했습니다.
이번 PR은 카드 단위 찜 경험 확장 + 초기 찜 상태 일치 + 기존 흐름(상세/찜목록)과의 일관성 유지를 목표로 반영했습니다.
관련 이슈 번호: #이슈번호
Closes #이슈번호
🎯 변경 의도
isLiked,wishlistId)를 반영해 UI 신뢰도 확보WishlistButton,useWishlist) 재사용으로 유지보수성 확보🚀 주요 변경 사항
1) 홈 추천 상품 카드 찜 연동
isLiked,wishlistId매핑 후 카드에 전달WishlistButton에 초기 상태값 주입2) 랜덤 브랜드 추천 카드 찜 연동
WishlistButton추가3) 하위 카테고리 상품 카드 찜 연동
ProductListContainer에서 상품별 찜 상태 매핑ProductList에showWishlistButton옵션 추가ProductCard에서 옵션 활성 시 찜 버튼 렌더링4) 구조/안정성 보완
🧪 테스트/검증 포인트
홈
하위 카테고리
공통
✅ 체크리스트