diff --git a/apps/server/src/controllers/v1/schedules.ts b/apps/server/src/controllers/v1/schedules.ts index 00dc879..bef6172 100644 --- a/apps/server/src/controllers/v1/schedules.ts +++ b/apps/server/src/controllers/v1/schedules.ts @@ -5,8 +5,7 @@ import middlewares from '@/middlewares' const router: Router = asyncify(express.Router()) -// TODO: add validator(eventId, authorId) -router.post('/', async (req: Request, res: Response) => { +router.post('/', middlewares.schedules.addScheduleMiddleware, async (req: Request, res: Response) => { const { name, eventId, authorId, startDate, endDate, address, location, description, images } = req.body const schedule = await ScheduleModel.create({ name: name, diff --git a/apps/server/src/middlewares/schedules.ts b/apps/server/src/middlewares/schedules.ts index 3779cfd..6870902 100644 --- a/apps/server/src/middlewares/schedules.ts +++ b/apps/server/src/middlewares/schedules.ts @@ -1,6 +1,8 @@ import { NextFunction, Request, Response } from 'express' import { ScheduleModel } from '@/models/schedule' -import { UnauthorizedSchedule } from '@/types/errors/schedule' +import { InvalidRequestFormat, UnauthorizedSchedule } from '@/types/errors/schedule' +import { EventsModel } from '@/models/event' +import { UserModel } from '@/models/user' export const verifyAuthorMiddleware = async (req: Request, res: Response, next: NextFunction) => { const authorId = (await ScheduleModel.findOne({ _id: req.params.id }).exec())?.authorId @@ -12,3 +14,23 @@ export const verifyAuthorMiddleware = async (req: Request, res: Response, next: } next() } + +const verifyEventId = async (req: Request, res: Response, next: NextFunction) => { + const { eventId } = req.body + const target = await EventsModel.findOne({ _id: eventId }).exec() + if (!target) { + throw new InvalidRequestFormat(new Error(`not found event => id: ${eventId}`)) + } + next() +} + +const verifyAuthorId = async (req: Request, res: Response, next: NextFunction) => { + const { authorId } = req.body + const target = await UserModel.findOne({ _id: authorId }).exec() + if (!target) { + throw new InvalidRequestFormat(new Error(`not found author => id: ${authorId}`)) + } + next() +} + +export const addScheduleMiddleware = [verifyAuthorId, verifyEventId] diff --git a/apps/server/src/models/event.ts b/apps/server/src/models/event.ts index 35517dc..dc12822 100644 --- a/apps/server/src/models/event.ts +++ b/apps/server/src/models/event.ts @@ -1,9 +1,10 @@ import { getModelForClass, plugin, prop, ReturnModelType } from '@typegoose/typegoose' import mongoose from 'mongoose' import { TimeStamps } from '@typegoose/typegoose/lib/defaultClasses' -import { User } from '@/models/user' import mongoosePaginate from 'mongoose-paginate-v2' +import { User } from '@/models/user' + @plugin(mongoosePaginate) export class Events extends TimeStamps { static paginate: mongoose.PaginateModel['paginate'] @@ -57,6 +58,7 @@ export class Events extends TimeStamps { return { _id: this._id, name: this.name, + address: this.address, location: this.location, startDate: this.startDate, endDate: this.endDate, @@ -71,7 +73,11 @@ export class Events extends TimeStamps { } } - public static async findByCategory(this: ReturnModelType, category: string, options: object) { + public static async findByCategory( + this: ReturnModelType, + category: string, + options: mongoose.PaginateOptions, + ): Promise>> { return await this.paginate({ category: category }, options) } } diff --git a/apps/server/src/types/errors/schedule.ts b/apps/server/src/types/errors/schedule.ts index 2b901c0..79a74de 100644 --- a/apps/server/src/types/errors/schedule.ts +++ b/apps/server/src/types/errors/schedule.ts @@ -7,3 +7,11 @@ export class UnauthorizedSchedule extends APIError { Error.captureStackTrace(this, UnauthorizedSchedule) } } + +export class InvalidRequestFormat extends APIError { + constructor(cause: Error | string = null) { + super(400, 40000, 'invalid request format', cause) + Object.setPrototypeOf(this, InvalidRequestFormat.prototype) + Error.captureStackTrace(this, InvalidRequestFormat) + } +} 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 23253fe..9fe7e47 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -242,6 +242,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 @@ -5217,6 +5223,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==} @@ -6291,6 +6306,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': {} @@ -12430,6 +12463,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: {} @@ -13374,6 +13413,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: {} @@ -13613,3 +13657,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)