diff --git a/src/app/api/products/batch/route.ts b/src/app/api/products/batch/route.ts new file mode 100644 index 00000000..b3e1fe9a --- /dev/null +++ b/src/app/api/products/batch/route.ts @@ -0,0 +1,48 @@ +import { NextResponse } from 'next/server'; + +import fetcher from '@/lib/utils/fetcher'; +import { ProductDetail } from '@/types/product/productType'; + +const BASE_URL = process.env.API_BASE_URL; +const TEAM_ID = process.env.TEST_TEAM_ID; + +export async function GET(req: Request) { + const { searchParams } = new URL(req.url); + const idsParam = searchParams.get('ids') ?? ''; + + if (!idsParam) { + return NextResponse.json({ list: [] }, { status: 200 }); + } + + const ids = Array.from( + new Set( + idsParam + .split(',') + .map((v) => Number(v.trim())) + .filter((n) => Number.isFinite(n)), + ), + ); + + try { + const promises = ids.map(async (id) => { + try { + const product = await fetcher(`${BASE_URL}/${TEAM_ID}/products/${id}`, { + method: 'GET', + next: { revalidate: 300 }, + }); + return product as ProductDetail; + } catch { + return null; + } + }); + + const results = await Promise.all(promises); + const list = results.filter((p): p is ProductDetail => p !== null); + return NextResponse.json({ list }, { status: 200 }); + } catch { + return NextResponse.json( + { list: [], error: '영화를 불러오는데 실패했습니다' }, + { status: 500 }, + ); + } +} diff --git a/src/app/api/reviews/[productId]/route.ts b/src/app/api/reviews/[productId]/route.ts new file mode 100644 index 00000000..8305a83e --- /dev/null +++ b/src/app/api/reviews/[productId]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse } from 'next/server'; + +import fetcher from '@/lib/utils/fetcher'; +import { ReviewResponse } from '@/types/review/review'; + +const BASE_URL = process.env.API_BASE_URL; +const TEAM_ID = process.env.TEST_TEAM_ID; + +export async function GET(_: Request, { params }: { params: Promise<{ productId: string }> }) { + const { productId } = await params; + const idNum = Number(productId); + if (!Number.isFinite(idNum)) { + return NextResponse.json({ topReviews: [] }, { status: 400 }); + } + + try { + const data = await fetcher( + `${BASE_URL}/${TEAM_ID}/products/${idNum}/reviews?order=ratingDesc&limit=2`, + { + method: 'GET', + next: { revalidate: 300, tags: ['reviews'] }, + }, + ); + + const { list } = data as ReviewResponse; + const topReviews = (list ?? []).slice(0, 2); + return NextResponse.json({ topReviews }, { status: 200 }); + } catch { + return NextResponse.json( + { topReviews: [], error: '리뷰를 불러오는데 실패했습니다' }, + { status: 500 }, + ); + } +} diff --git a/src/app/compare/components/CompareCard.tsx b/src/app/compare/components/CompareCard.tsx index e3eb3d56..212de626 100644 --- a/src/app/compare/components/CompareCard.tsx +++ b/src/app/compare/components/CompareCard.tsx @@ -1,17 +1,18 @@ 'use client'; +import { CircleCheckBig } from 'lucide-react'; import Image from 'next/image'; import Link from 'next/link'; import StarIcon from '@/assets/icon/Icon-star.svg'; import Button from '@/components/ui/Buttons'; import { verifyImgUrl } from '@/lib/utils/verifyImgUrl'; -import { type Product } from '@/types/product/productType'; +import { type ProductDetail } from '@/types/product/productType'; interface CompareCardProps { - product: Product; + product: ProductDetail; isSelected: boolean; - onSelect: (product: Product) => void; + onSelect: (productId: number) => void; } const CompareCard = ({ product, isSelected, onSelect }: CompareCardProps) => { @@ -22,10 +23,11 @@ const CompareCard = ({ product, isSelected, onSelect }: CompareCardProps) => {
onSelect(product)} - tabIndex={-1} + onClick={() => onSelect(product.id)} >
{ className='rounded-sm' sizes='(max-width: 768px) 140px, (max-width: 1280px) 227px, 260px' /> + {isSelected && ( +
+ +
+ )}
diff --git a/src/app/compare/components/CompareConfirmModal.tsx b/src/app/compare/components/CompareConfirmModal.tsx index 57d446a9..51c1d30a 100644 --- a/src/app/compare/components/CompareConfirmModal.tsx +++ b/src/app/compare/components/CompareConfirmModal.tsx @@ -5,34 +5,29 @@ import { useRouter } from 'next/navigation'; import Modal from '@/components/common/ModalUi'; import Button from '@/components/ui/Buttons'; import { DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { useCompareStore } from '@/store/compareStore'; import { useModalStore } from '@/store/modalStore'; const CompareConfirmModal = () => { const { closeModal } = useModalStore(); const router = useRouter(); + const { setShouldAutoSelect } = useCompareStore(); const handleConfirm = () => { - document.body.style.overflow = ''; - document.body.style.paddingRight = ''; // 모달 수정 뒤 수정예정 + setShouldAutoSelect(true); closeModal(); router.push('/compare'); }; - const handleCancel = () => { - document.body.style.overflow = ''; - document.body.style.paddingRight = ''; // 모달 수정 뒤 수정예정 - closeModal(); - }; - return ( - 비교하기 + 비교하기
-

비교하러 가시겠습니까?

+

비교하러 가시겠습니까?

- -
diff --git a/src/app/compare/components/CompareResult.tsx b/src/app/compare/components/CompareResult.tsx index 8dd36a29..6b5dc023 100644 --- a/src/app/compare/components/CompareResult.tsx +++ b/src/app/compare/components/CompareResult.tsx @@ -1,5 +1,12 @@ +import { useEffect, useState } from 'react'; + +import { toast } from 'sonner'; + +import { getHtmlJustWatch } from '@/actions/external/getHtmlJustWatch'; +import { StreamingIcon } from '@/app/testTrailer/StreamingIcon'; import Button from '@/components/ui/Buttons'; -import { type Product } from '@/types/product/productType'; +import { type ProviderInfo } from '@/types/justwatch/providers'; +import { type ProductDetail } from '@/types/product/productType'; import { compareProducts } from '@/utils/compareProducts'; import CompareMovieCards from './CompareMovieCards'; @@ -7,16 +14,41 @@ import CompareResultHeader from './CompareResultHeader'; import CompareTable from './CompareTable'; interface CompareResultProps { - products: [Product, Product]; + products: [ProductDetail, ProductDetail]; onBackToSelection: () => void; } const CompareResult = ({ products, onBackToSelection }: CompareResultProps) => { - const [product1, product2] = products; - const comparisonResult = compareProducts(product1, product2); + const comparisonResult = compareProducts(products[0], products[1]); + const [hasStreamingProviders, setHasStreamingProviders] = useState(false); + const [providers, setProviders] = useState([]); + + useEffect(() => { + const checkStreamingProviders = async () => { + if (!comparisonResult.winner) return; + + try { + const providerList = await getHtmlJustWatch(comparisonResult.winner.name); + setProviders(providerList); + setHasStreamingProviders(providerList.length > 0); + } catch { + setHasStreamingProviders(false); + } + }; + + checkStreamingProviders(); + }, [comparisonResult.winner]); + + const handleShowStreaming = async () => { + toast(, { + duration: 30 * 1000, + position: 'bottom-right', + style: { minWidth: 0, width: 'auto' }, + }); + }; return ( -
+
{ products={products} /> - + -
- +
+ {comparisonResult.winner && hasStreamingProviders && ( + + )} + +
+ +
); diff --git a/src/app/compare/components/CompareResultHeader.tsx b/src/app/compare/components/CompareResultHeader.tsx index 58db3667..08578f2c 100644 --- a/src/app/compare/components/CompareResultHeader.tsx +++ b/src/app/compare/components/CompareResultHeader.tsx @@ -1,30 +1,41 @@ -import { type Product } from '@/types/product/productType'; -import { getProductColorClass } from '@/utils/compareColors'; +import { type ProductDetail } from '@/types/product/productType'; interface CompareResultHeaderProps { - winner: Product | null; + winner: ProductDetail | null; winCount: number; isDraw: boolean; - products: [Product, Product]; + products: [ProductDetail, ProductDetail]; } +const TOTAL_COMPARISON_ITEMS = 3; + const CompareResultHeader = ({ winner, winCount, isDraw, products }: CompareResultHeaderProps) => { + if (isDraw) { + return ( +
+

+ 무승부입니다. +

+

+ {TOTAL_COMPARISON_ITEMS}가지 항목에서 동등한 결과를 보입니다. +

+
+ ); + } + + if (!winner) return null; + + // 승자 색상 결정 (첫 번째 제품이면 초록, 두 번째면 분홍) + const winnerColorClass = winner.id === products[0].id ? 'text-[#05D58B]' : 'text-[#FF2F9F]'; + return ( -
+

- {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} - - 결과 -
-
+ <> + {/* 데스크톱용 테이블 */} +
+ + + + + + + + + + + {COMPARISON_METRICS.map((metric) => ( + + ))} + + +
기준 + {products[0].name} + + {products[1].name} + 결과
+
+ + {/* 모바일용 카드 레이아웃 */} +
+ {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}) - @@ -49,7 +48,7 @@ const CompareToolbar = ({ variant='tertiary' onClick={onEnterDelete} disabled={compareListLength === 0} - className='text-mogazoa-12px-300 text-white-f1f1f5 !h-[35px] w-[100px] px-5 py-1 whitespace-nowrap' + className={BUTTON_CLASS} > 선택 삭제 @@ -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}

+ +
+
+ ); + } // 비교목록이 비어있을 때 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); // 결과값 반환