diff --git a/public/images/default_activity_image.jpg b/public/images/default_activity_image.jpg new file mode 100644 index 0000000..9684f57 Binary files /dev/null and b/public/images/default_activity_image.jpg differ diff --git a/src/app/api/activitiesList.ts b/src/app/api/activitiesList.ts new file mode 100644 index 0000000..15f7fbb --- /dev/null +++ b/src/app/api/activitiesList.ts @@ -0,0 +1,53 @@ +import { instance } from '@/app/api/instance'; + +export interface ActivitiesProps { + id: number; + userId: number; + title: string; + description: string; + category: string; + price: number; + address: string; + bannerImageUrl: string; + rating: number; + reviewCount: number; + createdAt: string; + updatedAt: string; +} + +export interface ActivitiesResponse { + cursorId: number | null; + totalCount: number; + activities: ActivitiesProps[]; +} + +export const fetchActivities = async ({ + method, + cursorId, + page, + size, + keyword, + sort, + category, +}: { + method: 'offset' | 'cursor'; + cursorId?: number; + page?: number; + size?: number; + keyword?: string; + sort?: 'most_reviewed' | 'price_asc' | 'price_desc' | 'latest'; + category?: string; +}): Promise => { + const response = await instance.get('/activities', { + params: { + method, + cursorId, + page, + size, + keyword, + sort, + category, + }, + }); + return response.data; +}; \ No newline at end of file diff --git a/src/app/api/popularActivities.ts b/src/app/api/popularActivities.ts new file mode 100644 index 0000000..8c1db0b --- /dev/null +++ b/src/app/api/popularActivities.ts @@ -0,0 +1,11 @@ +import { QueryFunctionContext } from '@tanstack/react-query'; +import { fetchActivities, ActivitiesResponse } from '@/app/api/activitiesList'; + +export async function fetchPopularActivities({}: QueryFunctionContext): Promise { + const response = await fetchActivities({ + method: 'cursor', + size: 4, + sort: 'most_reviewed', + }); + return response; +} \ No newline at end of file diff --git a/src/app/main/main.css.ts b/src/app/main.css.ts similarity index 58% rename from src/app/main/main.css.ts rename to src/app/main.css.ts index 674dca8..b1b45a9 100644 --- a/src/app/main/main.css.ts +++ b/src/app/main.css.ts @@ -1,11 +1,9 @@ import { style } from '@vanilla-extract/css'; import { theme } from '@/app/global.css'; -//import picHero from '../../../public/images/hero.png' -//import { mediaQueries } from '@/styles/media'; +import { mediaQueries } from '@/styles/media'; export const container = style({ display: 'flex', - width: '100%', flexDirection: 'column', padding: '0', margin: '0', @@ -15,34 +13,99 @@ export const container = style({ }); export const sectionWall = style({ - background: 'url("/images/dance.png") center center/cover no-repeat', width: '100%', height: '550px', display: 'flex', alignItems: 'center', justifyContent: 'center', color: theme.colors.white, + + '@media': { + [mediaQueries.tablet]: {}, + [mediaQueries.mobile]: { + height: '240px', + }, + }, }); -export const walltextContainer = style({ - width: '1200px', +export const bannerImage = style({ + position: 'absolute', + top: 0, + left: 0, + objectFit: 'cover', + zIndex: 0, +}); + +export const bannerTextWrapper = style({ + position: 'relative', + width: '100%', + height: '550px', + overflow: 'hidden', + display: 'flex', + flexDirection: 'column', + justifyContent: 'center', + padding: '0 30px', + margin: '0 auto', + color: theme.colors.white, + + '@media': { + [mediaQueries.tablet]: {}, + [mediaQueries.mobile]: { + height: '240px', + }, + }, }); export const hText = style({ textAlign: 'left', fontSize: '68px', - margin: 0, + fontWeight: '700', + lineHeight: '81px', + + margin: '0 auto', + zIndex: 1, + width: '1200px', + + '@media': { + [mediaQueries.tablet]: { + width: '696px', + fontSize: '54px', + lineHeight: '64px', + }, + [mediaQueries.mobile]: { + width: '335px', + fontSize: '24px', + lineHeight: '28px', + }, + }, }); export const pText = style({ + zIndex: 1, textAlign: 'left', - marginTop: '10px', + width: '1200px', + margin: '0 auto', + paddingTop: '20px', fontSize: theme.text['2xl-bold'].fontSize, fontWeight: theme.text['2xl-bold'].fontWeight, + lineHeight: '28px', + + '@media': { + [mediaQueries.tablet]: { + width: '696px', + fontSize: theme.text['xl-bold'].fontSize, + lineHeight: '26px', + }, + [mediaQueries.mobile]: { + width: '335px', + fontSize: theme.text['md-bold'].fontSize, + }, + }, }); export const searchBar = style({ width: '1200px', + height: '184px', display: 'flex', flexDirection: 'column', justifyContent: 'center', @@ -53,12 +116,32 @@ export const searchBar = style({ position: 'relative', top: '-60px', gap: '32px', + + '@media': { + [mediaQueries.tablet]: { + width: '696px', + }, + [mediaQueries.mobile]: { + width: '343px', + height: '129px', + padding: '16px 24px', + gap: '15px', + marginTop: '20px', + }, + }, }); export const searchBarText = style({ fontSize: theme.text['xl-bold'].fontSize, lineHeight: theme.text['xl-bold'].lineHeight, fontWeight: theme.text['xl-bold'].fontWeight, + + '@media': { + [mediaQueries.mobile]: { + fontSize: theme.text['lg-bold'].fontSize, + lineHeight: theme.text['lg-bold'].lineHeight, + }, + }, }); export const searchBarForm = style({ @@ -82,7 +165,7 @@ export const searchBarForm = style({ export const searchBarInput = style({ padding: '4px 16px 4px 50px', - width: '100%', + width: '1004px', height: '56px', border: `1px solid ${theme.colors.gray1}`, borderRadius: '5px', @@ -90,11 +173,29 @@ export const searchBarInput = style({ fontSize: theme.text['lg-regular'].fontSize, lineHeight: theme.text['lg-regular'].lineHeight, fontWeight: theme.text['lg-regular'].fontWeight, + + '@media': { + [mediaQueries.tablet]: { + width: '500px', + }, + [mediaQueries.mobile]: { + width: '187px', + }, + }, }); export const content = style({ width: '1200px', - marginTop: '34px', + margin: '0 auto', + + '@media': { + [mediaQueries.tablet]: { + width: '696px', + }, + [mediaQueries.mobile]: { + width: '343px', + }, + }, }); export const section = style({ @@ -133,25 +234,45 @@ export const PaginationArrow = style({ export const cardHotContainer = style({ display: 'flex', + flexDirection: 'row', + flexWrap: 'nowrap', + overflow: 'hidden', + overflowX: 'auto', gap: '24px', + + // 스크롤바 숨기기 (웹킷 / 파이어폭스 / IE 표준 호환) + WebkitOverflowScrolling: 'touch', + scrollbarWidth: 'none', // Firefox + msOverflowStyle: 'none', // IE, Edge + + selectors: { + '&::-webkit-scrollbar': { + display: 'none', + }, + }, }); export const cardHot = style({ + display: 'flex', width: '384px', height: '384px', + flex: '0 0 auto', border: '0', borderRadius: '20px', overflow: 'hidden', position: 'relative', color: theme.colors.white, -}); -export const cardImage = style({ - width: '100%', - height: '100%', - objectFit: 'cover', + '@media': { + [mediaQueries.mobile]: { + width: '186px', + height: '186px', + }, + }, }); +export const cardImage = style({}); + export const cardText = style({ position: 'absolute', bottom: '0', @@ -165,13 +286,30 @@ export const cardH = style({ margin: '0', fontSize: theme.text['3xl-bold'].fontSize, fontWeight: theme.text['3xl-bold'].fontWeight, + lineHeight: theme.text['3xl-bold'].lineHeight, + + '@media': { + [mediaQueries.mobile]: { + fontSize: theme.text['2lg-bold'].fontSize, + fontWeight: theme.text['2lg-bold'].fontWeight, + lineHeight: theme.text['2lg-bold'].lineHeight, + }, + }, }); export const cardP = style({ margin: '0', fontSize: theme.text['xl-bold'].fontSize, fontWeight: theme.text['xl-bold'].fontWeight, + lineHeight: theme.text['xl-bold'].lineHeight, //color: theme.colors.white, + + '@media': { + [mediaQueries.mobile]: { + fontSize: theme.text['lg-bold'].fontSize, + lineHeight: theme.text['lg-bold'].lineHeight, + }, + }, }); export const cardSmall = style({ @@ -195,15 +333,32 @@ export const tags = style({ fontWeight: theme.text['2lg-medium'].fontWeight, lineHeight: theme.text['2lg-medium'].lineHeight, padding: '16px', + backgroundColor: theme.colors.white, }); export const cardActivityContainer = style({ - width: '100%', + width: '1204px', + height: '897px', display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gridTemplateRows: 'repeat(2, 1fr)', columnGap: '16px', rowGap: '24px', + + '@media': { + [mediaQueries.tablet]: { + width: '695px', + height: '1154px', + gridTemplateColumns: 'repeat(3, 1fr)', + gridTemplateRows: 'repeat(3, 1fr)', + }, + [mediaQueries.mobile]: { + width: '344px', + height: '591px', + gridTemplateColumns: 'repeat(2, 1fr)', + gridTemplateRows: 'repeat(2, 1fr)', + }, + }, }); export const cardActivity = style({ @@ -213,15 +368,6 @@ export const cardActivity = style({ borderRadius: '20px', overflow: 'hidden', position: 'relative', - /* - selectors: { - '&::after': { - display: 'block', - content: '', - paddingBottom: '100%', - }, - }, - */ }); export const cardActivityImage = style({ @@ -256,4 +402,16 @@ export const pagBu = style({ cursor: 'pointer', width: '55px', height: '55px', +}); + +export const linkLine = style({ + color: '#1b1b1b', + textDecorationLine: 'none', + + ':visited': { + color: '#1b1b1b', + }, + ':hover': { + textDecorationLine: 'underline', + }, }); \ No newline at end of file diff --git a/src/app/main/page.tsx b/src/app/main/page.tsx deleted file mode 100644 index d6e05f1..0000000 --- a/src/app/main/page.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import Header from "../../components/Header"; -import Footer from "../../components/Footer"; -import * as style from './main.css'; -import Dropdown from "@/components/Dropdown"; -import CustomButton from "@/components/CustomButton"; - - -export default function Main() { - return ( -
-
-
-
-
-

함께 배우면 즐거운

-

스트릿 댄스

-

1월의 인기 체험 BEST🔥

-
-
-
-
무엇을 체험하고 싶으신가요?
-
- - - -
-
-
-
-

🔥인기 체험

-
-

<

-

>

-
-
-
-
- 스트릿 댄스 -
-

- 함께 배우면 즐거운
- 스트릿 댄스 -

-

₩38,000 / 인

-
-
-
- 다리 건너기 -
-

- 연인과 사랑의 징검
- 다리 건너기 -

-

₩5,600 / 인

-
-
-
- VR 게임 -
-

- VR 게임 마스터
- 하는 법 -

-

₩38,000 / 인

-
-
-
-
-
-
문화 · 예술
-
-
-
-

🧳 모든 체험

-
-
-
- 피오르 체험 -

3.9(108)

-

피오르 체험

-

₩42,800 / 인

-
-
-
-
- - {[1, 2, 3, 4, 5].map((page) => ( - - ))} - -
-
-
-
-
- ); -}; \ No newline at end of file diff --git a/src/app/page.tsx b/src/app/page.tsx index 15c4803..b3a3c7f 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,7 +1,337 @@ -export default function Home() { +'use client'; + +import React, { useRef, useState, useCallback, FormEvent } from 'react'; +import Image from 'next/image'; +import Link from 'next/link'; +import * as style from './main.css'; +import CustomButton from '@/components/CustomButton'; + +import { usePopularActivities } from '@/hooks/usePopularActivities'; +import { useAllActivities } from '@/hooks/useAllActivities'; +import { useResponsivePageSize } from '@/hooks/useResponsivePageSize'; + +const categories = [ + '문화 · 예술', + '식음료', + '스포츠', + '투어', + '관광', + '웰빙', +] as const; + +const sortOptions = ['price_asc', 'price_desc'] as const; + +// 1) 중복 제거 함수 +import { ActivitiesProps } from '@/app/api/activitiesList'; + +function removeDuplicateActivities(arr: ActivitiesProps[]): ActivitiesProps[] { + const seen = new Set(); + return arr.filter((item) => { + if (seen.has(item.id)) { + return false; + } + seen.add(item.id); + return true; + }); +} + +export default function Main() { + /** + * [인기 체험] 섹션: 무한스크롤 + */ + const { + data: popularData, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + } = usePopularActivities(); + + // 모든 페이지 데이터를 합쳐 단일 배열로 + const rawPopularActivities = + popularData?.pages.flatMap((p) => p.activities) || []; + + // 2) 중복 ID 제거 + const popularActivities = removeDuplicateActivities(rawPopularActivities); + + // 스크롤 컨테이너 (인기 체험 카드 리스트) 참조 + const cardHotContainerRef = useRef(null); + + // < 버튼 → 왼쪽 스크롤 + const handleScrollLeft = useCallback(() => { + if (!cardHotContainerRef.current) return; + cardHotContainerRef.current.scrollBy({ left: -400, behavior: 'smooth' }); + }, []); + + // > 버튼 → 오른쪽 스크롤 + 다음 페이지 fetch + const handleScrollRight = useCallback(() => { + if (!cardHotContainerRef.current) return; + // 가로로 400px 이동 + cardHotContainerRef.current.scrollBy({ left: 400, behavior: 'smooth' }); + + // 아직 다음 페이지가 있고, 현재 fetch 중이 아니라면 다음 페이지 요청 + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + const firstPopular = popularActivities[0]; // 첫 번째 인기 체험 + + /** + * -------------------------------------------------------------------------------- + * 2) [모든 체험] 섹션: 페이지네이션 + 카테고리 + 정렬 + 반응형 pageSize + * -------------------------------------------------------------------------------- + */ + // 브라우저 너비에 따라 8 or 9 or 4 + const responsiveSize = useResponsivePageSize(); + + const [page, setPage] = useState(1); + const [selectedCategory, setSelectedCategory] = useState< + string | undefined + >(); + const [selectedSort, setSelectedSort] = useState< + 'price_asc' | 'price_desc' + >(); + const [keywordSearch, setKeywordSearch] = useState(''); + + // 한 페이지에 responsiveSize 개씩 + const { data: allActivitiesData, isLoading } = useAllActivities({ + page, + size: responsiveSize, // 반응형 크기 + sort: selectedSort, + category: selectedCategory, + keyword: keywordSearch.trim() ? keywordSearch : undefined, + }); + + // 페이지네이션을 위해 totalCount 사용 + const totalCount = allActivitiesData?.totalCount || 0; + // totalPage = totalCount / responsiveSize + const totalPage = Math.ceil(totalCount / responsiveSize); + + // 페이지 이동 + const handlePrevPage = () => setPage((prev) => Math.max(prev - 1, 1)); + const handleNextPage = () => setPage((prev) => Math.min(prev + 1, totalPage)); + const handlePageClick = (pageNumber: number) => setPage(pageNumber); + + // 카테고리 변경 + const handleCategoryChange = (cat: string) => { + setSelectedCategory(cat); + setPage(1); // 필터 바뀌면 1페이지로 + }; + + // 가격 정렬 변경 + const handleSortChange = (sort: 'price_asc' | 'price_desc') => { + setSelectedSort(sort); + setPage(1); + }; + + // 검색 폼 제출 시 + const handleSearchSubmit = (e: FormEvent) => { + e.preventDefault(); + setPage(1); + }; + + // 실제 '모든 체험' 목록 데이터 + const allActivities = allActivitiesData?.activities || []; + return (
-
+
+ {/* ---------------------------------------------------------------- + 섹션 1) 메인 배너 + ----------------------------------------------------------------*/} +
+
+

+ {firstPopular ? '함께 배우면 즐거운' : ''} +

+

+ {firstPopular ? firstPopular.title : '로딩중...'} +

+

+ {firstPopular ? `1월의 인기 체험 BEST🔥` : ''} +

+ {firstPopular?.title +
+
+ + {/* --------------------------- + 검색 바 (keyword 입력) + --------------------------- */} +
+
무엇을 체험하고 싶으신가요?
+
+ setKeywordSearch(e.target.value)} + /> + + +
+ +
+ {/** ---------------------------------------------------------------- + [인기 체험] 섹션 (무한 스크롤 + 가로 스크롤) + ----------------------------------------------------------------*/} +
+
+

🔥인기 체험

+
+ {/* 왼쪽 화살표 */} +

+ < +

+ {/* 오른쪽 화살표 */} +

+ > +

+
+
+ +
+ {popularActivities.map((activity) => ( +
+ + {activity.title} + +
+

{activity.title}

+

+ ₩{activity.price.toLocaleString()}{' '} + / 인 +

+
+
+ ))} +
+
+ + {/* ---------------------------------------------------------------- + 카테고리 / 정렬 선택 영역 + ----------------------------------------------------------------*/} +
+
+ {categories.map((cat) => ( + + ))} +
+
+ {sortOptions.map((opt) => ( + + ))} +
+
+ + + {/** ---------------------------------------------------------------- + [모든 체험] 섹션: useAllActivities (페이지네이션) + ----------------------------------------------------------------*/} +
+
+

🧳 모든 체험

+
+ + {isLoading &&

로딩 중...

} + {!isLoading && ( +
+ {allActivities.map((activity) => ( +
+ + {activity.title} + +

+ {activity.rating}({activity.reviewCount}) +

+ +

{activity.title}

+ +

+ ₩{activity.price.toLocaleString()}{' '} + / 인 +

+ +
+ ))} +
+ )} +
+ + {/* 페이지네이션 버튼 */} +
+ + + {Array.from({ length: totalPage }).map((_, idx) => { + const pageNumber = idx + 1; + return ( + + ); + })} + + +
+
+
); -} +} \ No newline at end of file diff --git a/src/components/Dropdown.tsx b/src/components/Dropdown.tsx index 3fdeb4f..f36d3c0 100644 --- a/src/components/Dropdown.tsx +++ b/src/components/Dropdown.tsx @@ -1,7 +1,6 @@ 'use client' import React, { useState, useRef, useEffect } from 'react'; -import type { CustomDropdownMode } from '@/types/CustomDropdownMode'; import * as styles from './Dropdown.css'; import DropdownBox from './Dropdown/DropdownBox'; import DropdownMenu from './Dropdown/DropdownMenu'; diff --git a/src/components/Dropdown/DropdownBox.tsx b/src/components/Dropdown/DropdownBox.tsx index 3edfdec..a9a2f1d 100644 --- a/src/components/Dropdown/DropdownBox.tsx +++ b/src/components/Dropdown/DropdownBox.tsx @@ -5,15 +5,6 @@ interface DropdownBoxProps { label: string; } -const DropdownIn: React.FC = ({ onClick, label }) => ( - -); - const DropdownBox: React.FC = ({ onClick, label }) => (