From be98e1e6d91f4b813c9bba374b77dd59464aae41 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Mon, 8 Sep 2025 17:12:49 +0900 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=201=EA=B0=9C?= =?UTF-8?q?=EC=9D=BC=EB=95=8C=20=ED=86=A0=EC=8A=A4=ED=8A=B8=20=EB=AC=B8?= =?UTF-8?q?=EA=B5=AC=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/products/[productId]/components/AddToCompareButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/products/[productId]/components/AddToCompareButton.tsx b/src/app/products/[productId]/components/AddToCompareButton.tsx index ad26c7b2..7f2db682 100644 --- a/src/app/products/[productId]/components/AddToCompareButton.tsx +++ b/src/app/products/[productId]/components/AddToCompareButton.tsx @@ -31,7 +31,7 @@ const AddToCompareButton = ({ product, className }: AddToCompareButtonProps) => if (currentCount === 0) { // 첫 번째 담기: 바로 추가 + 안내 토스트 addProduct(product); - toast.info('비교대상을 추가해주세요'); + toast.info('첫 영화가 담겼습니다. 비교할 다른 영화를 추가해주세요'); } else if (currentCount >= MAX_COMPARE_ITEMS) { // 최대 개수 도달 시: 추가하면서 제거된 항목 안내 모달 const result = addProduct(product); From 0a4d3d9aef2c23ce44d8627f1815e5a56f1f7e06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Mon, 8 Sep 2025 17:46:33 +0900 Subject: [PATCH 02/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=20=EC=8B=9C=20=EB=B0=B0=EC=97=B4=20=EC=88=9C=EC=84=9C?= =?UTF-8?q?=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/page.tsx | 12 ++- src/app/not-found.tsx | 4 +- .../common/gnb/mobileSearch/MobileSearch.tsx | 6 +- .../common/gnb/section/DesktopCenter.tsx | 8 +- src/hooks/useCompareController.ts | 79 ++++++++----------- 5 files changed, 55 insertions(+), 54 deletions(-) diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index ef7ad5e4..06b59edb 100644 --- a/src/app/compare/page.tsx +++ b/src/app/compare/page.tsx @@ -27,9 +27,11 @@ const ComparePage = () => { clearAll, } = useCompareController({ compareList, removeProduct, clearCompareList }); - // 선택된 상품들을 배열로 변환 - const selectedProducts = compareList.filter((p) => selectedIds.has(p.id)); - const selectedDeleteProducts = compareList.filter((p) => selectedDeleteIds.has(p.id)); + // 선택된 상품들을 배열로 변환 (선택 순서 유지) + const selectedProducts = selectedIds + .map((id) => compareList.find((p) => p.id === id)) + .filter(Boolean) as Product[]; + const selectedDeleteProducts = compareList.filter((p) => selectedDeleteIds.includes(p.id)); // 비교목록이 비어있을 때 if (compareList.length === 0) { @@ -77,7 +79,9 @@ const ComparePage = () => { - mode === 'delete' ? selectedDeleteIds.has(product.id) : selectedIds.has(product.id) + mode === 'delete' + ? selectedDeleteIds.includes(product.id) + : selectedIds.includes(product.id) } onSelect={handleCardSelect} /> diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 610c7bdc..57e6c9c9 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -5,7 +5,7 @@ import { useRouter } from 'next/navigation'; import Button from '@/components/ui/Buttons'; import { cn } from '@/lib/utils'; -const ErrorFallback = ({}) => { +const Page = ({}) => { const router = useRouter(); return ( @@ -21,4 +21,4 @@ const ErrorFallback = ({}) => { ); }; -export default ErrorFallback; +export default Page; diff --git a/src/components/common/gnb/mobileSearch/MobileSearch.tsx b/src/components/common/gnb/mobileSearch/MobileSearch.tsx index 1edaf0a7..5c53d467 100644 --- a/src/components/common/gnb/mobileSearch/MobileSearch.tsx +++ b/src/components/common/gnb/mobileSearch/MobileSearch.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { Suspense, useState } from 'react'; import { Search } from 'lucide-react'; @@ -34,7 +34,9 @@ const MobileSearch = () => { onClick={(e) => e.stopPropagation()} // 내부 클릭 시 오버레이 닫힘 방지 > {/* 검색 진입 시 검색창, 오버레이 제거 */} - setSearchOpen(false)} /> + + setSearchOpen(false)} /> + )} diff --git a/src/components/common/gnb/section/DesktopCenter.tsx b/src/components/common/gnb/section/DesktopCenter.tsx index 3feab67d..1e7370da 100644 --- a/src/components/common/gnb/section/DesktopCenter.tsx +++ b/src/components/common/gnb/section/DesktopCenter.tsx @@ -1,7 +1,13 @@ +import { Suspense } from 'react'; + import SearchForm from '../searchForm/SearchForm'; const DesktopCenter = () => { - return ; + return ( + + + + ); }; export default DesktopCenter; diff --git a/src/hooks/useCompareController.ts b/src/hooks/useCompareController.ts index d47d110e..29dc8e77 100644 --- a/src/hooks/useCompareController.ts +++ b/src/hooks/useCompareController.ts @@ -18,63 +18,58 @@ export function useCompareController({ 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([]); // '비교하러가기' 버튼으로 진입 시 최초 1회 목록 내 마지막 2개 자동 선택 const didAutoFill = useRef(false); useEffect(() => { if (!didAutoFill.current && compareList.length >= 2) { const lastTwo = compareList.slice(-2).map((p) => p.id); - setSelectedDeleteIds(new Set(lastTwo)); + setSelectedDeleteIds(lastTwo); didAutoFill.current = true; } }, [compareList]); // 비교 목록이 변할 때 존재하지 않는 id 정리 useEffect(() => { - if (selectedIds.size === 0 && selectedDeleteIds.size === 0) return; + if (selectedIds.length === 0 && selectedDeleteIds.length === 0) return; - const existing = new Set(compareList.map((p) => p.id)); + const existingIds = 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; + if (prev.length === 0) return prev; + const filtered = prev.filter((id) => existingIds.includes(id)); + return filtered.length !== prev.length ? filtered : prev; }); setSelectedDeleteIds((prev) => { - if (prev.size === 0) return prev; - const next = new Set(); - prev.forEach((id) => existing.has(id) && next.add(id)); - return next; + if (prev.length === 0) return prev; + const filtered = prev.filter((id) => existingIds.includes(id)); + return filtered.length !== prev.length ? filtered : prev; }); - }, [compareList, selectedIds.size, selectedDeleteIds.size]); + }, [compareList, selectedIds.length, selectedDeleteIds.length]); // 모드에 따른 카드 클릭 동작 const handleCardSelect = useCallback( (product: Product) => { 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(product.id)) { + return prev.filter((id) => id !== product.id); + } else { + return [...prev, product.id]; + } }); 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(product.id)) { + return prev.filter((id) => id !== product.id); } - if (next.size >= 2) { + if (prev.length >= 2) { // 가장 먼저 들어온 id 제거 - const [first] = next; - next.delete(first); + return [prev[1], product.id]; } - next.add(product.id); - return next; + return [...prev, product.id]; }); }, [mode], @@ -83,17 +78,15 @@ export function useCompareController({ // 입력 영역에서 선택 제거 const handleProductRemoveFromSelected = useCallback((productId: number) => { setSelectedIds((prev) => { - if (!prev.has(productId)) return prev; - const next = new Set(prev); - next.delete(productId); - return next; + if (!prev.includes(productId)) return prev; + return prev.filter((id) => id !== productId); }); }, []); // 비교모드 시작/종료 const handleCompare = useCallback(() => { - if (selectedIds.size === 2) setMode('compare'); - }, [selectedIds.size]); + if (selectedIds.length === 2) setMode('compare'); + }, [selectedIds.length]); const backToBrowse = useCallback(() => { setMode('browse'); @@ -102,36 +95,32 @@ export function useCompareController({ // 삭제 모드 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; + if (prev.length === 0) return prev; + return prev.filter((id) => !selectedDeleteIds.includes(id)); }); }, [removeProduct, selectedDeleteIds]); // 전체 삭제 const clearAll = useCallback(() => { clearCompareList(); - setSelectedIds(new Set()); - setSelectedDeleteIds(new Set()); + setSelectedIds([]); + setSelectedDeleteIds([]); setMode('browse'); }, [clearCompareList]); From 3bde06595ce3cd0810ffa649fb2f074c38387bc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Mon, 8 Sep 2025 17:49:06 +0900 Subject: [PATCH 03/21] =?UTF-8?q?feat:=20=EC=B9=B4=EB=93=9C=20=EB=82=B4=20?= =?UTF-8?q?=EC=98=81=ED=99=94=20=EC=A0=9C=EB=AA=A9=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/components/CompareMovieCards.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/compare/components/CompareMovieCards.tsx b/src/app/compare/components/CompareMovieCards.tsx index 027b28c7..27c55e42 100644 --- a/src/app/compare/components/CompareMovieCards.tsx +++ b/src/app/compare/components/CompareMovieCards.tsx @@ -22,7 +22,6 @@ const CompareMovieCards = ({ products }: CompareMovieCardsProps) => { sizes='(max-width: 768px) 140px, (max-width: 1280px) 227px, 260px' /> -

{product1.name}

@@ -35,7 +34,6 @@ const CompareMovieCards = ({ products }: CompareMovieCardsProps) => { sizes='(max-width: 768px) 140px, (max-width: 1280px) 227px, 260px' />
-

{product2.name}

); From 37a25d8e9e973f233bfa17b1b4a3fb22fa51cd03 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:14:06 +0900 Subject: [PATCH 04/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20fetch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/api/products/batch/route.ts | 48 ++++++++++++++++++++++++ src/app/api/reviews/[productId]/route.ts | 34 +++++++++++++++++ 2 files changed, 82 insertions(+) create mode 100644 src/app/api/products/batch/route.ts create mode 100644 src/app/api/reviews/[productId]/route.ts 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 }, + ); + } +} From 0587335438c6311104d73f8bde287e39cd608a2a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:14:42 +0900 Subject: [PATCH 05/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EA=B0=9C=EB=B3=84=20=EC=B9=B4=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/components/CompareCard.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 && ( +
+ +
+ )}
From cbb4780f91fe391f48df882177454273f9b5a836 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:15:18 +0900 Subject: [PATCH 06/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EB=9F=AC=EA=B0=80=EA=B8=B0=20=EB=AA=A8=EB=8B=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compare/components/CompareConfirmModal.tsx | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) 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 ( - 비교하기 + 비교하기
-

비교하러 가시겠습니까?

+

비교하러 가시겠습니까?

- -
From 0a04f30a75a74452e436f96f0e358a58586a663b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:17:16 +0900 Subject: [PATCH 11/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=EA=B0=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/components/CompareResult.tsx | 60 +++++++++++++++---- .../components/CompareResultHeader.tsx | 43 ++++++++----- 2 files changed, 77 insertions(+), 26 deletions(-) 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}가지 항목에서 우세합니다.

); From 738043218986264d4f1f555fafc44ca54eb8b06e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:17:37 +0900 Subject: [PATCH 12/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EA=B2=B0=EA=B3=BC=EA=B0=92=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../compare/components/CompareReviewCell.tsx | 24 +- .../compare/components/CompareReviewRow.tsx | 42 +-- src/app/compare/components/CompareTable.tsx | 264 ++++++++++++++---- .../compare/components/CompareTableRow.tsx | 63 +++-- 4 files changed, 293 insertions(+), 100 deletions(-) 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} +
승리 🎉
) : ( 무승부 From 6dfff03d6c96e8ce79c19a20da463726b430fac6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:17:48 +0900 Subject: [PATCH 13/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=B6=94=EA=B0=80=ED=95=98=EA=B8=B0=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/AddToCompareButton.tsx | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/src/app/products/[productId]/components/AddToCompareButton.tsx b/src/app/products/[productId]/components/AddToCompareButton.tsx index 7f2db682..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: {}, - }); } }; From 4449329e586c1d945db8cf8b67f0cca472a3838d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:18:01 +0900 Subject: [PATCH 14/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=88=AB=EC=9E=90=20=ED=91=9C=EC=8B=9C=20=EB=B2=84?= =?UTF-8?q?=ED=8A=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/gnb/buttons/CompareButton.tsx | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) 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 ( Date: Thu, 11 Sep 2025 16:18:13 +0900 Subject: [PATCH 15/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=82=AD=EC=A0=9C=ED=88=B4=EB=B0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/components/CompareToolbar.tsx | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) 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; - -// 버튼 공통 컴포로 만들 수 있을 것 같음. From 8ab5a64fcfe00684d108176cb53ef8df0ccbd4c7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:18:32 +0900 Subject: [PATCH 16/21] =?UTF-8?q?refactor:=20=ED=83=80=EC=9E=85=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useInfiniteScroll.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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]); From ddf80f06a32ba5d339c4afba07bc9f6ac8df6ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:19:05 +0900 Subject: [PATCH 17/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=9B=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/hooks/useCompareController.ts | 156 ++++++++++++++++++++++-------- src/hooks/useCompareProducts.ts | 19 ++-- 2 files changed, 129 insertions(+), 46 deletions(-) diff --git a/src/hooks/useCompareController.ts b/src/hooks/useCompareController.ts index 29dc8e77..78f8a04e 100644 --- a/src/hooks/useCompareController.ts +++ b/src/hooks/useCompareController.ts @@ -1,96 +1,168 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; -import { Product } from '@/types/product/productType'; +import { useCompareStore } from '@/store/compareStore'; +import { type ProductDetail } from '@/types/product/productType'; type Mode = 'browse' | 'delete' | 'compare'; type ControllerDeps = { - compareList: Product[]; + compareList: number[]; removeProduct: (id: number) => 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([]); 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(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.length === 0 && selectedDeleteIds.length === 0) return; - const existingIds = compareList.map((p) => p.id); - setSelectedIds((prev) => { - if (prev.length === 0) return prev; - const filtered = prev.filter((id) => existingIds.includes(id)); - return filtered.length !== prev.length ? filtered : prev; - }); - setSelectedDeleteIds((prev) => { + const filterIds = (prev: number[]) => { if (prev.length === 0) return prev; - const filtered = prev.filter((id) => existingIds.includes(id)); + 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]); + + // 결과 쌍: 슬롯에 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) => { - if (prev.includes(product.id)) { - return prev.filter((id) => id !== product.id); + if (prev.includes(productId)) { + return prev.filter((id) => id !== productId); } else { - return [...prev, product.id]; + return [...prev, productId]; } }); return; } // browse/compare: 비교 선택(최대 2개 유지) setSelectedIds((prev) => { - if (prev.includes(product.id)) { - return prev.filter((id) => id !== product.id); + if (prev.includes(productId)) { + return prev.filter((id) => id !== productId); } if (prev.length >= 2) { // 가장 먼저 들어온 id 제거 - return [prev[1], product.id]; + return [prev[1], productId]; } - return [...prev, product.id]; + return [...prev, productId]; }); }, [mode], ); - // 입력 영역에서 선택 제거 - const handleProductRemoveFromSelected = useCallback((productId: number) => { + // CompareInput 전용: 특정 인덱스에 상품 설정 + const handleInputProductSelect = useCallback((productId: number, index: number) => { setSelectedIds((prev) => { - if (!prev.includes(productId)) return prev; - return prev.filter((id) => id !== productId); + 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.length === 2) setMode('compare'); - }, [selectedIds.length]); + if (comparePair) setMode('compare'); + }, [comparePair]); + + // 선택 상태만 초기화 + const resetSelection = useCallback(() => { + setSelectedIds([]); + setSelectedDeleteIds([]); + setInputProducts([null, null]); + }, []); const backToBrowse = useCallback(() => { setMode('browse'); - }, []); + resetSelection(); + }, [resetSelection]); // 삭제 모드 const enterDeleteMode = useCallback(() => { @@ -110,10 +182,7 @@ export function useCompareController({ setMode('browse'); // 삭제된 항목이 비교 선택에 남지 않게 정리 - setSelectedIds((prev) => { - if (prev.length === 0) return prev; - return prev.filter((id) => !selectedDeleteIds.includes(id)); - }); + setSelectedIds((prev) => prev.filter((id) => !selectedDeleteIds.includes(id))); }, [removeProduct, selectedDeleteIds]); // 전체 삭제 @@ -121,6 +190,7 @@ export function useCompareController({ clearCompareList(); setSelectedIds([]); setSelectedDeleteIds([]); + setInputProducts([null, null]); setMode('browse'); }, [clearCompareList]); @@ -129,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 }; From 6a6aed2f3acd5ad6e246e7073b20747152295b68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:19:15 +0900 Subject: [PATCH 18/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=8A=A4=ED=86=A0=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/store/compareStore.ts | 50 ++++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 27 deletions(-) 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 }); }, }), { From 32b97fc89c0d8846452b51892564ae39d3bc507b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:19:30 +0900 Subject: [PATCH 19/21] =?UTF-8?q?feat:=20=EB=B9=84=EA=B5=90=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=ED=83=80=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/types/compare/compareType.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 }; }; } From d8792b67d6f8820b40f8c817ad21745f5de3cce9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:22:36 +0900 Subject: [PATCH 20/21] =?UTF-8?q?refactor:=20=EB=B3=80=EC=88=98=EB=AA=85?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/utils/compareProducts.ts | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) 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); // 결과값 반환 From ef7867793d8d7181cc61e3b2bb452ed3d4d0251a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sophia=20Na=20=28=EB=82=98=EC=86=8C=EC=97=B0=29?= Date: Thu, 11 Sep 2025 16:22:55 +0900 Subject: [PATCH 21/21] =?UTF-8?q?refactor:=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/compare/page.tsx | 101 +++++++++++++++++++++++++++------------ 1 file changed, 70 insertions(+), 31 deletions(-) diff --git a/src/app/compare/page.tsx b/src/app/compare/page.tsx index 06b59edb..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,51 +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 = selectedIds - .map((id) => compareList.find((p) => p.id === id)) - .filter(Boolean) as Product[]; - const selectedDeleteProducts = compareList.filter((p) => selectedDeleteIds.includes(p.id)); + // 로딩 상태 + if (loading) { + return ( +
+
+

영화 정보를 불러오는 중...

+
+
+ ); + } + + // 에러 상태 + if (error) { + return ( +
+
+

{error}

+ +
+
+ ); + } // 비교목록이 비어있을 때 if (compareList.length === 0) { return ( -
+
); } + // 비교 모드일 때 + if (mode === 'compare' && comparePair) { + return ( +
+ +
+ ); + } + return ( -
+
{mode !== 'compare' && ( @@ -69,23 +116,15 @@ const ComparePage = () => { /> )} - {/* 영화 비교 시 */} - {mode === 'compare' && selectedProducts.length === 2 ? ( - - ) : ( - - mode === 'delete' - ? selectedDeleteIds.includes(product.id) - : selectedIds.includes(product.id) - } - onSelect={handleCardSelect} - /> - )} + + mode === 'delete' + ? selectedDeleteIds.includes(product.id) + : selectedIds.includes(product.id) + } + onSelect={handleCardSelect} + />
); };