Skip to content
39 changes: 30 additions & 9 deletions src/components/product/product-card.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,48 @@
import Link from 'next/link';
import { Product } from '@/types/domain/product';
import { WishlistButton } from './wishlist-button';

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

// 카드 UI 컴포넌트, 클릭 시 상세 페이지로 이동함.
interface ProductCardProps {
product: Product;
product: ProductWithWishlist;
detailFrom?: string;
showWishlistButton?: boolean;
}

export default function ProductCard({ product, detailFrom }: ProductCardProps) {
export default function ProductCard({
product,
detailFrom,
showWishlistButton = false,
}: ProductCardProps) {
const href = detailFrom
? `/product/${product.id}?from=${encodeURIComponent(detailFrom)}`
: `/product/${product.id}`;

return (
<Link href={href} className="block">
<div className="font-pretendard flex w-41 flex-col gap-1">
<img
src={product.thumbnailImageUrl}
alt={product.name}
width={164}
height={170}
className="h-[170px] w-[164px] object-cover"
/>
<div className="relative">
<img
src={product.thumbnailImageUrl}
alt={product.name}
width={164}
height={170}
className="h-[170px] w-[164px] object-cover"
/>
{showWishlistButton && (
<WishlistButton
productId={product.id}
initialIsLiked={product.isLiked || false}
initialWishlistId={product.wishlistId}
className="absolute top-2 right-2"
/>
)}
</div>
<span className="font-extrabold">{product.brandName}</span>
<span className="line-clamp-2 h-14 w-full overflow-hidden text-lg font-medium text-ellipsis">
{product.name}
Expand Down
32 changes: 23 additions & 9 deletions src/components/product/product-list-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { api } from '@/lib/api-client';
import { ProductSearchResult } from '@/types/domain/product';
import { ProductSortType } from '@/types/enums';
import { ProductList } from './product-list';
import { getMyWishlist } from '@/app/actions/wishlist';

interface ProductListContainerProps {
params: Promise<{ parentId: string; id: string }>;
Expand Down Expand Up @@ -35,22 +36,35 @@ export default async function ProductListContainer({
throw new Error('Invalid category ID');
}

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


const wishlistByProductId = new Map(
wishlistItems.map((item) => [item.productId, item.wishlistId]),
);
const productsWithWishlist = result.products.content.map((product) => ({
...product,
isLiked: wishlistByProductId.has(product.id),
wishlistId: wishlistByProductId.get(product.id),
}));

const totalElements = result.products.totalElements;

return (
<ProductList
products={result.products.content}
products={productsWithWishlist}
totalElements={totalElements}
productDetailFrom={`/category/${parentId}/${subCategoryId}`}
showWishlistButton
/>
);
}
9 changes: 8 additions & 1 deletion src/components/product/product-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,24 @@ import { Product } from '@/types/domain/product';
import ProductCard from './product-card';

// 상품 목록 데이터를 받아서 그리드 형태로 보여주는 목록 페이지 컴포넌트
type ProductWithWishlist = Product & {
isLiked?: boolean;
wishlistId?: number;
};

interface ProductListProps {
products: Product[];
products: ProductWithWishlist[];
title?: string;
totalElements?: number;
productDetailFrom?: string;
showWishlistButton?: boolean;
}

export function ProductList({
products,
totalElements,
productDetailFrom,
showWishlistButton = false,
}: ProductListProps) {
const count = totalElements ?? products.length;

Expand All @@ -37,6 +43,7 @@ export function ProductList({
key={product.id}
product={product}
detailFrom={productDetailFrom}
showWishlistButton={showWishlistButton}
/>
))}
</div>
Expand Down
16 changes: 13 additions & 3 deletions src/components/recommend-brand/recommended-brand-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,29 @@ import { BrandWithProducts } from '@/types/domain/brand';
import RecommendedBrandHeader from './recommended-brand-header';
import RecommendedBrandGridCard from './recommended-brand-grid-card';
import { getLocaleFromDocument, t } from '@/lib/i18n';
import { Product } from '@/types/domain/product';

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

type BrandWithWishlistProducts = Omit<BrandWithProducts, 'products'> & {
products: BrandProduct[];
};

interface RecommendedBrandClientProps {
brands: BrandWithProducts[];
brands: BrandWithWishlistProducts[];
}

export default function RecommendedBrandClient({
brands,
}: RecommendedBrandClientProps) {
if (brands.length === 0) return null;

const [selectedIndex, setSelectedIndex] = useState<number>(0);
const locale = getLocaleFromDocument();

if (brands.length === 0) return null;

const currentBrand = brands[selectedIndex];
const currentProducts = currentBrand?.products || [];

Expand Down
28 changes: 26 additions & 2 deletions src/components/recommend-brand/recommended-brand-container.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,33 @@
import { api } from '@/lib/api-client';
import { BrandWithProducts } from '@/types/domain/brand';
import RecommendedBrandClient from './recommended-brand-client';
import { getMyWishlist } from '@/app/actions/wishlist';

export default async function RecommendedBrandContainer() {
const brands = await api.get<BrandWithProducts[]>('/brands/recommend');
const [brandsResult, wishlistResult] = await Promise.allSettled([
api.get<BrandWithProducts[]>('/brands/recommend', {
cache: 'force-cache',
next: { revalidate: 3600, tags: ['recommended-brands'] },
}),
getMyWishlist(),
]);

return <RecommendedBrandClient brands={brands} />;
const brands = brandsResult.status === 'fulfilled' ? brandsResult.value : [];
const wishlistItems =
wishlistResult.status === 'fulfilled' ? wishlistResult.value : [];

const wishlistByProductId = new Map(
wishlistItems.map((item) => [item.productId, item.wishlistId]),
);

const brandsWithWishlist = brands.map((brand) => ({
...brand,
products: brand.products.map((product) => ({
...product,
isLiked: wishlistByProductId.has(product.id),
wishlistId: wishlistByProductId.get(product.id),
})),
}));

return <RecommendedBrandClient brands={brandsWithWishlist} />;
}
30 changes: 22 additions & 8 deletions src/components/recommend-brand/recommended-brand-grid-card.tsx
Original file line number Diff line number Diff line change
@@ -1,24 +1,38 @@
import Image from 'next/image';
import Link from 'next/link';
import { Product } from '@/types/domain/product';
import { WishlistButton } from '../product/wishlist-button';

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

export default function RecommendedBrandGridCard({
product,
}: {
product: Product;
product: BrandProduct;
}) {
const href = `/product/${product.id}?from=${encodeURIComponent('/')}`;

return (
<Link href={href} className="block">
<div className="font-pretendard flex w-41 flex-col gap-1">
<Image
src={product.thumbnailImageUrl}
alt={product.name}
width={164}
height={170}
className="h-[170px] w-[164px] object-cover"
/>
<div className="relative">
<Image
src={product.thumbnailImageUrl}
alt={product.name}
width={164}
height={170}
className="h-[170px] w-[164px] object-cover"
/>
<WishlistButton
productId={product.id}
initialIsLiked={product.isLiked || false}
initialWishlistId={product.wishlistId}
className="absolute top-2 right-2"
/>
</div>
<span className="font-extrabold">{product.brandName}</span>
<span className="line-clamp-2 h-14 w-full overflow-hidden text-lg font-medium text-ellipsis">
{product.name}
Expand Down
24 changes: 22 additions & 2 deletions src/components/recommend-carousel/recommend-product-container.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Product } from '@/types/domain/product';
import RecommendedProductCard from './recommended-product-card';
import { RecommendCarouselItem } from './recommend-carousel-item';
import { RecommendCarousel } from './recommend-carousel';
import { getMyWishlist } from '@/app/actions/wishlist';

interface RecommendProductContainerProps {
endpoint: string;
Expand All @@ -13,12 +14,31 @@ export default async function RecommendProductContainer({
endpoint,
heading,
}: RecommendProductContainerProps) {
const products = await api.get<Product[]>(endpoint);
const [productsResult, wishlistResult] = await Promise.allSettled([
api.get<Product[]>(endpoint),
getMyWishlist(),
]);

const products =
productsResult.status === 'fulfilled' ? productsResult.value : [];
const wishlistItems =
wishlistResult.status === 'fulfilled' ? wishlistResult.value : [];

const wishlistByProductId = new Map(
wishlistItems.map((item) => [item.productId, item.wishlistId]),
);

return (
<RecommendCarousel heading={heading}>
{products.map((product) => (
<RecommendCarouselItem key={product.id}>
<RecommendedProductCard productInfo={product} />
<RecommendedProductCard
productInfo={{
...product,
isLiked: wishlistByProductId.has(product.id),
wishlistId: wishlistByProductId.get(product.id),
}}
/>
</RecommendCarouselItem>
))}
</RecommendCarousel>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { WishlistButton } from '../product/wishlist-button';

interface RecommendedProduct extends Product {
isLiked?: boolean;
wishlistId?: number;
}

export default function RecommendedProductCard({
Expand Down Expand Up @@ -72,6 +73,7 @@ export default function RecommendedProductCard({
<WishlistButton
productId={productInfo.id}
initialIsLiked={productInfo.isLiked || false}
initialWishlistId={productInfo.wishlistId}
className="absolute top-2 right-2"
/>
</div>
Expand Down