+
- {isDraw ? (
- 무승부입니다.
- ) : (
- <>
- {winner!.name}
- 이(가) 승리하였습니다!
- >
- )}
+ {winner.name}
+ 이(가) 승리하였습니다!
- {isDraw
- ? '3가지 항목에서 동등한 결과를 보입니다.'
- : `3가지 항목 중 ${winCount}가지 항목에서 우세합니다.`}
+ {TOTAL_COMPARISON_ITEMS}가지 항목 중 {winCount}가지 항목에서 우세합니다.
);
diff --git a/src/app/compare/components/CompareReviewCell.tsx b/src/app/compare/components/CompareReviewCell.tsx
index c0a5c883..5ccecb6f 100644
--- a/src/app/compare/components/CompareReviewCell.tsx
+++ b/src/app/compare/components/CompareReviewCell.tsx
@@ -1,40 +1,42 @@
import Link from 'next/link';
import StarDisplay from '@/components/ui/StarDisplay';
-import { type Product } from '@/types/product/productType';
+import { type ProductDetail } from '@/types/product/productType';
import { type ReviewDetail } from '@/types/review/review';
interface CompareReviewCellProps {
- product: Product;
+ product: ProductDetail;
reviews: ReviewDetail[];
loading: boolean;
}
+const MAX_VISIBLE_REVIEWS = 2;
+
const CompareReviewCell = ({ product, reviews, loading }: CompareReviewCellProps) => {
+ const cellTextClass = 'text-gray-9fa6b2 text-mogazoa-14px-300';
+
return (
-
+ |
{loading ? (
- 리뷰 로딩 중...
+ 리뷰 로딩 중...
) : reviews.length > 0 ? (
{reviews.map((review) => (
-
-
- {review.content}
-
+
+ {review.content}
))}
) : (
- 리뷰가 없습니다
+ 리뷰가 없습니다
)}
- {product.reviewCount > 2 && (
+ {product.reviewCount > MAX_VISIBLE_REVIEWS && (
더 많은 리뷰 확인하러가기
diff --git a/src/app/compare/components/CompareReviewRow.tsx b/src/app/compare/components/CompareReviewRow.tsx
index 60c9c2a3..b6325f1a 100644
--- a/src/app/compare/components/CompareReviewRow.tsx
+++ b/src/app/compare/components/CompareReviewRow.tsx
@@ -1,49 +1,59 @@
+'use client';
+
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
-import { getProductReviews } from '@/actions/review/review';
-import { type Product } from '@/types/product/productType';
+import { type ProductDetail } from '@/types/product/productType';
import { type ReviewDetail } from '@/types/review/review';
import CompareReviewCell from './CompareReviewCell';
interface CompareReviewRowProps {
- products: [Product, Product];
+ products: [ProductDetail, ProductDetail];
}
const CompareReviewRow = ({ products }: CompareReviewRowProps) => {
- const [product1, product2] = products;
- const [reviews1, setReviews1] = useState ([]);
- const [reviews2, setReviews2] = useState([]);
+ const [reviewsData, setReviewsData] = useState<[ReviewDetail[], ReviewDetail[]]>([[], []]);
const [loading, setLoading] = useState(true);
+ const productId1 = products[0].id;
+ const productId2 = products[1].id;
+
useEffect(() => {
+ const controller = new AbortController();
const fetchReviews = async () => {
try {
- const [reviewsData1, reviewsData2] = await Promise.all([
- (await getProductReviews(product1.id, 'ratingDesc')).list,
- (await getProductReviews(product2.id, 'ratingDesc')).list,
+ const [res1, res2] = await Promise.all([
+ fetch(`/api/reviews/${productId1}`, { signal: controller.signal }),
+ fetch(`/api/reviews/${productId2}`, { signal: controller.signal }),
]);
- setReviews1(reviewsData1.slice(0, 2));
- setReviews2(reviewsData2.slice(0, 2));
- } catch {
- toast.error('리뷰 데이터를 불러올 수 없습니다.');
+
+ if (!res1.ok || !res2.ok) throw new Error('Failed to fetch reviews');
+
+ const [data1, data2] = await Promise.all([res1.json(), res2.json()]);
+ setReviewsData([data1.topReviews ?? [], data2.topReviews ?? []]);
+ } catch (e) {
+ if (e instanceof Error && e.name !== 'AbortError') {
+ toast.error('리뷰 데이터를 불러올 수 없습니다.');
+ }
} finally {
setLoading(false);
}
};
fetchReviews();
- }, [product1.id, product2.id]);
+ return () => controller.abort();
+ }, [productId1, productId2]);
return (
|
리뷰
|
-
-
+
+
+ |
);
};
diff --git a/src/app/compare/components/CompareTable.tsx b/src/app/compare/components/CompareTable.tsx
index b516cb7a..ca732ccf 100644
--- a/src/app/compare/components/CompareTable.tsx
+++ b/src/app/compare/components/CompareTable.tsx
@@ -1,62 +1,232 @@
+import { useEffect, useState } from 'react';
+
+import { toast } from 'sonner';
+
+import FavoriteIcon from '@/assets/icon/Icon-favorite.svg';
+import ReviewIcon from '@/assets/icon/Icon-review.svg';
+import StarIcon from '@/assets/icon/Icon-star.svg';
+import StarDisplay from '@/components/ui/StarDisplay';
import { type ComparisonResult } from '@/types/compare/compareType';
-import { type Product } from '@/types/product/productType';
+import { type ProductDetail } from '@/types/product/productType';
+import { type ReviewDetail } from '@/types/review/review';
import CompareReviewRow from './CompareReviewRow';
import CompareTableRow from './CompareTableRow';
interface CompareTableProps {
- products: [Product, Product];
+ products: [ProductDetail, ProductDetail];
comparisonResult: ComparisonResult;
}
+const COMPARISON_METRICS = [
+ { label: '별점', key: 'rating' as const },
+ { label: '찜 개수', key: 'favoriteCount' as const },
+ { label: '리뷰 개수', key: 'reviewCount' as const },
+];
+
+const ICON_MAP = {
+ 별점: ,
+ '찜 개수': ,
+ '리뷰 개수': ,
+} as const;
+
const CompareTable = ({ products, comparisonResult }: CompareTableProps) => {
- const [product1, product2] = products;
+ const headerCellClass = 'text-mogazoa-16px-400 text-gray-9fa6b2 w-1/4 py-5 text-center';
+ const [reviewsData, setReviewsData] = useState<[ReviewDetail[], ReviewDetail[]]>([[], []]);
+ const [reviewsLoading, setReviewsLoading] = useState(true);
+
+ const productId1 = products[0].id;
+ const productId2 = products[1].id;
+
+ // 모바일용 리뷰 데이터 가져오기
+ useEffect(() => {
+ const controller = new AbortController();
+ const fetchReviews = async () => {
+ try {
+ const [res1, res2] = await Promise.all([
+ fetch(`/api/reviews/${productId1}`, { signal: controller.signal }),
+ fetch(`/api/reviews/${productId2}`, { signal: controller.signal }),
+ ]);
+
+ if (!res1.ok || !res2.ok) throw new Error('Failed to fetch reviews');
+
+ const [data1, data2] = await Promise.all([res1.json(), res2.json()]);
+ setReviewsData([data1.topReviews ?? [], data2.topReviews ?? []]);
+ } catch (e) {
+ if (e instanceof Error && e.name !== 'AbortError') {
+ toast.error('리뷰 데이터를 불러올 수 없습니다.');
+ }
+ } finally {
+ setReviewsLoading(false);
+ }
+ };
+
+ fetchReviews();
+ return () => controller.abort();
+ }, [productId1, productId2]);
+
+ // 각 영화별 색상 가져오기 (데스크톱 버전과 동일)
+ const getProductColorClass = (product: ProductDetail) => {
+ return product.id === products[0].id ? 'text-[#05D58B]' : 'text-[#FF2F9F]';
+ };
+
+ const getProductBgClass = (product: ProductDetail) => {
+ return product.id === products[0].id
+ ? 'bg-[#05D58B]/10 border-[#05D58B]/30'
+ : 'bg-[#FF2F9F]/10 border-[#FF2F9F]/30';
+ };
+
+ // 모바일용 메트릭 카드 컴포넌트
+ const MetricCard = ({ metric }: { metric: (typeof COMPARISON_METRICS)[0] }) => {
+ const detail = comparisonResult.details[metric.key];
+ const winner = detail.winner as ProductDetail | null;
+ const icon = ICON_MAP[metric.label as keyof typeof ICON_MAP];
+
+ return (
+
+
+ {icon}
+ {metric.label}
+
+
+ {products.map((product, productIndex) => {
+ const value = productIndex === 0 ? detail.value1 : detail.value2;
+ const isWinner = winner?.id === product.id;
+ return (
+
+
+ {product.name}
+
+
+ {metric.key === 'rating' ? value.toFixed(1) : value.toLocaleString()}
+
+
+ );
+ })}
+
+ {winner && (
+
+
+ {winner.name} 승리 🎉
+
+
+ )}
+
+ );
+ };
return (
-
-
-
-
- |
- 기준
- |
-
- {product1.name}
- |
-
- {product2.name}
- |
-
- 결과
- |
-
-
-
-
-
-
-
-
-
-
+ <>
+ {/* 데스크톱용 테이블 */}
+
+
+
+
+ | 기준 |
+
+ {products[0].name}
+ |
+
+ {products[1].name}
+ |
+ 결과 |
+
+
+
+ {COMPARISON_METRICS.map((metric) => (
+
+ ))}
+
+
+
+
+
+ {/* 모바일용 카드 레이아웃 */}
+
+ {COMPARISON_METRICS.map((metric) => (
+
+ ))}
+
+ {/* 모바일용 리뷰 섹션 */}
+
+
+
+ 리뷰
+
+ {reviewsLoading ? (
+
+ 리뷰 로딩 중...
+
+ ) : (
+
+ {products.map((product, productIndex) => {
+ const productReviews = reviewsData[productIndex];
+ const productColorClass = getProductColorClass(product);
+
+ return (
+
+
+ {product.name}
+
+
+ {productReviews.length > 0 ? (
+
+ {productReviews.map((review) => (
+
+
+
+
+ {review.rating}점
+
+
+
+ {review.content}
+
+
+ ))}
+
+ ) : (
+
+ )}
+
+ {product.reviewCount > 2 && (
+
+ )}
+
+ );
+ })}
+
+ )}
+
+
+ >
);
};
diff --git a/src/app/compare/components/CompareTableRow.tsx b/src/app/compare/components/CompareTableRow.tsx
index 805647e2..85bebe0f 100644
--- a/src/app/compare/components/CompareTableRow.tsx
+++ b/src/app/compare/components/CompareTableRow.tsx
@@ -1,51 +1,62 @@
import FavoriteIcon from '@/assets/icon/Icon-favorite.svg';
import ReviewIcon from '@/assets/icon/Icon-review.svg';
import StarIcon from '@/assets/icon/Icon-star.svg';
-import { type Product } from '@/types/product/productType';
-import { getProductColorClass } from '@/utils/compareColors';
+import { type ProductDetail } from '@/types/product/productType';
interface CompareTableRowProps {
label: string;
value1: number;
value2: number;
- winner: Product | null;
- products: [Product, Product];
+ winner: ProductDetail | null;
+ products: [ProductDetail, ProductDetail];
}
+const ICON_MAP = {
+ 별점: ,
+ '찜 개수': ,
+ '리뷰 개수': ,
+} as const;
+
const CompareTableRow = ({ label, value1, value2, winner, products }: CompareTableRowProps) => {
- const [product1, product2] = products;
-
- const getIcon = () => {
- if (label === '별점') return ;
- if (label === '찜 개수')
- return ;
- if (label === '리뷰 개수') return ;
- return null;
+ const icon = ICON_MAP[label as keyof typeof ICON_MAP];
+
+ const formatValue = (value: number) => {
+ return label === '별점' ? (value === 0 ? '0' : value.toFixed(1)) : value.toLocaleString();
+ };
+
+ const getProductColor = (product: ProductDetail) => {
+ return product.id === products[0].id ? 'text-[#05D58B]' : 'text-[#FF2F9F]';
};
+ const getCellClass = (product: ProductDetail) => {
+ const isWinner = winner?.id === product.id;
+ return `text-mogazoa-16px-400 px-[40px] py-7 text-center ${
+ isWinner ? `${getProductColor(product)} text-mogazoa-16px-600` : 'text-white-f1f1f5'
+ }`;
+ };
+
+ const baseCellClass = 'text-mogazoa-16px-400 text-gray-9fa6b2 px-[40px] py-7 text-center';
+
return (
- | {label} |
-
+ | {label} |
+
- {getIcon()}
- {label === '별점' ? (value1 === 0 ? '0' : value1.toFixed(1)) : value1.toLocaleString()}
+ {icon}
+ {formatValue(value1)}
|
-
+ |
- {getIcon()}
- {label === '별점' ? (value2 === 0 ? '0' : value2.toFixed(1)) : value2.toLocaleString()}
+ {icon}
+ {formatValue(value2)}
|
-
+ |
{winner ? (
-
- {winner.name} 승리 🎉
+
+ {winner.name}
+ 승리 🎉
) : (
무승부
diff --git a/src/app/compare/components/CompareToolbar.tsx b/src/app/compare/components/CompareToolbar.tsx
index 00aeb90a..1c1860f4 100644
--- a/src/app/compare/components/CompareToolbar.tsx
+++ b/src/app/compare/components/CompareToolbar.tsx
@@ -4,7 +4,10 @@ import Button from '@/components/ui/Buttons';
type Mode = 'browse' | 'delete' | 'compare';
-type Props = {
+const BUTTON_CLASS =
+ 'text-mogazoa-12px-300 text-white-f1f1f5 !h-[35px] w-[100px] px-5 py-1 whitespace-nowrap';
+
+interface CompareToolbarProps {
mode: Mode;
compareListLength: number;
selectedDeleteCount: number;
@@ -12,7 +15,7 @@ type Props = {
onExitDelete: () => void;
onConfirmDelete: () => void;
onClearAll: () => void;
-};
+}
const CompareToolbar = ({
mode,
@@ -22,7 +25,7 @@ const CompareToolbar = ({
onExitDelete,
onConfirmDelete,
onClearAll,
-}: Props) => {
+}: CompareToolbarProps) => {
return (
{mode === 'delete' ? (
@@ -31,15 +34,11 @@ const CompareToolbar = ({
variant='primary'
onClick={onConfirmDelete}
disabled={selectedDeleteCount === 0}
- className='text-mogazoa-12px-300 text-white-f1f1f5 !h-[35px] w-[100px] px-5 py-1 whitespace-nowrap'
+ className={BUTTON_CLASS}
>
삭제 ({selectedDeleteCount})
-
@@ -57,7 +56,7 @@ const CompareToolbar = ({
variant='tertiary'
onClick={onClearAll}
disabled={compareListLength === 0}
- className='text-mogazoa-12px-300 text-white-f1f1f5 !h-[35px] w-[100px] px-5 py-1 whitespace-nowrap'
+ className={BUTTON_CLASS}
>
전체 삭제
@@ -68,5 +67,3 @@ const CompareToolbar = ({
};
export default CompareToolbar;
-
-// 버튼 공통 컴포로 만들 수 있을 것 같음.
diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx
index ef7ad5e4..07c863cc 100644
--- a/src/app/compare/page.tsx
+++ b/src/app/compare/page.tsx
@@ -1,8 +1,10 @@
'use client';
+import { useMemo } from 'react';
+
import { useCompareController } from '@/hooks/useCompareController';
+import { useCompareProducts } from '@/hooks/useCompareProducts';
import { useCompareStore } from '@/store/compareStore';
-import { type Product } from '@/types/product/productType';
import CompareGrid from './components/CompareGrid';
import CompareInput from './components/CompareInput';
@@ -10,49 +12,96 @@ import CompareResult from './components/CompareResult';
import CompareToolbar from './components/CompareToolbar';
import NoList from './components/NoList';
+const CONTAINER_CLASS = 'container mx-auto md:px-4 xl:px-30 py-[60px]';
+const TEXT_CENTER_CLASS = 'text-center';
+
const ComparePage = () => {
const { compareList, removeProduct, clearCompareList } = useCompareStore();
+ const { products: compareProducts, loading, error } = useCompareProducts(compareList);
const {
mode,
selectedIds,
selectedDeleteIds,
+ selectedProducts,
+ comparePair,
handleCardSelect,
- handleProductRemoveFromSelected,
+ handleInputSelect,
+ handleSlotRemove,
handleCompare,
backToBrowse,
enterDeleteMode,
exitDeleteMode,
confirmDeleteSelected,
clearAll,
- } = useCompareController({ compareList, removeProduct, clearCompareList });
+ } = useCompareController({ compareList, removeProduct, clearCompareList, compareProducts });
+
+ const selectedDeleteProducts = useMemo(
+ () => compareProducts.filter((p) => selectedDeleteIds.includes(p.id)),
+ [compareProducts, selectedDeleteIds],
+ );
- // 선택된 상품들을 배열로 변환
- const selectedProducts = compareList.filter((p) => selectedIds.has(p.id));
- const selectedDeleteProducts = compareList.filter((p) => selectedDeleteIds.has(p.id));
+ // 로딩 상태
+ if (loading) {
+ return (
+
+ );
+ }
+
+ // 에러 상태
+ if (error) {
+ return (
+
+
+ {error}
+ window.location.reload()}
+ className='text-main-indigo hover:underline'
+ >
+ 다시 시도
+
+
+
+ );
+ }
// 비교목록이 비어있을 때
if (compareList.length === 0) {
return (
-
+
);
}
+ // 비교 모드일 때
+ if (mode === 'compare' && comparePair) {
+ return (
+
+
+
+ );
+ }
+
return (
-
+
{mode !== 'compare' && (
@@ -67,21 +116,15 @@ const ComparePage = () => {
/>
)}
- {/* 영화 비교 시 */}
- {mode === 'compare' && selectedProducts.length === 2 ? (
-
- ) : (
-
- mode === 'delete' ? selectedDeleteIds.has(product.id) : selectedIds.has(product.id)
- }
- onSelect={handleCardSelect}
- />
- )}
+
+ mode === 'delete'
+ ? selectedDeleteIds.includes(product.id)
+ : selectedIds.includes(product.id)
+ }
+ onSelect={handleCardSelect}
+ />
);
};
diff --git a/src/app/products/[productId]/components/AddToCompareButton.tsx b/src/app/products/[productId]/components/AddToCompareButton.tsx
index ad26c7b2..76fda95d 100644
--- a/src/app/products/[productId]/components/AddToCompareButton.tsx
+++ b/src/app/products/[productId]/components/AddToCompareButton.tsx
@@ -5,7 +5,6 @@ import { toast } from 'sonner';
import CompareConfirmModal from '@/app/compare/components/CompareConfirmModal';
import CompareOverflowModal from '@/app/compare/components/CompareOverflowModal';
import Button from '@/components/ui/Buttons';
-import { MAX_COMPARE_ITEMS } from '@/constants/compareNumber';
import { useCompareStore } from '@/store/compareStore';
import { useModalStore } from '@/store/modalStore';
import { Product } from '@/types/product/productType';
@@ -21,36 +20,32 @@ const AddToCompareButton = ({ product, className }: AddToCompareButtonProps) =>
const handleAddToCompare = () => {
const currentCount = compareList.length;
+ const result = addProduct(product.id);
// 이미 담긴 상품인지 확인
- if (compareList.some((p) => p.id === product.id)) {
+ if (result.isDuplicate) {
toast.info('이미 저장된 항목입니다. "비교하기"에서 확인해주세요.');
return;
}
- if (currentCount === 0) {
- // 첫 번째 담기: 바로 추가 + 안내 토스트
- addProduct(product);
- toast.info('비교대상을 추가해주세요');
- } else if (currentCount >= MAX_COMPARE_ITEMS) {
- // 최대 개수 도달 시: 추가하면서 제거된 항목 안내 모달
- const result = addProduct(product);
- if (result.removedProduct) {
+ if (result.shouldShowModal) {
+ // 최대 개수 도달: 오버플로우 모달 표시
+ openModal({
+ component: CompareOverflowModal,
+ props: {
+ newProduct: product,
+ },
+ });
+ } else {
+ // 성공적으로 추가됨
+ if (currentCount === 0) {
+ toast.info('첫 영화가 담겼습니다. 비교할 다른 영화를 추가해주세요');
+ } else {
+ // 1개 이상: 확인 모달
openModal({
- component: CompareOverflowModal,
- props: {
- removedProduct: result.removedProduct,
- newProduct: product,
- },
+ component: CompareConfirmModal,
});
}
- } else {
- // 1개 이상이면서 최대 미만: 추가 + 확인 모달
- addProduct(product);
- openModal({
- component: CompareConfirmModal,
- props: {},
- });
}
};
diff --git a/src/components/common/gnb/buttons/CompareButton.tsx b/src/components/common/gnb/buttons/CompareButton.tsx
index a17cd300..c01d43c4 100644
--- a/src/components/common/gnb/buttons/CompareButton.tsx
+++ b/src/components/common/gnb/buttons/CompareButton.tsx
@@ -1,18 +1,12 @@
'use client';
-import { useEffect, useState } from 'react';
-
import Link from 'next/link';
import { useCompareStore } from '@/store/compareStore';
const CompareButton = () => {
const { compareList } = useCompareStore();
- const [count, setCount] = useState(0);
-
- useEffect(() => {
- setCount(compareList.length);
- }, [compareList]);
+ const count = compareList.length;
return (
void;
clearCompareList: () => void;
+ compareProducts: ProductDetail[];
};
export function useCompareController({
compareList,
removeProduct,
clearCompareList,
+ compareProducts,
}: ControllerDeps) {
+ const { shouldAutoSelect, setShouldAutoSelect } = useCompareStore();
const [mode, setMode] = useState ('browse');
// 선택 상태(id값) 관리
- const [selectedIds, setSelectedIds] = useState>(new Set());
- const [selectedDeleteIds, setSelectedDeleteIds] = useState>(new Set());
+ const [selectedIds, setSelectedIds] = useState([]);
+ const [selectedDeleteIds, setSelectedDeleteIds] = useState([]);
+ // 인풋에서 선택된 상품 (슬롯 0/1)
+ const [inputProducts, setInputProducts] = useState<(ProductDetail | null)[]>([null, null]);
- // '비교하러가기' 버튼으로 진입 시 최초 1회 목록 내 마지막 2개 자동 선택
- const didAutoFill = useRef(false);
+ // id -> product 매핑
+ const idMap = useMemo(() => new Map(compareProducts.map((p) => [p.id, p])), [compareProducts]);
+
+ // '비교하러가기' 버튼으로 진입 시 마지막 2개 자동 선택
useEffect(() => {
- if (!didAutoFill.current && compareList.length >= 2) {
- const lastTwo = compareList.slice(-2).map((p) => p.id);
- setSelectedDeleteIds(new Set(lastTwo));
- didAutoFill.current = true;
+ if (shouldAutoSelect && compareList.length >= 2) {
+ const lastTwo = compareList.slice(-2);
+ setSelectedIds(lastTwo);
+ setShouldAutoSelect(false); // 사용 후 초기화
}
- }, [compareList]);
+ }, [shouldAutoSelect, compareList, setShouldAutoSelect]);
// 비교 목록이 변할 때 존재하지 않는 id 정리
useEffect(() => {
- if (selectedIds.size === 0 && selectedDeleteIds.size === 0) return;
+ if (selectedIds.length === 0 && selectedDeleteIds.length === 0) return;
+
+ const filterIds = (prev: number[]) => {
+ if (prev.length === 0) return prev;
+ const filtered = prev.filter((id) => compareList.includes(id));
+ return filtered.length !== prev.length ? filtered : prev;
+ };
+
+ setSelectedIds(filterIds);
+ setSelectedDeleteIds(filterIds);
+ }, [compareList, selectedIds.length, selectedDeleteIds.length]);
+
+ // 슬롯 계산: 인풋 고정 → 선택된 id로 빈 슬롯 채우기
+ const selectedProducts = useMemo<(ProductDetail | null)[]>(() => {
+ const slots: (ProductDetail | null)[] = [inputProducts[0], inputProducts[1]];
+ for (const id of selectedIds) {
+ const found = idMap.get(id) ?? null;
+ if (!found) continue;
+ if (slots.some((s) => s && s.id === found.id)) continue;
+ const empty = slots.findIndex((s) => s === null);
+ if (empty !== -1) slots[empty] = found;
+ }
+ return slots;
+ }, [inputProducts, selectedIds, idMap]);
- const existing = new Set(compareList.map((p) => p.id));
- setSelectedIds((prev) => {
- if (prev.size === 0) return prev;
- const next = new Set();
- prev.forEach((id) => existing.has(id) && next.add(id));
- return next;
- });
- setSelectedDeleteIds((prev) => {
- if (prev.size === 0) return prev;
- const next = new Set();
- prev.forEach((id) => existing.has(id) && next.add(id));
- return next;
- });
- }, [compareList, selectedIds.size, selectedDeleteIds.size]);
+ // 결과 쌍: 슬롯에 2개 제품이 모두 있으면 비교 가능
+ const comparePair = useMemo<[ProductDetail, ProductDetail] | null>(() => {
+ const slot0 = selectedProducts[0];
+ const slot1 = selectedProducts[1];
+ if (slot0 && slot1) return [slot0, slot1];
+ return null;
+ }, [selectedProducts]);
+
+ const canCompare = !!comparePair;
// 모드에 따른 카드 클릭 동작
const handleCardSelect = useCallback(
- (product: Product) => {
+ (productId: number) => {
if (mode === 'delete') {
setSelectedDeleteIds((prev) => {
- const next = new Set(prev);
- if (next.has(product.id)) next.delete(product.id);
- else next.add(product.id);
- return next;
+ if (prev.includes(productId)) {
+ return prev.filter((id) => id !== productId);
+ } else {
+ return [...prev, productId];
+ }
});
return;
} // browse/compare: 비교 선택(최대 2개 유지)
setSelectedIds((prev) => {
- const next = new Set(prev);
- if (next.has(product.id)) {
- next.delete(product.id);
- return next;
+ if (prev.includes(productId)) {
+ return prev.filter((id) => id !== productId);
}
- if (next.size >= 2) {
+ if (prev.length >= 2) {
// 가장 먼저 들어온 id 제거
- const [first] = next;
- next.delete(first);
+ return [prev[1], productId];
}
- next.add(product.id);
- return next;
+ return [...prev, productId];
});
},
[mode],
);
- // 입력 영역에서 선택 제거
- const handleProductRemoveFromSelected = useCallback((productId: number) => {
+ // CompareInput 전용: 특정 인덱스에 상품 설정
+ const handleInputProductSelect = useCallback((productId: number, index: number) => {
setSelectedIds((prev) => {
- if (!prev.has(productId)) return prev;
- const next = new Set(prev);
- next.delete(productId);
- return next;
+ const newIds = [...prev];
+ newIds[index] = productId;
+ return newIds;
});
}, []);
+ // CompareInput 통합 선택: compareList에 있으면 selectedIds, 아니면 inputProducts 업데이트
+ const handleInputSelect = useCallback(
+ (product: ProductDetail, index: number) => {
+ if (idMap.has(product.id)) {
+ handleInputProductSelect(product.id, index);
+ return;
+ }
+ setInputProducts((prev) => {
+ const next = [...prev];
+ next[index] = product;
+ return next;
+ });
+ },
+ [idMap, handleInputProductSelect],
+ );
+
+ // 슬롯 제거: 실제 슬롯에 표시된 출처에 따라 제거 처리
+ const handleSlotRemove = useCallback(
+ (index: number) => {
+ const slot = selectedProducts[index];
+ if (!slot) return;
+ if (selectedIds.includes(slot.id)) {
+ // 리스트 선택에서 온 경우 선택 해제
+ setSelectedIds((prev) => prev.filter((id) => id !== slot.id));
+ }
+ // 인풋에서 온 경우에만 인풋 클리어
+ setInputProducts((prev) => {
+ const next = [...prev];
+ if (prev[index]?.id === slot.id) next[index] = null;
+ return next;
+ });
+ },
+ [selectedProducts, selectedIds],
+ );
+
// 비교모드 시작/종료
const handleCompare = useCallback(() => {
- if (selectedIds.size === 2) setMode('compare');
- }, [selectedIds.size]);
+ if (comparePair) setMode('compare');
+ }, [comparePair]);
+
+ // 선택 상태만 초기화
+ const resetSelection = useCallback(() => {
+ setSelectedIds([]);
+ setSelectedDeleteIds([]);
+ setInputProducts([null, null]);
+ }, []);
const backToBrowse = useCallback(() => {
setMode('browse');
- }, []);
+ resetSelection();
+ }, [resetSelection]);
// 삭제 모드
const enterDeleteMode = useCallback(() => {
setMode('delete');
- setSelectedDeleteIds(new Set());
+ setSelectedDeleteIds([]);
}, []);
const exitDeleteMode = useCallback(() => {
setMode('browse');
- setSelectedDeleteIds(new Set());
+ setSelectedDeleteIds([]);
}, []);
const confirmDeleteSelected = useCallback(() => {
- if (selectedDeleteIds.size === 0) return;
+ if (selectedDeleteIds.length === 0) return;
selectedDeleteIds.forEach((id) => removeProduct(id));
- setSelectedDeleteIds(new Set());
+ setSelectedDeleteIds([]);
setMode('browse');
// 삭제된 항목이 비교 선택에 남지 않게 정리
- setSelectedIds((prev) => {
- if (prev.size === 0) return prev;
- const next = new Set();
- prev.forEach((id) => {
- if (!selectedDeleteIds.has(id)) next.add(id);
- });
- return next;
- });
+ setSelectedIds((prev) => prev.filter((id) => !selectedDeleteIds.includes(id)));
}, [removeProduct, selectedDeleteIds]);
// 전체 삭제
const clearAll = useCallback(() => {
clearCompareList();
- setSelectedIds(new Set());
- setSelectedDeleteIds(new Set());
+ setSelectedIds([]);
+ setSelectedDeleteIds([]);
+ setInputProducts([null, null]);
setMode('browse');
}, [clearCompareList]);
@@ -140,16 +199,22 @@ export function useCompareController({
mode,
selectedIds,
selectedDeleteIds,
+ inputProducts,
+ selectedProducts,
+ comparePair,
+ canCompare,
// actions
handleCardSelect,
- handleProductRemoveFromSelected,
+ handleInputProductSelect,
+ handleInputSelect,
+ handleSlotRemove,
handleCompare,
backToBrowse,
-
enterDeleteMode,
exitDeleteMode,
confirmDeleteSelected,
clearAll,
+ resetSelection,
};
}
diff --git a/src/hooks/useCompareProducts.ts b/src/hooks/useCompareProducts.ts
index 7bdb863a..8fbaba07 100644
--- a/src/hooks/useCompareProducts.ts
+++ b/src/hooks/useCompareProducts.ts
@@ -1,6 +1,5 @@
import { useEffect, useState } from 'react';
-import { getBatchProductDetails } from '@/actions/compareProducts';
import { ProductDetail } from '@/types/product/productType';
interface UseCompareProductsResult {
@@ -15,8 +14,12 @@ export function useCompareProducts(productIds: number[]): UseCompareProductsResu
const [error, setError] = useState(null);
useEffect(() => {
+ const controller = new AbortController();
+
const fetchProducts = async () => {
- if (productIds.length === 0) {
+ const uniqueIds = Array.from(new Set(productIds.filter((n) => Number.isFinite(n))));
+
+ if (uniqueIds.length === 0) {
setProducts([]);
setLoading(false);
return;
@@ -26,11 +29,14 @@ export function useCompareProducts(productIds: number[]): UseCompareProductsResu
setError(null);
try {
- const validProducts = await getBatchProductDetails(productIds);
- setProducts(validProducts);
+ const qs = new URLSearchParams({ ids: uniqueIds.join(',') }).toString();
+ const res = await fetch(`/api/products/batch?${qs}`, { signal: controller.signal });
+ if (!res.ok) throw new Error('Failed to fetch');
+ const data: { list: ProductDetail[] } = await res.json();
+ setProducts(data.list ?? []);
} catch (err) {
- console.error('Failed to fetch products:', err);
- setError('상품 정보를 불러오는 데 실패했습니다.');
+ if (err instanceof Error && err.name === 'AbortError') return;
+ setError('영화 정보를 불러오는 데 실패했습니다.');
setProducts([]);
} finally {
setLoading(false);
@@ -38,6 +44,7 @@ export function useCompareProducts(productIds: number[]): UseCompareProductsResu
};
fetchProducts();
+ return () => controller.abort();
}, [productIds]);
return { products, loading, error };
diff --git a/src/hooks/useInfiniteScroll.ts b/src/hooks/useInfiniteScroll.ts
index ec2f85e5..24287fd9 100644
--- a/src/hooks/useInfiniteScroll.ts
+++ b/src/hooks/useInfiniteScroll.ts
@@ -28,8 +28,8 @@ export function useInfiniteScroll({
const result = await fetcher(cursor);
setItems((prev) => [...prev, ...result.list]);
setCursor(result.nextCursor);
- } catch (error) {
- console.error('Failed to load more items:', error);
+ } catch {
+ return;
}
});
}, [cursor, isPending, fetcher]);
@@ -40,8 +40,8 @@ export function useInfiniteScroll({
const result = await fetcher(null);
setItems(result.list);
setCursor(result.nextCursor);
- } catch (error) {
- console.error('Failed to reset items:', error);
+ } catch {
+ return;
}
});
}, [fetcher]);
diff --git a/src/store/compareStore.ts b/src/store/compareStore.ts
index 814caac6..a29d7bf9 100644
--- a/src/store/compareStore.ts
+++ b/src/store/compareStore.ts
@@ -2,46 +2,49 @@ import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { MAX_COMPARE_ITEMS } from '@/constants/compareNumber';
-import { Product } from '@/types/product/productType';
interface CompareState {
- compareList: Product[];
- addProduct: (product: Product) => { removedProduct?: Product };
+ compareList: number[];
+ shouldAutoSelect: boolean;
+ addProduct: (productId: number) => { shouldShowModal: boolean; isDuplicate: boolean };
+ addProductWithRemoval: (productId: number, removeProductId: number) => void;
removeProduct: (productId: number) => void;
clearCompareList: () => void;
- isProductInList: (productId: number) => boolean;
- undoRemove: (removedProduct: Product, newProduct: Product) => void;
+ setShouldAutoSelect: (value: boolean) => void;
}
export const useCompareStore = create()(
persist(
(set, get) => ({
compareList: [],
+ shouldAutoSelect: false,
- addProduct: (product: Product) => {
+ addProduct: (productId: number) => {
const { compareList } = get();
// 이미 있는 상품인지 확인
- if (compareList.some((p) => p.id === product.id)) {
- return {};
+ if (compareList.includes(productId)) {
+ return { shouldShowModal: false, isDuplicate: true };
}
- let newList = [...compareList, product];
- let removedProduct: Product | undefined;
-
- // 최대 8개 제한, FIFO(가장 앞의 상품부터 제거) 방식 -> 모달 내에서 선택하게 수정
- if (newList.length > MAX_COMPARE_ITEMS) {
- removedProduct = newList[0];
- newList = newList.slice(1);
+ // 최대 개수에 도달한 경우 모달을 표시해야 함
+ if (compareList.length >= MAX_COMPARE_ITEMS) {
+ return { shouldShowModal: true, isDuplicate: false };
}
- set({ compareList: newList });
- return { removedProduct };
+ // 공간이 있으면 바로 추가
+ set({ compareList: [...compareList, productId] });
+ return { shouldShowModal: false, isDuplicate: false };
+ },
+
+ addProductWithRemoval: (productId: number, removeProductId: number) => {
+ const { compareList } = get();
+ set({ compareList: [...compareList.filter((id) => id !== removeProductId), productId] });
},
removeProduct: (productId: number) => {
set((state) => ({
- compareList: state.compareList.filter((p) => p.id !== productId),
+ compareList: state.compareList.filter((id) => id !== productId),
}));
},
@@ -49,15 +52,8 @@ export const useCompareStore = create()(
set({ compareList: [] });
},
- isProductInList: (productId: number) => {
- return get().compareList.some((p) => p.id === productId);
- },
-
- undoRemove: (removedProduct: Product, newProduct: Product) => {
- const { compareList } = get();
- // 새로 추가된 상품 제거하고 삭제된 상품을 맨 앞에 복원
- const listWithoutNew = compareList.filter((p) => p.id !== newProduct.id);
- set({ compareList: [removedProduct, ...listWithoutNew] });
+ setShouldAutoSelect: (value: boolean) => {
+ set({ shouldAutoSelect: value });
},
}),
{
diff --git a/src/types/compare/compareType.ts b/src/types/compare/compareType.ts
index 9eafbf88..ba9df5f2 100644
--- a/src/types/compare/compareType.ts
+++ b/src/types/compare/compareType.ts
@@ -1,15 +1,15 @@
-import { Product } from '../product/productType';
+import { ProductDetail } from '../product/productType';
export interface ComparisonResult {
- winner: Product | null;
- loser: Product | null;
+ winner: ProductDetail | null;
+ loser: ProductDetail | null;
winCount: number;
isDraw: boolean;
product1Wins: number;
product2Wins: number;
details: {
- rating: { winner: Product | null; value1: number; value2: number };
- favoriteCount: { winner: Product | null; value1: number; value2: number };
- reviewCount: { winner: Product | null; value1: number; value2: number };
+ rating: { winner: ProductDetail | null; value1: number; value2: number };
+ favoriteCount: { winner: ProductDetail | null; value1: number; value2: number };
+ reviewCount: { winner: ProductDetail | null; value1: number; value2: number };
};
}
diff --git a/src/utils/compareProducts.ts b/src/utils/compareProducts.ts
index 27b16214..1c339b12 100644
--- a/src/utils/compareProducts.ts
+++ b/src/utils/compareProducts.ts
@@ -1,13 +1,21 @@
import { ComparisonResult } from '@/types/compare/compareType';
-import { Product } from '@/types/product/productType';
+import { ProductDetail } from '@/types/product/productType';
// 승자를 뽑고, 승자가 없으면 null
-function pickWinner(a: number, b: number, p1: Product, p2: Product): Product | null {
+function pickWinner(
+ a: number,
+ b: number,
+ p1: ProductDetail,
+ p2: ProductDetail,
+): ProductDetail | null {
if (a === b) return null;
return a > b ? p1 : p2;
}
-export function compareProducts(product1: Product, product2: Product): ComparisonResult {
+export function compareProducts(
+ product1: ProductDetail,
+ product2: ProductDetail,
+): ComparisonResult {
// 항목별 승자 판별
const ratingWinner = pickWinner(product1.rating, product2.rating, product1, product2);
const favoriteWinner = pickWinner(
@@ -18,20 +26,21 @@ export function compareProducts(product1: Product, product2: Product): Compariso
);
const reviewWinner = pickWinner(product1.reviewCount, product2.reviewCount, product1, product2);
- // 총 승수 집계 (비교 후 1승씩 추가)
+ const winners = [ratingWinner, favoriteWinner, reviewWinner];
+
+ // 총 승수 집계
let product1Wins = 0;
let product2Wins = 0;
- for (const w of [ratingWinner, favoriteWinner, reviewWinner]) {
- if (!w) continue;
- if (w.id === product1.id) product1Wins++;
- else product2Wins++;
- }
+ winners.forEach((winner) => {
+ if (winner?.id === product1.id) product1Wins++;
+ else if (winner?.id === product2.id) product2Wins++;
+ });
// 승자 판정
const isDraw = product1Wins === product2Wins;
const winner = isDraw ? null : product1Wins > product2Wins ? product1 : product2;
- const loser = isDraw ? null : winner?.id === product1.id ? product2 : product1;
+ const loser = winner ? (winner.id === product1.id ? product2 : product1) : null;
const winCount = Math.max(product1Wins, product2Wins);
// 결과값 반환
| |