diff --git a/next.config.ts b/next.config.ts index 45bbc3e..286a610 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,9 @@ import type { NextConfig } from 'next'; const nextConfig: NextConfig = { // Docker 배포를 위한 standalone 모드 활성화 // 해당 설정은 프로덕션 빌드 시 필요한 파일만 .next/standalone 폴더에 복사됨. + images: { + domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'], + }, output: 'standalone', }; diff --git a/src/app/(with-header)/activities/[activitiesId]/page.tsx b/src/app/(with-header)/activities/[activitiesId]/page.tsx deleted file mode 100644 index f1e6e7c..0000000 --- a/src/app/(with-header)/activities/[activitiesId]/page.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import { mockActivity } from './mock/mock'; -import Title from './components/Title'; -import ImageGrid from './components/ImageGrid'; -import ReviewCard from './components/ReviewCard'; -import BookingInterface from '@/components/FloatingBox/BookingInterface'; -import LocationMap from '@/components/LocationMap'; -import ReviewTitle from './components/ReviewTitle'; - -export default function ActivityDetailPage() { - const { - title, - category, - description, - address, - bannerImageUrl, - subImages, - rating, - reviewCount, - } = mockActivity; - - const subImageUrls = subImages.map((image) => image.imageUrl); - - return ( -
- - <ImageGrid mainImage={bannerImageUrl} subImages={subImageUrls} /> - - <div className='mt-86 grid grid-cols-1 gap-15 md:grid-cols-3'> - <div className='md:col-span-2'> - <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 설명</h2> - <p className='whitespace-pre-line'>{description}</p> - </div> - <div className='md:row-span-2'> - <BookingInterface /> - </div> - - <div className='md:col-span-2'> - <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 장소</h2> - <LocationMap address='서울특별시 강남구' /> - <ReviewTitle /> - <ReviewCard - userName='강지현' - date='2023. 2. 4' - reviewText='전문가가 직접 강사로 참여하기 때문에 어떤 수준의 춤추는 사람도 쉽게 이해할 수 있었습니다. 이번 체험을 거쳐 저의 춤추기 실력은 더욱 향상되었어요.' - avatarSrc='/test/image1.png' - /> - <ReviewCard - userName='강지현' - date='2023. 2. 4' - reviewText='전문가가 직접 강사로 참여하기 때문에 어떤 수준의 춤추는 사람도 쉽게 이해할 수 있었습니다. 이번 체험을 거쳐 저의 춤추기 실력은 더욱 향상되었어요.' - avatarSrc='/test/image1.png' - /> - - <ReviewCard - userName='강지현' - date='2023. 2. 4' - reviewText='전문가가 직접 강사로 참여하기 때문에 어떤 수준의 춤추는 사람도 쉽게 이해할 수 있었습니다. 이번 체험을 거쳐 저의 춤추기 실력은 더욱 향상되었어요.' - avatarSrc='/test/image1.png' - /> - <ReviewCard - userName='강지현' - date='2023. 2. 4' - reviewText='전문가가 직접 강사로 참여하기 때문에 어떤 수준의 춤추는 사람도 쉽게 이해할 수 있었습니다. 이번 체험을 거쳐 저의 춤추기 실력은 더욱 향상되었어요.' - avatarSrc='/test/image1.png' - /> - </div> - </div> - </div> - ); -} diff --git a/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx b/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx new file mode 100644 index 0000000..7a5b4a8 --- /dev/null +++ b/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx @@ -0,0 +1,124 @@ +'use client'; + +import { useParams } from 'next/navigation'; +import Title from './Title'; +import ImageGrid from './ImageGrid'; +import BookingInterface from '@/components/FloatingBox/BookingInterface'; +import LocationMap from '@/components/LocationMap'; +import ReviewTitle from './ReviewTitle'; +import { useQuery } from '@tanstack/react-query'; +import { privateInstance } from '@/apis/privateInstance'; +import { useState, useEffect } from 'react'; +import useUserStore from '@/stores/authStore'; +import { padMonth } from '../utils/MonthFormatChange'; + +export default function ActivityDetailForm() { + const [year, setYear] = useState(2025); + const [month, setMonth] = useState(7); + const [isOwner, setIsOwner] = useState(false); + + const { id } = useParams(); + + const { data: activityData, isLoading } = useQuery({ + queryKey: ['activity', id], + queryFn: async () => { + return privateInstance.get(`/activities/${id}`); + }, + select: (response) => response.data, + enabled: !!id, + }); + + const userId = activityData?.userId; + + const currentUserId = useUserStore((state) => + state.user ? state.user.id : null, + ); + + useEffect(() => { + if (currentUserId && currentUserId === userId) { + setIsOwner(true); + console.log('니가 작성한 체험임'); + } else { + setIsOwner(false); + } + }, [currentUserId, userId]); + + const { data: schedulesData } = useQuery({ + queryKey: ['available-schedule', id, year, month], + queryFn: async () => { + const prevMonth = month === 1 ? 12 : month - 1; + const prevYear = month === 1 ? year - 1 : year; + const nextMonth = month === 12 ? 1 : month + 1; + const nextYear = month === 12 ? year + 1 : year; + + const results = await Promise.allSettled([ + privateInstance.get( + `/activities/${id}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`, + ), + privateInstance.get( + `/activities/${id}/available-schedule?year=${year}&month=${padMonth(month)}`, + ), + privateInstance.get( + `/activities/${id}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`, + ), + ]); + // 성공한 것만 합치기 + const data = results + .filter((r) => r.status === 'fulfilled') + .flatMap((r) => (r.status === 'fulfilled' ? r.value.data : [])); + return data; + }, + enabled: !!id && !!year && !!month, + }); + + if (isLoading || !activityData) { + return <div>로딩 중...</div>; + } + + const subImageUrls = activityData.subImages.map( + (image: { imageUrl: string }) => image.imageUrl, + ); + + return ( + <div className='mx-auto max-w-1200 p-4 sm:px-20 lg:p-8'> + <Title {...activityData} isOwner={isOwner} /> + <ImageGrid + mainImage={activityData.bannerImageUrl} + subImages={subImageUrls} + /> + + <div + className={`mt-86 grid gap-15 ${ + isOwner ? 'md:grid-cols-2' : 'md:grid-cols-3' + } grid-cols-1`} + > + <div className={`${isOwner ? 'md:col-span-2' : 'md:col-span-2'}`}> + <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 설명</h2> + <p className='whitespace-pre-line'>{activityData.description}</p> + </div> + + {!isOwner && ( + <div className='md:row-span-2'> + <BookingInterface + schedules={schedulesData ?? []} + onMonthChange={(year, month) => { + setTimeout(() => { + setYear(year); + setMonth(month); + }, 0); + }} + isOwner={isOwner} + price={activityData.price} + /> + </div> + )} + + <div className={`${isOwner ? 'md:col-span-4' : 'md:col-span-2'}`}> + <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 장소</h2> + <LocationMap address={activityData.address} /> + <ReviewTitle /> + </div> + </div> + </div> + ); +} diff --git a/src/app/(with-header)/activities/[activitiesId]/components/ImageGrid.tsx b/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx similarity index 96% rename from src/app/(with-header)/activities/[activitiesId]/components/ImageGrid.tsx rename to src/app/(with-header)/activities/[id]/components/ImageGrid.tsx index e3a597e..46427fc 100644 --- a/src/app/(with-header)/activities/[activitiesId]/components/ImageGrid.tsx +++ b/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx @@ -4,10 +4,7 @@ import Image from 'next/image'; import React, { useState } from 'react'; import { ImageGridProps } from '@/types/activityDetailType'; -export default function ImageGrid({ - mainImage, - subImages, -}: ImageGridProps) { +export default function ImageGrid({ mainImage, subImages }: ImageGridProps) { const images = [mainImage, ...subImages]; const [currentIndex, setCurrentIndex] = useState(0); //캐러셀 구현용 state diff --git a/src/app/(with-header)/activities/[activitiesId]/components/ReviewCard.tsx b/src/app/(with-header)/activities/[id]/components/ReviewCard.tsx similarity index 100% rename from src/app/(with-header)/activities/[activitiesId]/components/ReviewCard.tsx rename to src/app/(with-header)/activities/[id]/components/ReviewCard.tsx diff --git a/src/app/(with-header)/activities/[activitiesId]/components/ReviewTitle.tsx b/src/app/(with-header)/activities/[id]/components/ReviewTitle.tsx similarity index 100% rename from src/app/(with-header)/activities/[activitiesId]/components/ReviewTitle.tsx rename to src/app/(with-header)/activities/[id]/components/ReviewTitle.tsx diff --git a/src/app/(with-header)/activities/[id]/components/Skeleton.tsx b/src/app/(with-header)/activities/[id]/components/Skeleton.tsx new file mode 100644 index 0000000..89958ef --- /dev/null +++ b/src/app/(with-header)/activities/[id]/components/Skeleton.tsx @@ -0,0 +1,4 @@ + +export function Skeleton({ className = '' }: { className?: string }) { + return <div className={`animate-pulse bg-gray-200 ${className}`} />; +} diff --git a/src/app/(with-header)/activities/[activitiesId]/components/Title.tsx b/src/app/(with-header)/activities/[id]/components/Title.tsx similarity index 55% rename from src/app/(with-header)/activities/[activitiesId]/components/Title.tsx rename to src/app/(with-header)/activities/[id]/components/Title.tsx index 02091e3..5355cd0 100644 --- a/src/app/(with-header)/activities/[activitiesId]/components/Title.tsx +++ b/src/app/(with-header)/activities/[id]/components/Title.tsx @@ -1,7 +1,11 @@ import React from 'react'; import IconDropdown from '@assets/svg/dropdown'; import Star from '@assets/svg/star'; -import { TitleProps } from '@/types/activityDetailType'; +import { ActivityDetail } from '@/types/activityDetailType'; +import ActivityDropdown from '@/components/ActivityDropdown'; +import Menu from '@/components/ActivityDropdown/menu'; +import Item from '@/components/ActivityDropdown/Item'; +import Trigger from '@/components/ActivityDropdown/trigger'; export default function Title({ title, @@ -9,8 +13,8 @@ export default function Title({ rating, reviewCount, address, - isDropDown, -}: TitleProps) { + isOwner, +}: ActivityDetail) { return ( <div className='mb-6 flex items-start justify-between'> <div className='flex flex-col gap-8'> @@ -29,10 +33,16 @@ export default function Title({ </div> </div> - {isDropDown && ( - <div className='mt-30 flex items-center gap-1'> - <IconDropdown /> - </div> + {isOwner && ( + <ActivityDropdown> + <Trigger> + <IconDropdown /> + </Trigger> + <Menu> + <Item onClick={() => alert('수정')}>수정하기</Item> + <Item onClick={() => alert('삭제')}>삭제하기</Item> + </Menu> + </ActivityDropdown> )} </div> ); diff --git a/src/app/(with-header)/activities/[activitiesId]/mock/mock.ts b/src/app/(with-header)/activities/[id]/mock/mock.ts similarity index 100% rename from src/app/(with-header)/activities/[activitiesId]/mock/mock.ts rename to src/app/(with-header)/activities/[id]/mock/mock.ts diff --git a/src/app/(with-header)/activities/[id]/page.tsx b/src/app/(with-header)/activities/[id]/page.tsx new file mode 100644 index 0000000..f62da97 --- /dev/null +++ b/src/app/(with-header)/activities/[id]/page.tsx @@ -0,0 +1,5 @@ +import ActivityDetailForm from './components/ActivityDetailForm'; + +export default function ActivityDetailPage() { + return <ActivityDetailForm />; +} diff --git a/src/app/(with-header)/activities/[id]/utils/MonthFormatChange.ts b/src/app/(with-header)/activities/[id]/utils/MonthFormatChange.ts new file mode 100644 index 0000000..fc635d4 --- /dev/null +++ b/src/app/(with-header)/activities/[id]/utils/MonthFormatChange.ts @@ -0,0 +1,3 @@ +export function padMonth(m: number): string { + return m.toString().padStart(2, '0'); +} diff --git a/src/app/api/activities/[id]/available-schedule/route.ts b/src/app/api/activities/[id]/available-schedule/route.ts new file mode 100644 index 0000000..232cd42 --- /dev/null +++ b/src/app/api/activities/[id]/available-schedule/route.ts @@ -0,0 +1,51 @@ +import axios, { AxiosError } from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +interface ErrorResponse { + error?: string; + message?: string; +} + +export async function GET(request: NextRequest) { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + const { searchParams, pathname } = request.nextUrl; + + const year = searchParams.get('year'); + const month = searchParams.get('month'); + + const segments = pathname.split('/'); + const id = segments[segments.indexOf('activities') + 1]; + + if (!id) { + return NextResponse.json( + { error: '유효하지 않은 요청입니다.' }, + { status: 400 }, + ); + } + + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/activities/${id}/available-schedule?year=${year}&month=${month}`, + { + headers: { + Authorization: accessToken ? `Bearer ${accessToken}` : undefined, + }, + }, + ); + return NextResponse.json(response.data); + } catch (err) { + const error = err as AxiosError<ErrorResponse>; + + const message = + error.response?.data?.error || + error.response?.data?.message || + '스케줄 데이터 조회 실패'; + + const status = error.response?.status || 500; + + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/activities/[id]/reservation/route.ts b/src/app/api/activities/[id]/reservation/route.ts new file mode 100644 index 0000000..e69c72c --- /dev/null +++ b/src/app/api/activities/[id]/reservation/route.ts @@ -0,0 +1,52 @@ +import axios, { AxiosError } from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +interface ErrorResponse { + error?: string; + message?: string; +} + +export async function POST(request: NextRequest) { + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + const { selectedTimeId, participants } = await request.json(); + + const pathname = request.nextUrl.pathname; + const segments = pathname.split('/'); + const id = segments[segments.indexOf('activities') + 1]; + + if (!id) { + return NextResponse.json( + { error: '유효하지 않은 요청입니다.' }, + { status: 400 }, + ); + } + + try { + const response = await axios.post( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/activities/${id}/reservations`, + { + scheduleId: Number(selectedTimeId), + headCount: Number(participants), + }, + { + headers: { + Authorization: accessToken ? `Bearer ${accessToken}` : undefined, + }, + }, + ); + return NextResponse.json(response.data); + } catch (err) { + const error = err as AxiosError<ErrorResponse>; + + const message = + error.response?.data?.error || + error.response?.data?.message || + '예약 실패'; + + const status = error.response?.status || 500; + return NextResponse.json({ error: message }, { status }); + } +} diff --git a/src/app/api/activities/[id]/route.ts b/src/app/api/activities/[id]/route.ts new file mode 100644 index 0000000..f962734 --- /dev/null +++ b/src/app/api/activities/[id]/route.ts @@ -0,0 +1,32 @@ +import axios, { AxiosError } from 'axios'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; +import { ServerErrorResponse } from '@/types/apiErrorResponseType'; + +export const GET = async ( + request: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) => { + const { id } = await params; + const cookieStore = await cookies(); + const accessToken = cookieStore.get('accessToken')?.value; + + try { + const response = await axios.get( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/activities/${id}`, + { + headers: { + Authorization: accessToken ? `Bearer ${accessToken}` : undefined, + }, + }, + ); + + return NextResponse.json(response.data); + } catch (err) { + const error = err as AxiosError<ServerErrorResponse>; + const message = error.response?.data?.error || '활동 상세 데이터 조회실패'; + const status = error.response?.status || 500; + + return NextResponse.json({ error: message }, { status }); + } +}; diff --git a/src/components/ActivityDropdown/Dropdown.tsx b/src/components/ActivityDropdown/Dropdown.tsx new file mode 100644 index 0000000..c8db1dd --- /dev/null +++ b/src/components/ActivityDropdown/Dropdown.tsx @@ -0,0 +1,30 @@ +'use client'; + +import { useState, useRef, useEffect, ReactNode } from 'react'; +import { DropdownContext } from './DropdownContext'; + +export default function Wrapper({ children }: { children: ReactNode }) { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef<HTMLDivElement>(null); + + useEffect(() => { + const handleClickOutside = (e: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(e.target as Node) + ) { + setIsOpen(false); + } + }; + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + return ( + <DropdownContext.Provider value={{ isOpen, setIsOpen, dropdownRef }}> + <div ref={dropdownRef} className='relative'> + {children} + </div> + </DropdownContext.Provider> + ); +} diff --git a/src/components/ActivityDropdown/DropdownContext.tsx b/src/components/ActivityDropdown/DropdownContext.tsx new file mode 100644 index 0000000..e95dd6a --- /dev/null +++ b/src/components/ActivityDropdown/DropdownContext.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +interface DropdownContextType { + isOpen: boolean; + setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; + dropdownRef: React.RefObject<HTMLDivElement | null>; +} + +export const DropdownContext = createContext<DropdownContextType | null>(null); + +export function useDropdownContext() { + const context = useContext(DropdownContext); + if (!context) { + throw new Error('요소들은 <Dropdown> 내부에서만 사용되야합니다'); + } + return context; +} diff --git a/src/components/ActivityDropdown/Item.tsx b/src/components/ActivityDropdown/Item.tsx new file mode 100644 index 0000000..25e75ae --- /dev/null +++ b/src/components/ActivityDropdown/Item.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { ReactNode } from 'react'; + +import cn from '@/lib/cn'; + +import { useDropdownContext } from './DropdownContext'; + +/** + * Item + * + * Dropdown 내부 개별 아이템 요소 컴포넌트 + * + * 클릭 시 `onClick` 핸들러가 실행되고, 드롭다운 메뉴가 닫힙니다. + * + * @param {ReactNode | string} props.children - 버튼 내부 표시할 children + * @param {() => void} props.onClick - 버튼 클릭 시 실행될 함수 + * @param {string} [props.itemClassName] - 버튼 추가 클래스네임 + * + * @example + * ```tsx + * <Item onClick={() => console.log('클릭')}> + * 메뉴 1 + * </Item> + * ``` + * + */ +export default function Item({ + children, + onClick, + itemClassName, +}: { + children: string | ReactNode; + onClick: () => void; + itemClassName?: string; +}) { + const { setIsOpen } = useDropdownContext(); + + const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => { + e.preventDefault(); + e.stopPropagation(); + onClick(); + setIsOpen(false); + }; + + return ( + <button + className={cn( + 'md:text-md z-30 h-40 w-90 cursor-pointer rounded-[1.2rem] text-sm font-medium hover:animate-pulse hover:bg-gray-300 md:h-46 md:w-108', + itemClassName, + )} + type='button' + onClick={handleClick} + > + {children} + </button> + ); +} diff --git a/src/components/ActivityDropdown/index.ts b/src/components/ActivityDropdown/index.ts new file mode 100644 index 0000000..90a970f --- /dev/null +++ b/src/components/ActivityDropdown/index.ts @@ -0,0 +1,33 @@ +import Wrapper from './Dropdown'; +import Item from './Item'; +import Menu from './menu'; +import Trigger from './trigger'; + +/** + * ActivityDropdown 컴포넌트 + * + * 트리거를 클릭할시 하단에 요소들이 나타나도록하는 공통컴포넌트입니다. + * + * @example + * ```tsx + * <ActivityDropdown> + * <Trigger>메뉴 열기</Trigger> + * <Menu> + * <Item onClick={() => alert('수정됨')}> + * 수정 + * </Item> + * <Item onClick={() => alert('삭제됨')}> + * 삭제 + * </Item> + * </Menu> + * </ActivityDropdown> + * ``` + */ + +const ActivityDropdown = Object.assign(Wrapper, { + Trigger: Trigger, + Menu: Menu, + Item: Item, +}); + +export default ActivityDropdown; diff --git a/src/components/ActivityDropdown/menu.tsx b/src/components/ActivityDropdown/menu.tsx new file mode 100644 index 0000000..8858fc6 --- /dev/null +++ b/src/components/ActivityDropdown/menu.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { ReactNode } from 'react'; + +import cn from '@/lib/cn'; + +import { useDropdownContext } from './DropdownContext'; + +/** + * Menu + * + * 드롭다운 메뉴 컨테이너 컴포넌트입니다. + * + * 드롭다운이 열려 있을 때만 자식 요소들을 보여줍니다. + * + * @param {ReactNode} props.children - 드롭다운 메뉴에 들어갈 내용 + * @param {string} props.menuClassName - 추가 클래스네임 + * + * @example + * ```tsx + * <Menu> + * <Item onClick={() => console.log('메뉴 1 클릭')}>메뉴 1</Item> + * <Item onClick={() => console.log('메뉴 2 클릭')}>메뉴 2</Item> + * </Menu> + * ``` + * + * ``` + */ +export default function Menu({ + children, + menuClassName, +}: { + children: ReactNode; + menuClassName?: string; +}) { + const { isOpen } = useDropdownContext(); + + if (!isOpen) return null; + + return ( + <div + className={cn( + 'absolute top-37 right-0 z-9999 flex flex-col content-center justify-around rounded-md border border-gray-300 bg-white p-2 shadow-lg', + menuClassName, + )} + > + {children} + </div> + ); +} diff --git a/src/components/ActivityDropdown/trigger.tsx b/src/components/ActivityDropdown/trigger.tsx new file mode 100644 index 0000000..791b60d --- /dev/null +++ b/src/components/ActivityDropdown/trigger.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { ReactNode } from 'react'; + +import { useDropdownContext } from './DropdownContext'; + +/** + * Trigger + * + * 드롭다운 메뉴를 여닫는 트리거 버튼 컴포넌트입니다. + * + * + * @param {ReactNode} props.children - 버튼 내부 표시할 children + * + * @example + * ```tsx + * <Trigger> + * <ProfileImg /> + * </Trigger> + * ``` + * + */ +export default function Trigger({ children }: { children: ReactNode }) { + const { isOpen, setIsOpen } = useDropdownContext(); + + return ( + <button + aria-expanded={isOpen} + aria-haspopup='true' + className='cursor-pointer' + type='button' + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + setIsOpen((prev) => !prev); + }} + > + {children} + </button> + ); +} diff --git a/src/components/DatePicker/CalendarBody.tsx b/src/components/DatePicker/CalendarBody.tsx index 7fe1e6f..195dcfd 100644 --- a/src/components/DatePicker/CalendarBody.tsx +++ b/src/components/DatePicker/CalendarBody.tsx @@ -1,7 +1,6 @@ 'use client'; import type dayjs from 'dayjs'; - import { CalendarBodyProps } from '@/types/datePickerTypes'; export default function CalendarBody({ diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 10077e3..df413cc 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -6,52 +6,78 @@ import weekday from 'dayjs/plugin/weekday'; import isoWeek from 'dayjs/plugin/isoWeek'; import weekOfYear from 'dayjs/plugin/weekOfYear'; import 'dayjs/locale/ko'; + import CalendarHeader from './CalendarHeader'; import CalendarBody from './CalendarBody'; import useBookingStore from '@/stores/Booking/useBookingStore'; +import { SchedulesProps } from '@/types/activityDetailType'; dayjs.extend(weekday); dayjs.extend(isoWeek); dayjs.extend(weekOfYear); dayjs.locale('ko'); -const mockAvailableDates = [ - { - date: '2025-12-01', - times: [{ id: 21498, startTime: '12:00', endTime: '13:00' }], - }, - { - date: '2025-12-05', - times: [ - { id: 21499, startTime: '12:00', endTime: '13:00' }, - { id: 21500, startTime: '13:00', endTime: '14:00' }, - { id: 21501, startTime: '14:00', endTime: '15:00' }, - ], - }, -]; - -export default function DatePicker() { +export default function DatePicker({ + schedules, + onMonthChange, +}: { + schedules: SchedulesProps; + onMonthChange?: (year: number, month: number) => void; +}) { const { selectedDate, setSelectedDate, setAvailableDates, availableDates } = useBookingStore(); const today = dayjs(); - const [viewDate, setViewDate] = useState(dayjs(selectedDate)); + + const [viewDate, setViewDate] = useState(() => + selectedDate ? dayjs(selectedDate) : today, + ); useEffect(() => { - setAvailableDates(mockAvailableDates); - }, [setAvailableDates]); + setAvailableDates(schedules); + }, [setAvailableDates, schedules]); - const handleDateSelect = (date: dayjs.Dayjs) => { - setSelectedDate(date.toDate()); - }; + useEffect(() => { + if (!selectedDate && schedules.length > 0) { + const firstSchedule = schedules.find( + (item) => + dayjs(item.date).year() === viewDate.year() && + dayjs(item.date).month() === viewDate.month(), + ); + if (firstSchedule) { + setSelectedDate(dayjs(firstSchedule.date).toDate()); + } + } + }, [schedules, viewDate]); - const changeMonth = (direction: 'add' | 'subtract') => { - setViewDate((prev) => - direction === 'add' ? prev.add(1, 'month') : prev.subtract(1, 'month'), + useEffect(() => { + console.log( + '가능날짜', + availableDates.map((d) => d.date), + ); + console.log( + '하이라이트날짜', + highlightDates.map((d) => d.format('YYYY-MM-DD')), ); + console.log('뷰데이트', viewDate.format('YYYY-MM-DD')); + }, [availableDates, viewDate]); + + const changeMonth = (direction: 'add' | 'subtract') => { + setViewDate((prev) => { + const newDate = + direction === 'add' ? prev.add(1, 'month') : prev.subtract(1, 'month'); + onMonthChange?.(newDate.year(), newDate.month() + 1); + return newDate; + }); + }; + + console.log('schedules', schedules); + + const handleDateSelect = (date: dayjs.Dayjs) => { + setSelectedDate(date.toDate()); }; - const highlightDates = availableDates.map((item) => dayjs(item.date)); + const highlightDates = (availableDates ?? []).map((item) => dayjs(item.date)); return ( <div className='max-h-[746px] w-full max-w-md rounded-2xl bg-white p-6'> @@ -59,7 +85,7 @@ export default function DatePicker() { <CalendarBody viewDate={viewDate} today={today} - selectedDate={dayjs(selectedDate)} + selectedDate={selectedDate ? dayjs(selectedDate) : dayjs('')} onSelectDate={handleDateSelect} highlightDates={highlightDates} /> diff --git a/src/components/FloatingBox/BookingInterface.tsx b/src/components/FloatingBox/BookingInterface.tsx index cefba9e..92107a5 100644 --- a/src/components/FloatingBox/BookingInterface.tsx +++ b/src/components/FloatingBox/BookingInterface.tsx @@ -9,33 +9,75 @@ import TotalPriceDisplay from './TotalPriceDisplay'; import BookingModal from '@/ui/BookingModal'; import DatePicker from '../DatePicker/DatePicker'; import Button from '../Button'; +import { SchedulesProps } from '@/types/activityDetailType'; +import { privateInstance } from '@/apis/privateInstance'; +import { useParams } from 'next/navigation'; +import { AxiosError } from 'axios'; -export default function BookingInterface() { - const handleBooking = () => { - alert('예약이 완료되었습니다!'); +export default function BookingInterface({ + schedules, + onMonthChange, + isOwner, + price, +}: { + schedules: SchedulesProps; + onMonthChange?: (year: number, month: number) => void; + isOwner: boolean; + price: number; +}) { + const handleBooking = async () => { + try { + await privateInstance.post(`/activities/${id}/reservation`, { + selectedTimeId, + participants, + }); + + alert('예약이 완료되었습니다!'); + setIsOpen(false); + } catch (err) { + const error = err as AxiosError; + + const responseData = error.response?.data as + | { error?: string; message?: string } + | undefined; + + console.error('전체 에러:', error); + + alert( + responseData?.error || + responseData?.message || + error.message || + '예약에 실패했습니다.', + ); + } }; const setIsOpen = useBookingStore((state) => state.setIsOpen); const { selectedDate, selectedTime, participants, selectedTimeId } = useBookingStore(); + const { id } = useParams(); const isBookable = - !!selectedDate && !!selectedTime && !!selectedTimeId && !!participants; + !!selectedDate && + !!selectedTime && + !!selectedTimeId && + !!participants && + !isOwner; return ( <div className='w-full max-w-sm'> {/* PC */} <div className='hidden rounded-lg border border-gray-800 bg-white p-6 lg:block'> <div className='flex flex-col gap-10 px-20'> - <PriceDisplay /> + <PriceDisplay price={price} /> <div className='flex justify-center'> - <DatePicker /> + <DatePicker schedules={schedules} onMonthChange={onMonthChange} /> </div> <TimeSelector /> <ParticipantsSelector /> <BookingButton disabled={!isBookable} onClick={handleBooking}> - 예약하기 + {isOwner ? '본인이 등록한 체험입니다' : '예약하기'} </BookingButton> - <TotalPriceDisplay /> + <TotalPriceDisplay price={price} /> </div> </div> @@ -43,7 +85,7 @@ export default function BookingInterface() { <div className='relative hidden w-full max-w-sm rounded-lg border border-gray-800 bg-white p-6 md:block lg:hidden'> <div className='flex flex-col gap-20 px-18'> <div className='mb-6'> - <PriceDisplay /> + <PriceDisplay price={price} /> <h3 className='mb-4 text-lg font-semibold text-gray-900'>날짜</h3> <button onClick={() => setIsOpen(true)} @@ -63,18 +105,18 @@ export default function BookingInterface() { </div> <div className='flex flex-col items-center justify-center gap-20 px-10'> <ParticipantsSelector /> - <BookingModal /> + <BookingModal schedules={schedules} price={price} /> <BookingButton disabled={!isBookable} onClick={handleBooking}> - 예약하기 + {isOwner ? '본인이 등록한 체험입니다' : '예약하기'} </BookingButton> - <TotalPriceDisplay /> + <TotalPriceDisplay price={price} /> </div> </div> </div> {/* 모바일 */} - <div className='fixed right-0 bottom-0 left-0 z-100 block border border-gray-200 bg-white p-6 md:hidden'> + <div className='fixed right-0 bottom-0 left-0 z-50 block border border-gray-200 bg-white p-6 md:hidden'> <div className='mb-6 flex items-start justify-between'> <div className='flex-1'> <div className='mb-1 text-xl font-bold text-gray-900'> @@ -98,7 +140,7 @@ export default function BookingInterface() { '날짜 선택하기' )} </div> - <BookingModal /> + <BookingModal schedules={schedules} price={price} /> <Button variant='primary' disabled={!isBookable} diff --git a/src/components/FloatingBox/PriceDisplay.tsx b/src/components/FloatingBox/PriceDisplay.tsx index 7d539f5..c3fd83b 100644 --- a/src/components/FloatingBox/PriceDisplay.tsx +++ b/src/components/FloatingBox/PriceDisplay.tsx @@ -1,8 +1,8 @@ -export default function PriceDisplay() { +export default function PriceDisplay({ price }: { price: number }) { return ( <div className='mt-15 mb-6'> <div className='mb-1 text-2xl font-bold text-black'> - ₩ 1,000 <span className='text-xl font-bold text-gray-800'>/ 인</span> + ₩{price} <span className='text-xl font-bold text-gray-800'>/ 인</span> </div> </div> ); diff --git a/src/components/FloatingBox/TabletPopup.tsx b/src/components/FloatingBox/TabletPopup.tsx index 0bad5de..e3f01a3 100644 --- a/src/components/FloatingBox/TabletPopup.tsx +++ b/src/components/FloatingBox/TabletPopup.tsx @@ -1,28 +1,29 @@ -'use client'; +// 'use client'; -import useBookingStore from '@/stores/Booking/useBookingStore'; -import IconClose from '@assets/svg/close'; -import DatePicker from '../DatePicker/DatePicker'; -import TimeSelector from './TimeSelector'; +// import useBookingStore from '@/stores/Booking/useBookingStore'; +// import IconClose from '@assets/svg/close'; +// import DatePicker from '../DatePicker/DatePicker'; +// import TimeSelector from './TimeSelector'; +// import { SchedulesProps } from '@/types/activityDetailType'; -export default function TabletPopup({}) { - const isOpen = useBookingStore((state) => state.isOpen); - const setIsOpen = useBookingStore((state) => state.setIsOpen); +// export default function TabletPopup({schedules}:SchedulesProps) { +// const isOpen = useBookingStore((state) => state.isOpen); +// const setIsOpen = useBookingStore((state) => state.setIsOpen); - if (!isOpen) return null; +// if (!isOpen) return null; - return ( - <div className='absolute z-50 flex flex-col items-center justify-center'> - <div className='flex justify-between'> - <h2>날짜</h2> - <button onClick={() => setIsOpen(false)}> - <IconClose /> - </button> - </div> +// return ( +// <div className='absolute z-50 flex flex-col items-center justify-center'> +// <div className='flex justify-between'> +// <h2>날짜</h2> +// <button onClick={() => setIsOpen(false)}> +// <IconClose /> +// </button> +// </div> - <DatePicker /> +// <DatePicker schedules={schedules}/> - <TimeSelector /> - </div> - ); -} +// <TimeSelector /> +// </div> +// ); +// } diff --git a/src/components/FloatingBox/TimeSelector.tsx b/src/components/FloatingBox/TimeSelector.tsx index 08a815f..2ffc640 100644 --- a/src/components/FloatingBox/TimeSelector.tsx +++ b/src/components/FloatingBox/TimeSelector.tsx @@ -13,7 +13,8 @@ export default function TimeSelector() { : null; const timeSlots = - availableDates.find((item) => item.date === selectedDateStr)?.times ?? []; + (availableDates ?? []).find((item) => item.date === selectedDateStr) + ?.times ?? []; return ( <div className='mb-6'> diff --git a/src/components/FloatingBox/TotalPriceDisplay.tsx b/src/components/FloatingBox/TotalPriceDisplay.tsx index 2665d4f..8e91f96 100644 --- a/src/components/FloatingBox/TotalPriceDisplay.tsx +++ b/src/components/FloatingBox/TotalPriceDisplay.tsx @@ -1,12 +1,12 @@ import useBookingStore from '@/stores/Booking/useBookingStore'; -export default function TotalPriceDisplay() { +export default function TotalPriceDisplay({ price }: { price: number }) { const participants = useBookingStore((state) => state.participants); - const pricePerPerson = 1000; + return ( <div className='mt-3 flex items-center justify-between text-xl font-semibold'> <span>총 합계</span> - <span>₩ {(participants * pricePerPerson).toLocaleString()}</span> + <span>₩ {(participants * price).toLocaleString()}</span> </div> ); } diff --git a/src/components/LocationMap.tsx b/src/components/LocationMap.tsx index 52ff47c..01deb87 100644 --- a/src/components/LocationMap.tsx +++ b/src/components/LocationMap.tsx @@ -76,7 +76,7 @@ const LocationMap = ({ address }: LocationMapProps) => { return ( <> - <div className='flex h-[480px] w-full flex-col overflow-hidden rounded-lg shadow-md lg:max-w-[800px]'> + <div className='flex h-[480px] w-full flex-col overflow-hidden rounded-lg shadow-md lg:max-w-[1200px]'> {/* 지도 */} <Map center={coords} diff --git a/src/types/activityDetailType.ts b/src/types/activityDetailType.ts index 1529396..cf1a6b9 100644 --- a/src/types/activityDetailType.ts +++ b/src/types/activityDetailType.ts @@ -3,7 +3,6 @@ export interface ImageGridProps { subImages: string[]; } - export interface ReviewCardProps { userName: string; date: string; @@ -11,8 +10,6 @@ export interface ReviewCardProps { avatarSrc: string; } - - export interface TitleProps { title: string; category: string; @@ -20,4 +17,50 @@ export interface TitleProps { reviewCount: number; address: string; isDropDown?: boolean; -} \ No newline at end of file +} + +export interface ActivitySchedule { + id: number; + date: string; + startTime: string; + endTime: string; +} + +export type TimeSlot = { + id: number; + startTime: string; + endTime: string; +}; + +export type GroupedSchedule = { + date: string; + times: TimeSlot[]; +}; + +export type SchedulesProps = GroupedSchedule[]; + +export interface ActivitySubImage { + imageUrl: string; +} + +export interface ActivityDetail { + id: number; + isOwner : boolean; + userId: number; + title: string; + description: string; + category: string; + price: number; + address: string | undefined; + bannerImageUrl: string; + rating: number; + reviewCount: number; + createdAt: string; + updatedAt: string; + subImages: ActivitySubImage[]; + schedules: ActivitySchedule[]; +} + +export interface BookinDateProps { + schedules: ActivitySchedule[]; +} diff --git a/src/types/apiErrorResponseType.ts b/src/types/apiErrorResponseType.ts new file mode 100644 index 0000000..334f351 --- /dev/null +++ b/src/types/apiErrorResponseType.ts @@ -0,0 +1,3 @@ +export type ServerErrorResponse = { + error?: string; +}; diff --git a/src/ui/BookingModal.tsx b/src/ui/BookingModal.tsx index 7dfd714..fbec41a 100644 --- a/src/ui/BookingModal.tsx +++ b/src/ui/BookingModal.tsx @@ -1,14 +1,22 @@ 'use client'; +import { SchedulesProps } from '@/types/activityDetailType'; import MobileModal from './MobileBookingModal'; import TabletModal from './TabletBookingModal'; import useDeviceSize from '@/hooks/useDeviceSize'; -export default function BookingModal() { +export default function BookingModal({ + schedules, + price, +}: { + schedules: SchedulesProps; + price: number; +}) { const device = useDeviceSize(); - if (device === 'mobile') return <MobileModal />; - if (device === 'tablet') return <TabletModal />; + if (device === 'mobile') + return <MobileModal schedules={schedules} price={price} />; + if (device === 'tablet') return <TabletModal schedules={schedules} />; return null; } diff --git a/src/ui/MobileBookingModal.tsx b/src/ui/MobileBookingModal.tsx index 92b5b9a..980d8d7 100644 --- a/src/ui/MobileBookingModal.tsx +++ b/src/ui/MobileBookingModal.tsx @@ -8,8 +8,15 @@ import TimeSelector from '@/components/FloatingBox/TimeSelector'; import BookingButton from '@/components/FloatingBox/BookingButton'; import ParticipantsSelector from '@/components/FloatingBox/ParticipantSelector'; import TotalPriceDisplay from '@/components/FloatingBox/TotalPriceDisplay'; +import { SchedulesProps } from '@/types/activityDetailType'; -export default function MobileModal() { +export default function MobileModal({ + schedules, + price, +}: { + schedules: SchedulesProps; + price: number; +}) { const isOpen = useBookingStore((state) => state.isOpen); const setIsOpen = useBookingStore((state) => state.setIsOpen); @@ -44,7 +51,7 @@ export default function MobileModal() { <Modal.Item className='relative min-h-400'> <div className={step === 'date-time' ? 'block' : 'hidden'}> <div className='flex justify-center'> - <DatePicker /> + <DatePicker schedules={schedules} /> </div> <div className='mt-6'> <h3 className='mb-2 text-sm font-semibold text-gray-900'> @@ -70,7 +77,7 @@ export default function MobileModal() { <div> <p className='font-bold'>인원 {participants}</p> </div> - <TotalPriceDisplay /> + <TotalPriceDisplay price={price} /> </div> </div> </Modal.Item> diff --git a/src/ui/TabletBookingModal.tsx b/src/ui/TabletBookingModal.tsx index 651c60d..8bf7289 100644 --- a/src/ui/TabletBookingModal.tsx +++ b/src/ui/TabletBookingModal.tsx @@ -5,8 +5,13 @@ import DatePicker from '@/components/DatePicker/DatePicker'; import TimeSelector from '@/components/FloatingBox/TimeSelector'; import IconClose from '@assets/svg/close'; import Button from '@/components/Button'; +import { SchedulesProps } from '@/types/activityDetailType'; -export default function TabletModal() { +export default function TabletModal({ + schedules, +}: { + schedules: SchedulesProps; +}) { const isOpen = useBookingStore((state) => state.isOpen); const setIsOpen = useBookingStore((state) => state.setIsOpen); @@ -23,7 +28,7 @@ export default function TabletModal() { <div className='min-h-[400px] w-full'> <div className='mb-4 flex justify-center'> - <DatePicker /> + <DatePicker schedules={schedules} /> </div> <div className='mt-6'>