diff --git a/apps/web/config/index.ts b/apps/web/config/index.ts new file mode 100644 index 0000000..99fd7a3 --- /dev/null +++ b/apps/web/config/index.ts @@ -0,0 +1 @@ +export const SERVER_URL = process.env.NEXT_PUBLIC_API_SERVER_URL diff --git a/apps/web/package.json b/apps/web/package.json index bc106a7..22aa671 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,7 +13,9 @@ "@tanstack/react-query": "^5.62.2", "next": "15.0.2", "react": "19.0.0-rc-02c0e824-20241028", - "react-dom": "19.0.0-rc-02c0e824-20241028" + "react-dom": "19.0.0-rc-02c0e824-20241028", + "react-intersection-observer": "^9.13.1", + "zustand": "^5.0.2" }, "devDependencies": { "@next/eslint-plugin-next": "^15.0.2", diff --git a/apps/web/src/api/event.ts b/apps/web/src/api/event.ts new file mode 100644 index 0000000..16a4187 --- /dev/null +++ b/apps/web/src/api/event.ts @@ -0,0 +1,14 @@ +import { IGetEventsByCategoryResponse } from '@/types/event' +import { SERVER_URL } from '../../config' + +export async function getEventsByCategory(category: string, page: number, limit: number): Promise { + const res = await fetch(`${SERVER_URL}/v1/events/category/${category}?page=${page}&limit=${limit}`, { + method: 'GET', + // TODO: add authorization Header + }) + + if (!res.ok) { + throw new Error(`code: ${res.status}\ndescription: ${res.statusText}`) + } + return res.json() +} diff --git a/apps/web/src/app/events/category/page.tsx b/apps/web/src/app/events/category/page.tsx new file mode 100644 index 0000000..c59d487 --- /dev/null +++ b/apps/web/src/app/events/category/page.tsx @@ -0,0 +1,38 @@ +'use client' + +import { useRouter } from 'next/navigation' + +import { EventList } from '@/components/events/eventList' +import { BackButtonIcon } from '@/components/icon' +import { categoryStore } from '@/store' + +export default function Page() { + const router = useRouter() + const { category, setCategory } = categoryStore() + + const onClick = () => { + router.back() + setCategory('') + } + if (!category) { + return ( +
+
잘못된 접근입니다.
+
+ ) + } + return ( +
+ {/*TODO: Remove temporary header*/} +
+ +
{category}
+
+
+ +
+
+ ) +} diff --git a/apps/web/src/app/events/page.tsx b/apps/web/src/app/events/page.tsx new file mode 100644 index 0000000..27e06e0 --- /dev/null +++ b/apps/web/src/app/events/page.tsx @@ -0,0 +1,27 @@ +'use client' + +import { CategoryItem } from '@/components/events/categoryItem' + +export default function Page() { + // TODO: add get category names + return ( +
+
+ + +
+ {/*User Favorites Categories*/} +
+ + + +
+ {/*Other Categories*/} +
+ + + +
+
+ ) +} diff --git a/apps/web/src/components/events/categoryItem.tsx b/apps/web/src/components/events/categoryItem.tsx new file mode 100644 index 0000000..5743632 --- /dev/null +++ b/apps/web/src/components/events/categoryItem.tsx @@ -0,0 +1,40 @@ +import Link from 'next/link' + +import { BaseCategoryIcon, HottestCategoryIcon, WrittenCategoryIcon } from '@/components/icon' +import { categoryStore } from '@/store' + +interface Props { + category: string + isFavorites: boolean +} + +export function CategoryItem({ category, isFavorites }: Props) { + const { setCategory } = categoryStore() + const onClick = () => { + setCategory(category) + } + // TODO: change category names + if (category === '내가 등록한 행사') { + return ( + + +
내가 등록한 행사
+ + ) + } else if (category === '인기 행사') { + return ( + + +
인기 행사
+ + ) + } else { + // TODO: add logic making to Favorites + return ( + + +
{category}
+ + ) + } +} diff --git a/apps/web/src/components/events/event.tsx b/apps/web/src/components/events/event.tsx new file mode 100644 index 0000000..160653c --- /dev/null +++ b/apps/web/src/components/events/event.tsx @@ -0,0 +1,43 @@ +import { IEvent } from '@/types/event' + +interface Props { + event: IEvent | undefined +} + +const formatDate = (date: Date) => { + const formattedDate = new Date(date).toLocaleDateString('ko-KR', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + return formattedDate.replace(/\s/g, '').replace(/\.$/, '') +} + +export default function Event({ event }: Props) { + if (!event) { + return ( +
+
+
+
+
+
+
+
+ ) + } + + return ( +
+
+
{event.name}
+
{`${formatDate(event.startDate)}~${formatDate(event.endDate)}`}
+
{event.address}
+
+
+ {/*TODO: change image tag*/} + 대표 사진 +
+
+ ) +} diff --git a/apps/web/src/components/events/eventList.tsx b/apps/web/src/components/events/eventList.tsx new file mode 100644 index 0000000..43921ce --- /dev/null +++ b/apps/web/src/components/events/eventList.tsx @@ -0,0 +1,83 @@ +'use client' + +import { useEffect } from 'react' +import { useInfiniteQuery } from '@tanstack/react-query' +import { useInView } from 'react-intersection-observer' + +import { getEventsByCategory } from '@/api/event' +import { IGetEventsByCategoryResponse } from '@/types/event' +import Event from '@/components/events/event' + +interface useEventsByCategoryQueryProps { + category: string + startPage: number +} + +export function EventList({ category }: { category: string }) { + const { data, isLoading, isError, fetchNextPage, isFetchingNextPage } = useEventsByCategoryQuery({ + category: category, + startPage: 1, + }) + const { ref, inView } = useInView() + useEffect(() => { + if (inView) { + console.log('무한 스크롤 요청중') + fetchNextPage() + } + }, [inView]) + + if (isLoading) { + return ( +
+
+ {Array.from({ length: 6 }, (_, index) => ( + + ))} +
+ {isFetchingNextPage ? :
} +
+ ) + } + + if (isError) { + return ( +
+
+ 행사를 불러오는 데 실패하였습니다. +
+ 다시 시도해 주시길 바랍니다. +
+
+ ) + } + + return ( +
+
+ {/*TODO: add navigation to event detail page*/} + {data && data.pages.map(events => events.events.map(event => ))} +
+ {isFetchingNextPage ? :
} +
+ ) +} + +const useEventsByCategoryQuery = ({ category, startPage }: useEventsByCategoryQueryProps) => { + const { data, isLoading, isError, fetchNextPage, isFetchingNextPage } = useInfiniteQuery({ + queryKey: ['events', category], + queryFn: ({ pageParam }) => getEventsByCategory(category, pageParam as number, 6), + initialPageParam: startPage, + getNextPageParam: lastPage => { + if (lastPage.page.hasNextPage) { + return lastPage.page.page + 1 + } + }, + getPreviousPageParam: lastPage => { + if (lastPage.page.hasPrevPage) { + return lastPage.page.page - 1 + } + }, + }) + + return { data, isLoading, isError, fetchNextPage, isFetchingNextPage } +} diff --git a/apps/web/src/components/icon.tsx b/apps/web/src/components/icon.tsx new file mode 100644 index 0000000..5ddef47 --- /dev/null +++ b/apps/web/src/components/icon.tsx @@ -0,0 +1,72 @@ +interface IconProps { + width: number + height: number +} + +interface BaseCategoryProps extends IconProps { + isFavorites: boolean +} + +export function BackButtonIcon({ width, height }: IconProps) { + return ( + + + + ) +} + +export function WrittenCategoryIcon({ width, height }: IconProps) { + return ( + + + + + + + + + + ) +} + +export function HottestCategoryIcon({ width, height }: IconProps) { + return ( + + + + ) +} + +export function BaseCategoryIcon({ width, height, isFavorites }: BaseCategoryProps) { + return ( + + + + ) +} diff --git a/apps/web/src/store/category.ts b/apps/web/src/store/category.ts new file mode 100644 index 0000000..f62036d --- /dev/null +++ b/apps/web/src/store/category.ts @@ -0,0 +1,13 @@ +import { create } from 'zustand' + +interface CategoryStore { + category: string + setCategory: (name: string) => void +} + +export const categoryStore = create(set => ({ + category: '', + setCategory: category => { + set({ category }) + }, +})) diff --git a/apps/web/src/store/index.ts b/apps/web/src/store/index.ts new file mode 100644 index 0000000..1d13c06 --- /dev/null +++ b/apps/web/src/store/index.ts @@ -0,0 +1 @@ +export * from './category' diff --git a/apps/web/src/types/event.ts b/apps/web/src/types/event.ts new file mode 100644 index 0000000..a6c5c28 --- /dev/null +++ b/apps/web/src/types/event.ts @@ -0,0 +1,31 @@ +export interface IEvent { + _id: string + name: string + address: string + location: { + type: string + coordinates: number[] + } + startDate: Date + endDate: Date + description: string + photo: string[] + cost: number + likeCount: number + commentCount: number + category: string[] + targetAudience: string[] + createdAt: Date +} + +export interface IGetEventsByCategoryResponse { + page: { + totalDocs: number + totalPages: number + hasNextPage: boolean + hasPrevPage: boolean + page: number + limit: number + } + events: IEvent[] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 865662a..1c4316f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,6 +230,12 @@ importers: react-dom: specifier: 19.0.0-rc-02c0e824-20241028 version: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + react-intersection-observer: + specifier: ^9.13.1 + version: 9.13.1(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028) + zustand: + specifier: ^5.0.2 + version: 5.0.2(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028)(use-sync-external-store@1.2.2(react@19.0.0-rc-02c0e824-20241028)) devDependencies: '@next/eslint-plugin-next': specifier: ^15.0.2 @@ -5142,6 +5148,15 @@ packages: peerDependencies: react: '>=17.0.0' + react-intersection-observer@9.13.1: + resolution: {integrity: sha512-tSzDaTy0qwNPLJHg8XZhlyHTgGW6drFKTtvjdL+p6um12rcnp8Z5XstE+QNBJ7c64n5o0Lj4ilUleA41bmDoMw==} + peerDependencies: + react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + react-dom: + optional: true + react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} @@ -6212,6 +6227,24 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zustand@5.0.2: + resolution: {integrity: sha512-8qNdnJVJlHlrKXi50LDqqUNmUbuBjoKLrYQBnoChIbVph7vni+sY+YpvdjXG9YLd/Bxr6scMcR+rm5H3aSqPaw==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -12278,6 +12311,12 @@ snapshots: dependencies: react: 18.3.1 + react-intersection-observer@9.13.1(react-dom@19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028))(react@19.0.0-rc-02c0e824-20241028): + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optionalDependencies: + react-dom: 19.0.0-rc-02c0e824-20241028(react@19.0.0-rc-02c0e824-20241028) + react-is@16.13.1: {} react-is@17.0.2: {} @@ -13222,6 +13261,11 @@ snapshots: dependencies: react: 18.3.1 + use-sync-external-store@1.2.2(react@19.0.0-rc-02c0e824-20241028): + dependencies: + react: 19.0.0-rc-02c0e824-20241028 + optional: true + util-deprecate@1.0.2: {} utils-merge@1.0.1: {} @@ -13459,3 +13503,9 @@ snapshots: yargs-parser: 21.1.1 yocto-queue@0.1.0: {} + + zustand@5.0.2(@types/react@18.3.12)(react@19.0.0-rc-02c0e824-20241028)(use-sync-external-store@1.2.2(react@19.0.0-rc-02c0e824-20241028)): + optionalDependencies: + '@types/react': 18.3.12 + react: 19.0.0-rc-02c0e824-20241028 + use-sync-external-store: 1.2.2(react@19.0.0-rc-02c0e824-20241028)