Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
269c1a9
refactor: light 속성 제거
Sophia-parang Sep 11, 2025
653d898
Merge remote-tracking branch 'origin' into feat/LIN-79-landing
Sophia-parang Sep 12, 2025
80d6a09
refactor: 우선순위 속성추가
Sophia-parang Sep 12, 2025
054a444
feat: 캐싱 태그 추가
Sophia-parang Sep 12, 2025
f87fa09
feat: 태그 추가
Sophia-parang Sep 13, 2025
262f557
feat: 영화목록 새로고침 위한 이벤트 핸들러 추가
Sophia-parang Sep 13, 2025
655cb88
feat: 카테고리 클릭 시 최상위로 이동
Sophia-parang Sep 13, 2025
1f1288a
feat: 레이아웃 시프트 수정 및 로딩 스피너 추가
Sophia-parang Sep 13, 2025
e71eed6
refactor: 카드 순서 변경
Sophia-parang Sep 13, 2025
50474a7
feat: 삭제 확인 모달 추가
Sophia-parang Sep 13, 2025
ffcfc7a
feat: 모달 추가
Sophia-parang Sep 13, 2025
513970c
feat: 카테고리 이동 변경
Sophia-parang Sep 13, 2025
b809380
feat: 삭제하기 모달 추가
Sophia-parang Sep 13, 2025
5a17d22
feat: 비교하기 버튼 클릭 시 최신 데이터 페치
Sophia-parang Sep 13, 2025
ba8f8cd
feat: 로딩 스피너 처리
Sophia-parang Sep 13, 2025
ef97604
feat: 결과 헤더 및 솔트 스티키 처리
Sophia-parang Sep 13, 2025
b09d490
fix: 모바일 검색 시 바로 랜딩으로 가는 문제 해결
Sophia-parang Sep 13, 2025
6fc2a78
feat: 히어로 랜딩 이미지 및 영상
Sophia-parang Sep 14, 2025
939134a
feat: 캐러셀 설치
Sophia-parang Sep 14, 2025
01af259
feat: 히어로 섹션 구현
Sophia-parang Sep 14, 2025
db4cb85
feat: 캐러셀 관련 설치 코드
Sophia-parang Sep 14, 2025
da946fe
refactor: 로고 이미지 최적화
Sophia-parang Sep 14, 2025
f267cdc
refactor: 리뷰 여백 수정
Sophia-parang Sep 14, 2025
695bf5e
refactor: 모바일 내 스티키 설정 제거
Sophia-parang Sep 14, 2025
7f4480e
refactor: 디바운스 오류 수정
Sophia-parang Sep 14, 2025
16377e8
feat: 히어로 이미지 최적화
Sophia-parang Sep 14, 2025
93a5d23
feat: 이미지 최적화 파일로 교체
Sophia-parang Sep 14, 2025
d4fa777
feat: 히어로 관련 추가 카드 및 수정
Sophia-parang Sep 15, 2025
5399967
chore: 캐러셀 픽스
Sophia-parang Sep 15, 2025
f146de3
feat: 순서 수정
Sophia-parang Sep 15, 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
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added public/images/Hero1.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/Hero2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/Hero3.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/Hero4.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added public/images/PikchaLogoAnimation.mp4
Binary file not shown.
2 changes: 1 addition & 1 deletion src/actions/compareProducts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export const getBatchProductDetails = async (productIds: number[]): Promise<Prod
return await fetcher(`${BASE_URL}/${TEAM_ID}/products/${id}`, {
method: 'GET',
cache: 'force-cache',
next: { revalidate: 30 },
next: { revalidate: 30, tags: [`product-${id}`, 'compare-products'] },
});
} catch {
return null;
Expand Down
26 changes: 25 additions & 1 deletion src/actions/productDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export const postProduct = async ({ categoryId, image, description, name }: Prod
body: JSON.stringify(newProduct),
});

revalidateTag(`products`);
// 전체 상품 목록과 해당 카테고리 상품 목록 업데이트
revalidateTag('products');
revalidateTag('products-list');
revalidateTag(`category-${categoryId}`);

return res;
};
Expand Down Expand Up @@ -75,7 +78,11 @@ export const patchProduct = async ({
body: JSON.stringify(newProduct),
});

// 제품 상세, 전체 목록, 해당 카테고리 목록 업데이트
revalidateTag(`products-${productId}`);
revalidateTag('products');
revalidateTag('products-list');
revalidateTag(`category-${data.categoryId}`);

return res;
};
Expand All @@ -84,6 +91,15 @@ export const deleteProduct = async (productId: number) => {
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: {
Expand All @@ -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;
};
4 changes: 3 additions & 1 deletion src/actions/productFavorite.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
22 changes: 17 additions & 5 deletions src/actions/productList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(path: string, init?: RequestInit): Promise<T> {
async function product<T>(path: string, init?: RequestInit, customTags?: string[]): Promise<T> {
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 || {}) },
});
}
Expand All @@ -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<ProductListRes>(url);

// 카테고리별로 다른 태그 사용
const tags = ['products', 'products-list'];
if (params.category != null) {
tags.push(`category-${params.category}`);
}

const result = await product<ProductListRes>(url, undefined, tags);

return result;
}

// 검색창 검색 시 검색창 하단에 추천 리스트를 보여주는 서버 액션
Expand All @@ -38,7 +47,10 @@ type SuggestionProductListRes = { list: SuggestionProduct[] };
const fetchByKeyword = async (keyword: string): Promise<SuggestionProduct[]> => {
const qs = new URLSearchParams({ keyword }).toString();
try {
const res = await product<SuggestionProductListRes>(`/products?${qs}`);
const res = await product<SuggestionProductListRes>(`/products?${qs}`, undefined, [
'products',
'products-list',
]);
return res.list ?? [];
} catch {
return [];
Expand Down
6 changes: 3 additions & 3 deletions src/actions/productRank.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(path: string): Promise<T> {
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' },
});
}
Expand Down
20 changes: 18 additions & 2 deletions src/actions/review/review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
};
Expand All @@ -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;
};

Expand All @@ -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: {
Expand Down
4 changes: 2 additions & 2 deletions src/actions/review/reviewer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserRanking[]> => {
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);
};
113 changes: 113 additions & 0 deletions src/app/_components/Hero.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Carousel
opts={{
loop: true,
}}
plugins={[autoplay.current]}
className='hidden pt-15 md:block'
onMouseEnter={() => autoplay.current.reset()}
onTouchStart={() => autoplay.current.reset()}
onMouseLeave={() => autoplay.current.play()}
>
<CarouselContent>
<CarouselItem>
<Link
href='https://github.com/TEAM3-Mogazoa/Mogazoa'
className='block h-full'
aria-label='개발진 깃허브 이동'
target='_blank'
rel='noopener noreferrer'
>
<HeroLogoCard />
</Link>
</CarouselItem>
<CarouselItem>
<Link href='/products/1825' className='block h-full' aria-label='굿 윌 헌팅 상세로 이동'>
<Image
src='/images/Hero1.webp'
alt='굿 윌 헌팅'
width={1180}
height={460}
priority
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 100vw, 1180px'
fetchPriority='high'
/>
</Link>
</CarouselItem>
<CarouselItem>
<Link
href='/products/1824'
className='block h-full'
aria-label='귀멸의 칼날 무한열차편 상세로 이동'
>
<Image
src='/images/Hero2.webp'
alt='귀멸의 칼날'
width={1180}
height={460}
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 100vw, 1180px'
/>
</Link>
</CarouselItem>
<CarouselItem>
<Link
href='/products/1823'
className='block h-full'
aria-label='뜨거운 것이 좋아 상세로 이동'
>
<Image
src='/images/Hero4.webp'
alt='뜨거운 것이 좋아'
width={1180}
height={460}
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 100vw, 1180px'
/>
</Link>
</CarouselItem>
<CarouselItem>
<Link href='/products/1826' className='block h-full' aria-label='기적 상세로 이동'>
<Image
src='/images/Hero3.webp'
alt='기적'
width={1180}
height={460}
sizes='(max-width: 768px) 100vw, (max-width: 1200px) 100vw, 1180px'
/>
</Link>
</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>
);
};

export default Hero;
9 changes: 9 additions & 0 deletions src/app/_components/HeroLogoCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const HeroLogoCard = () => {
return (
<video className='h-full w-full object-cover' autoPlay muted loop playsInline>
<source src='/images/PikchaLogoAnimation.mp4' type='video/mp4'></source>
</video>
);
};

export default HeroLogoCard;
4 changes: 1 addition & 3 deletions src/app/_components/NoResult.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,7 @@ const NoResult = () => {
return (
<div className='flex h-screen flex-col items-center justify-start gap-3 pt-[20vh]'>
<Clapperboard className='text-black-2e2e3a h-[100px] w-[100px]' />
<h2 className='text-mogazoa-18px-400 text-white-f1f1f5 light:text-gray-6e6e82'>
검색 결과가 없습니다
</h2>
<h2 className='text-mogazoa-18px-400 text-white-f1f1f5'>검색 결과가 없습니다</h2>
<ReturnToListButton />
</div>
);
Expand Down
3 changes: 3 additions & 0 deletions src/app/_components/ProductPost/ProductForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
8 changes: 4 additions & 4 deletions src/app/_components/ResultTitle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,23 @@ interface ResultTitleProps {
const ResultTitle = ({ q, category }: ResultTitleProps) => {
if (q && category !== null) {
return (
<h2 className='text-mogazoa-24px-600 md:pt-[60px]'>
<h2 className='text-mogazoa-24px-600 md:pt-[35px]'>
<span className='text-main-blue'>{getCategoryName(category)}</span> 내{' '}
<span className='text-gradient'>&apos;{q}&apos;</span> 를(을) 검색한 결과입니다
</h2>
);
}
if (q) {
return (
<h2 className='text-mogazoa-24px-600 md:pt-[60px]'>
<h2 className='text-mogazoa-24px-600 md:pt-[35px]'>
<span className='text-gradient'>&apos;{q}&apos;</span> 를(을) 검색한 결과입니다
</h2>
);
}
if (category !== null) {
return (
<h2 className='text-mogazoa-24px-600 md:pt-[60px]'>
<span className='text-main-blue'>{getCategoryName(category)}</span> 내 상품목록입니다
<h2 className='text-mogazoa-24px-600 md:pt-[35px]'>
<span className='text-main-blue'>{getCategoryName(category)}</span> 내 영화목록입니다
</h2>
);
}
Expand Down
Loading