Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
be98e1e
feat: 비교 1개일때 토스트 문구 변경
Sophia-parang Sep 8, 2025
0a4d3d9
feat: 비교 검색 시 배열 순서 유지
Sophia-parang Sep 8, 2025
3bde065
feat: 카드 내 영화 제목 삭제
Sophia-parang Sep 8, 2025
8d25ecd
Merge branch 'dev' into feat/LIN-68-compareqa
Sophia-parang Sep 10, 2025
37a25d8
feat: 비교하기 fetch
Sophia-parang Sep 11, 2025
0587335
feat: 비교하기 개별 카드
Sophia-parang Sep 11, 2025
cbb4780
feat: 비교하러가기 모달
Sophia-parang Sep 11, 2025
4c95aff
feat: 비교하기 그리드
Sophia-parang Sep 11, 2025
ea868a9
feat: 비교하기 인풋 필드
Sophia-parang Sep 11, 2025
bcfb40b
feat: 비교하기 결과 카드
Sophia-parang Sep 11, 2025
1ff5a12
feat: 비교하기 최대값 이상 시 모달
Sophia-parang Sep 11, 2025
0a04f30
feat: 비교하기 결과값
Sophia-parang Sep 11, 2025
7380432
feat: 비교하기 결과값 테이블
Sophia-parang Sep 11, 2025
6dfff03
feat: 비교하기 추가하기 버튼
Sophia-parang Sep 11, 2025
4449329
feat: 비교하기 숫자 표시 버튼
Sophia-parang Sep 11, 2025
eeacabb
feat: 비교하기 삭제툴바
Sophia-parang Sep 11, 2025
8ab5a64
refactor: 타입 변경
Sophia-parang Sep 11, 2025
ddf80f0
feat: 비교하기 훅
Sophia-parang Sep 11, 2025
6a6aed2
feat: 비교하기 스토어
Sophia-parang Sep 11, 2025
32b97fc
feat: 비교하기 타입
Sophia-parang Sep 11, 2025
d8792b6
refactor: 변수명 변경 및 추가
Sophia-parang Sep 11, 2025
ef78677
refactor: 페이지 수정
Sophia-parang Sep 11, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions src/app/api/products/batch/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
34 changes: 34 additions & 0 deletions src/app/api/reviews/[productId]/route.ts
Original file line number Diff line number Diff line change
@@ -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 },
);
}
}
19 changes: 13 additions & 6 deletions src/app/compare/components/CompareCard.tsx
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -22,10 +23,11 @@ const CompareCard = ({ product, isSelected, onSelect }: CompareCardProps) => {
<div className='relative'>
<div
className={`border-black-353542 block cursor-pointer rounded-[8px] transition-all ${
isSelected ? 'ring-main-indigo rounded-[8px] ring-4' : ''
isSelected
? 'border-main-indigo border-3'
: 'border-3 border-transparent hover:border-gray-500'
}`}
onClick={() => onSelect(product)}
tabIndex={-1}
onClick={() => onSelect(product.id)}
>
<div className='relative mb-1 aspect-[5/7]'>
<Image
Expand All @@ -35,6 +37,11 @@ const CompareCard = ({ product, isSelected, onSelect }: CompareCardProps) => {
className='rounded-sm'
sizes='(max-width: 768px) 140px, (max-width: 1280px) 227px, 260px'
/>
{isSelected && (
<div className='absolute inset-0 flex items-center justify-center rounded-sm bg-blue-500/20'>
<CircleCheckBig className='text-main-indigo h-14 w-14 rounded-full bg-white p-1 md:h-8 md:w-8 xl:h-14 xl:w-14' />
</div>
)}
</div>

<div className='flex flex-col'>
Expand Down
17 changes: 6 additions & 11 deletions src/app/compare/components/CompareConfirmModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Modal variant='compare'>
<DialogHeader>
<DialogTitle className='text-mogazoa-24px-400 mb-4'>비교하기</DialogTitle>
<DialogTitle className='text-mogazoa-24px-400'>비교하기</DialogTitle>
</DialogHeader>
<div className='text-center'>
<p className='text-gray-9fa6b2 mb-6'>비교하러 가시겠습니까?</p>
<p className='text-gray-9fa6b2 text-mogazoa-18px-400 mb-10'>비교하러 가시겠습니까?</p>
<div className='flex gap-3'>
<Button variant='tertiary' onClick={handleCancel} className='flex-1'>
<Button variant='tertiary' onClick={closeModal} className='flex-1'>
취소
</Button>
<Button variant='primary' onClick={handleConfirm} className='flex-1'>
Expand Down
16 changes: 8 additions & 8 deletions src/app/compare/components/CompareGrid.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { type Product } from '@/types/product/productType';
import { type ProductDetail } from '@/types/product/productType';

import CompareCard from './CompareCard';

type Props = {
list: Product[];
isSelected: (product: Product) => boolean;
onSelect: (product: Product) => void;
};
interface CompareGridProps {
list: ProductDetail[];
isSelected: (product: ProductDetail) => boolean;
onSelect: (productId: number) => void;
}

const CompareGrid = ({ list, isSelected, onSelect }: Props) => {
const CompareGrid = ({ list, isSelected, onSelect }: CompareGridProps) => {
return (
<div className='grid grid-cols-2 gap-6 md:grid-cols-4'>
<div className='grid grid-cols-2 gap-6 py-10 md:grid-cols-4 xl:gap-8 xl:px-16'>
{list.map((product) => (
<CompareCard
key={product.id}
Expand Down
40 changes: 20 additions & 20 deletions src/app/compare/components/CompareInput.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,45 @@
'use client';

import Button from '@/components/ui/Buttons';
import { type Product } from '@/types/product/productType';
import { type ProductDetail } from '@/types/product/productType';

import CompareInputField from './CompareInputField';

interface CompareInputProps {
selectedProducts: Product[];
onProductSelect: (product: Product) => void;
onProductRemove: (productId: number) => void;
selectedProducts: (ProductDetail | null)[];
onProductSelect: (product: ProductDetail, index: number) => void;
onProductRemove: (index: number) => void;
onCompare: () => void;
isCompareEnabled?: boolean;
}

const CompareInput = ({
selectedProducts,
onProductSelect,
onProductRemove,
onCompare,
isCompareEnabled,
}: CompareInputProps) => {
const validProductsCount = selectedProducts.filter((p) => p !== null).length;
const compareEnabled = isCompareEnabled ?? validProductsCount === 2;
return (
<div className='mb-3'>
<div className='items-start justify-center gap-10 md:flex'>
<CompareInputField
index={0}
selectedProduct={selectedProducts[0]}
onProductSelect={onProductSelect}
onProductRemove={onProductRemove}
/>
<div className='flex flex-col items-center justify-center gap-6 md:flex-row md:items-start md:gap-10'>
{selectedProducts.map((product, index) => (
<CompareInputField
key={index}
index={index}
selectedProduct={product}
onProductSelect={onProductSelect}
onProductRemove={onProductRemove}
/>
))}

<CompareInputField
index={1}
selectedProduct={selectedProducts[1]}
onProductSelect={onProductSelect}
onProductRemove={onProductRemove}
/>

<div className='mt-6 flex justify-center md:mt-0 md:pt-[35px]'>
<div className='mt-2 flex justify-center md:mt-0 md:pt-[35px]'>
<Button
variant='primary'
onClick={onCompare}
disabled={selectedProducts.length !== 2}
disabled={!compareEnabled}
className='text-mogazoa-18px-600 h-[50px] w-[200px] px-6 py-3 md:h-[50px] md:w-[165px] xl:h-[65px] xl:w-[200px]'
>
비교하기
Expand Down
28 changes: 17 additions & 11 deletions src/app/compare/components/CompareInputField.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,22 @@ import { useState } from 'react';

import { toast } from 'sonner';

import { getProductDetail } from '@/actions/productDetail';
import SearchSuggestions from '@/components/common/gnb/searchForm/SearchSuggestions';
import CompareChip from '@/components/ui/chips/CompareChip';
import { useSuggestions } from '@/hooks/useSuggestions';
import { type Product } from '@/types/product/productType';
import { type ProductDetail } from '@/types/product/productType';

type SuggestionProduct = { id: number; name: string; categoryId: number };

interface CompareInputFieldProps {
index: number;
selectedProduct?: Product;
onProductSelect: (product: Product) => void;
onProductRemove: (productId: number) => void;
selectedProduct: ProductDetail | null;
onProductSelect: (product: ProductDetail, index: number) => void;
onProductRemove: (index: number) => void;
}

const NO_RESULT_DISPLAY_TIME = 3000;

const CompareInputField = ({
index,
selectedProduct,
Expand All @@ -34,12 +35,17 @@ const CompareInputField = ({

const handleSuggestionSelect = async (suggestion: SuggestionProduct) => {
try {
const productDetail = await getProductDetail(suggestion.id);
onProductSelect(productDetail as Product);
const res = await fetch(`/api/products/batch?ids=${suggestion.id}`);
if (!res.ok) throw new Error('Failed to fetch');
const data: { list: ProductDetail[] } = await res.json();
const productDetail = data.list?.[0];
if (!productDetail) throw new Error('Not found');

onProductSelect(productDetail, index);
setQuery('');
setOpen(false);
} catch {
toast.error('상품 정보를 불러올 수 없습니다.');
toast.error('영화 정보를 불러올 수 없습니다.');
}
};

Expand All @@ -56,14 +62,14 @@ const CompareInputField = ({
<CompareChip
variant={index === 0 ? 'first' : 'second'}
productName={selectedProduct.name}
onClick={() => onProductRemove(selectedProduct.id)}
onClick={() => onProductRemove(index)}
/>
) : (
<input
value={query}
onChange={(e) => {
setQuery(e.target.value);
setOpen(true);
if (!open) setOpen(true);
setShowNoResult(false);
}}
onFocus={() => {
Expand All @@ -74,7 +80,7 @@ const CompareInputField = ({
onKeyDown={(e) => {
if (e.key === 'Enter' && query.trim() && suggestions.length === 0) {
setShowNoResult(true);
setTimeout(() => setShowNoResult(false), 3000);
setTimeout(() => setShowNoResult(false), NO_RESULT_DISPLAY_TIME);
}
}}
placeholder='목록에서 영화를 선택하거나 검색하세요'
Expand Down
Loading
Loading