diff --git a/package.json b/package.json index 47709086..fb466c16 100644 --- a/package.json +++ b/package.json @@ -24,10 +24,13 @@ "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", "@vercel/speed-insights": "^1.2.0", "cheerio": "^1.1.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "embla-carousel-autoplay": "^8.6.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.540.0", "next": "15.5.0", "next-auth": "^5.0.0-beta.29", diff --git a/public/images/Hero1.webp b/public/images/Hero1.webp new file mode 100644 index 00000000..d482b64e Binary files /dev/null and b/public/images/Hero1.webp differ diff --git a/public/images/Hero2.webp b/public/images/Hero2.webp new file mode 100644 index 00000000..04cd8c13 Binary files /dev/null and b/public/images/Hero2.webp differ diff --git a/public/images/Hero3.webp b/public/images/Hero3.webp new file mode 100644 index 00000000..1a10766b Binary files /dev/null and b/public/images/Hero3.webp differ diff --git a/public/images/Hero4.webp b/public/images/Hero4.webp new file mode 100644 index 00000000..b568f776 Binary files /dev/null and b/public/images/Hero4.webp differ diff --git a/public/images/PikchaLogoAnimation.mp4 b/public/images/PikchaLogoAnimation.mp4 new file mode 100644 index 00000000..831aa32e Binary files /dev/null and b/public/images/PikchaLogoAnimation.mp4 differ diff --git a/src/actions/compareProducts.ts b/src/actions/compareProducts.ts index abfc7604..3592b05d 100644 --- a/src/actions/compareProducts.ts +++ b/src/actions/compareProducts.ts @@ -12,7 +12,7 @@ export const getBatchProductDetails = async (productIds: number[]): Promise { const session = await auth(); const accessToken = session?.accessToken; + // 삭제하기 전에 제품 정보를 가져와서 카테고리 ID를 확인 + let categoryId: number | null = null; + try { + const productDetail = await getProductDetail(productId); + categoryId = productDetail.categoryId; + } catch { + // 제품 정보를 가져올 수 없어도 삭제는 진행 + } + const res = await fetcher(`${BASE_URL}/${TEAM_ID}/products/${productId}`, { method: 'DELETE', headers: { @@ -92,5 +108,13 @@ export const deleteProduct = async (productId: number) => { }, }); + // 전체 목록, 랭킹, 해당 카테고리 목록 업데이트 + revalidateTag('products'); + revalidateTag('products-ranking'); + revalidateTag('products-list'); + if (categoryId) { + revalidateTag(`category-${categoryId}`); + } + return res; }; diff --git a/src/actions/productFavorite.ts b/src/actions/productFavorite.ts index 40e34000..331ad33d 100644 --- a/src/actions/productFavorite.ts +++ b/src/actions/productFavorite.ts @@ -24,7 +24,9 @@ export const postProductFavorite = async (productId: number, isCurrentlyFavorite }); revalidateTag(`products-${productId}`); - // 일단 구현 후 리팩토링 다시 생각해보자 + revalidateTag(`product-${productId}`); + revalidateTag('compare-products'); + revalidateTag('products-ranking'); // 즐겨찾기 변경시 랭킹에도 영향 return res; }; diff --git a/src/actions/productList.ts b/src/actions/productList.ts index 3134e4de..0bd0e2ae 100644 --- a/src/actions/productList.ts +++ b/src/actions/productList.ts @@ -6,12 +6,12 @@ import { ProductListRes, ProductSearch } from '@/types/products/productList'; // 상품 검색을 위한 api 호출 // .env 내 teamId 환경 변수로 포함되어 있음. const API_BASE_URL = process.env.API_BASE_URL ?? ''; -const TEAM_ID = process.env.TEAM_ID ?? ''; +const TEAM_ID = process.env.TEST_TEAM_ID ?? ''; -async function product(path: string, init?: RequestInit): Promise { +async function product(path: string, init?: RequestInit, customTags?: string[]): Promise { return await fetcher(`${API_BASE_URL}/${TEAM_ID}${path}`, { ...init, - next: { revalidate: 300 }, + next: { revalidate: 300, tags: customTags || ['products', 'products-list'] }, headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) }, }); } @@ -26,7 +26,16 @@ export async function searchProducts(params: ProductSearch) { if (params.order != null) sp.set('order', String(params.order)); const url = `/products?${sp.toString()}`; - return product(url); + + // 카테고리별로 다른 태그 사용 + const tags = ['products', 'products-list']; + if (params.category != null) { + tags.push(`category-${params.category}`); + } + + const result = await product(url, undefined, tags); + + return result; } // 검색창 검색 시 검색창 하단에 추천 리스트를 보여주는 서버 액션 @@ -38,7 +47,10 @@ type SuggestionProductListRes = { list: SuggestionProduct[] }; const fetchByKeyword = async (keyword: string): Promise => { const qs = new URLSearchParams({ keyword }).toString(); try { - const res = await product(`/products?${qs}`); + const res = await product(`/products?${qs}`, undefined, [ + 'products', + 'products-list', + ]); return res.list ?? []; } catch { return []; diff --git a/src/actions/productRank.ts b/src/actions/productRank.ts index b67acbc3..b5182c31 100644 --- a/src/actions/productRank.ts +++ b/src/actions/productRank.ts @@ -6,13 +6,13 @@ import { ProductListRes } from '@/types/products/productList'; // 상품 검색을 위한 api 호출 // .env 내 teamId 환경 변수로 포함되어 있음. const API_BASE_URL = process.env.API_BASE_URL ?? ''; -const TEAM_ID = process.env.TEAM_ID ?? ''; +const TEAM_ID = process.env.TEST_TEAM_ID ?? ''; -// 랭킹은 5분에 한 번씩 캐싱 +// 랭킹은 1시간마다 캐싱, 태그를 통한 즉시 갱신 가능 async function api(path: string): Promise { return await fetcher(`${API_BASE_URL}/${TEAM_ID}/${path}`, { method: 'GET', - next: { revalidate: 300 }, + next: { revalidate: 3600, tags: ['products-ranking'] }, headers: { 'Content-Type': 'application/json' }, }); } diff --git a/src/actions/review/review.ts b/src/actions/review/review.ts index 09a88d8b..19edd5a4 100644 --- a/src/actions/review/review.ts +++ b/src/actions/review/review.ts @@ -63,11 +63,21 @@ export const postReview = async ({ }); revalidatePath(`/products/${productId}`); + revalidateTag(`product-${productId}`); + revalidateTag('compare-products'); + revalidateTag('products-ranking'); + revalidateTag('reviewer-ranking'); return res; }; -export const patchReview = async ({ rating, content, images, reviewId }: ReviewPatchFormValue) => { +export const patchReview = async ({ + rating, + content, + images, + reviewId, + productId, +}: ReviewPatchFormValue & { productId: number }) => { const session = await auth(); const accessToken = session?.accessToken; @@ -88,6 +98,9 @@ export const patchReview = async ({ rating, content, images, reviewId }: ReviewP }); revalidateTag('reviews'); + revalidateTag(`product-${productId}`); + revalidateTag('compare-products'); + revalidateTag('products-ranking'); return res; }; @@ -106,6 +119,10 @@ export const deleteReview = async (reviewId: number, productId: number) => { }); revalidatePath(`/products/${productId}`); + revalidateTag(`product-${productId}`); + revalidateTag('compare-products'); + revalidateTag('products-ranking'); + revalidateTag('reviewer-ranking'); return res; }; @@ -114,7 +131,6 @@ export const toggleReviewLike = async (reviewId: number, isCurrentlyLike: boolea const accessToken = session?.accessToken; const method = isCurrentlyLike ? 'DELETE' : 'POST'; - console.log('method:', method); const res = await fetcher(`${BASE_URL}/${TEAM_ID}/reviews/${reviewId}/like`, { method: method, headers: { diff --git a/src/actions/review/reviewer.ts b/src/actions/review/reviewer.ts index f79210a7..2af172db 100644 --- a/src/actions/review/reviewer.ts +++ b/src/actions/review/reviewer.ts @@ -5,13 +5,13 @@ import fetcher from '@/lib/utils/fetcher'; import { UserRanking } from '@/types/user/userRanking'; const API_BASE_URL = process.env.API_BASE_URL ?? ''; -const TEAM_ID = process.env.TEAM_ID ?? ''; +const TEAM_ID = process.env.TEST_TEAM_ID ?? ''; export const getReviewerRanking = async (): Promise => { const data = await fetcher(`${API_BASE_URL}/${TEAM_ID}/users/ranking`, { method: 'GET', headers: { 'Content-Type': 'application/json' }, - next: { revalidate: 300 }, + next: { revalidate: 300, tags: ['reviewer-ranking'] }, }); return data.slice(0, 5); }; diff --git a/src/app/_components/Hero.tsx b/src/app/_components/Hero.tsx new file mode 100644 index 00000000..f95a048d --- /dev/null +++ b/src/app/_components/Hero.tsx @@ -0,0 +1,113 @@ +'use client'; + +import { useRef } from 'react'; + +import Autoplay from 'embla-carousel-autoplay'; +import Image from 'next/image'; +import Link from 'next/link'; + +import { + Carousel, + CarouselContent, + CarouselItem, + CarouselNext, + CarouselPrevious, +} from '@/components/ui/carousel'; + +import HeroLogoCard from './HeroLogoCard'; + +const Hero = () => { + const autoplay = useRef( + Autoplay({ + delay: 5000, // 5초마다 자동으로 슬라이드 + stopOnInteraction: false, + stopOnMouseEnter: false, + rootNode: (emblaRoot) => emblaRoot.parentElement, + }), + ); + + return ( + autoplay.current.reset()} + onTouchStart={() => autoplay.current.reset()} + onMouseLeave={() => autoplay.current.play()} + > + + + + + + + + + 굿 윌 헌팅 + + + + + 귀멸의 칼날 + + + + + 뜨거운 것이 좋아 + + + + + 기적 + + + + + + + ); +}; + +export default Hero; diff --git a/src/app/_components/HeroLogoCard.tsx b/src/app/_components/HeroLogoCard.tsx new file mode 100644 index 00000000..889a2a23 --- /dev/null +++ b/src/app/_components/HeroLogoCard.tsx @@ -0,0 +1,9 @@ +const HeroLogoCard = () => { + return ( + + ); +}; + +export default HeroLogoCard; diff --git a/src/app/_components/NoResult.tsx b/src/app/_components/NoResult.tsx index cb8e61e8..0f12bbe4 100644 --- a/src/app/_components/NoResult.tsx +++ b/src/app/_components/NoResult.tsx @@ -6,9 +6,7 @@ const NoResult = () => { return (
-

- 검색 결과가 없습니다 -

+

검색 결과가 없습니다

); diff --git a/src/app/_components/ProductPost/ProductForm.tsx b/src/app/_components/ProductPost/ProductForm.tsx index 14d828d5..af05c965 100644 --- a/src/app/_components/ProductPost/ProductForm.tsx +++ b/src/app/_components/ProductPost/ProductForm.tsx @@ -54,6 +54,9 @@ const ProductForm = ({ product, mode }: { product: ProductDetail; mode: 'create' await patchProduct({ productId: product.id, data }); } closeModal(); + + // 상품 목록 새로고침을 위한 이벤트 발생 + window.dispatchEvent(new CustomEvent('productUpdated')); } catch { setIsError(true); } diff --git a/src/app/_components/ResultTitle.tsx b/src/app/_components/ResultTitle.tsx index d4b486ab..1dbe5595 100644 --- a/src/app/_components/ResultTitle.tsx +++ b/src/app/_components/ResultTitle.tsx @@ -8,7 +8,7 @@ interface ResultTitleProps { const ResultTitle = ({ q, category }: ResultTitleProps) => { if (q && category !== null) { return ( -

+

{getCategoryName(category)} 내{' '} '{q}' 를(을) 검색한 결과입니다

@@ -16,15 +16,15 @@ const ResultTitle = ({ q, category }: ResultTitleProps) => { } if (q) { return ( -

+

'{q}' 를(을) 검색한 결과입니다

); } if (category !== null) { return ( -

- {getCategoryName(category)} 내 상품목록입니다 +

+ {getCategoryName(category)} 내 영화목록입니다

); } diff --git a/src/app/_components/ReviewRank.tsx b/src/app/_components/ReviewRank.tsx index 8b8f8bea..e23d52fe 100644 --- a/src/app/_components/ReviewRank.tsx +++ b/src/app/_components/ReviewRank.tsx @@ -15,7 +15,7 @@ const ReviewRank = ({ products }: ReviewRankProps) => { return (
-

+

지금 핫한 영화 TOP 8

diff --git a/src/app/_components/SearchResultList.tsx b/src/app/_components/SearchResultList.tsx index d5ff4458..df63fa89 100644 --- a/src/app/_components/SearchResultList.tsx +++ b/src/app/_components/SearchResultList.tsx @@ -52,13 +52,25 @@ export default function SearchResultList({ reset(); }, [sortBy, reset]); + // 상품 등록/수정/삭제 시 목록 새로고침을 위한 이벤트 리스너 + useEffect(() => { + const handleProductUpdate = () => { + reset(); + }; + + window.addEventListener('productUpdated', handleProductUpdate); + return () => { + window.removeEventListener('productUpdated', handleProductUpdate); + }; + }, [reset]); + const handleSortChange = (value: string) => { setSortBy(value as 'recent' | 'rating' | 'reviewCount'); }; return (
-
+
- {isPending ? ( -
로딩 중...
// 로딩 부분 구현 필요 - ) : ( -
- )} + {isPending ?
로딩 중...
:
}
)}
diff --git a/src/app/_components/Sidebar.tsx b/src/app/_components/Sidebar.tsx index 1e11defd..f9d79b5d 100644 --- a/src/app/_components/Sidebar.tsx +++ b/src/app/_components/Sidebar.tsx @@ -6,8 +6,12 @@ import { CATEGORY_NAME_MAP } from '@/lib/utils/categoryNameMap'; const Sidebar = ({ selected, q }: { selected: number | null; q: string }) => { const { navigateToCategory } = useCategoryNavigation(); + const handleCategoryClick = (category: number | null) => { + navigateToCategory(category, q); + }; + return ( -