diff --git a/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx b/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx deleted file mode 100644 index 3b5cd6c0..00000000 --- a/src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx +++ /dev/null @@ -1,149 +0,0 @@ -'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 { useQuery } from '@tanstack/react-query'; -import { privateInstance } from '@/apis/privateInstance'; -import { useState, useCallback } from 'react'; -import useUserStore from '@/stores/authStore'; -import { padMonth } from '../utils/MonthFormatChange'; -import ReviewSection from './ReviewSection'; -import { AxiosError } from 'axios'; -import { notFound } from 'next/navigation'; - -import ActivityDetailSkeleton from './Skeletons/ActivityDetailSkeleton'; - -export default function ActivityDetailForm() { - const [year, setYear] = useState(new Date().getFullYear()); - const [month, setMonth] = useState(new Date().getMonth() + 1); - - const { id } = useParams(); - - const { - data: activityData, - isLoading, - status, - error, - } = useQuery({ - queryKey: ['activity', id], - queryFn: async () => { - return privateInstance.get(`/activities/${id}`); - }, - select: (response) => response.data, - enabled: !!id, - }); - - if (status === 'error') { - const axiosError = error as AxiosError; - const httpStatus = axiosError.response?.status; - - if (httpStatus === 404) { - console.log('404 에러임'); - notFound(); - } - } - - const currentUserId = useUserStore((state) => - state.user ? state.user.id : null, - ); - const userId = activityData?.userId; - const isOwner = currentUserId && userId && 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, - }); - - const handleMonthChange = useCallback((year: number, month: number) => { - setTimeout(() => { - setYear(year); - setMonth(month); - }); - }, []); - - if (isLoading || !activityData) { - return ; - } - - const subImageUrls = activityData.subImages.map( - (image: { imageUrl: string }) => image.imageUrl, - ); - - return ( -
- - <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='leading-relaxed whitespace-pre-line'> - {activityData.description} - </p> - </div> - - {!isOwner && ( - <div className='md:row-span-2'> - <BookingInterface - schedules={schedulesData ?? []} - onMonthChange={handleMonthChange} - 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} /> - - <ReviewSection - activityId={Number(id)} - reviewCount={activityData.reviewCount} - rating={activityData.rating} - /> - </div> - </div> - </div> - ); -} diff --git a/src/app/(with-header)/activities/[id]/components/BookingSection.tsx b/src/app/(with-header)/activities/[id]/components/BookingSection.tsx new file mode 100644 index 00000000..2a2a94a7 --- /dev/null +++ b/src/app/(with-header)/activities/[id]/components/BookingSection.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { useState, useCallback } from 'react'; +import { useQuery } from '@tanstack/react-query'; +import { AxiosResponse } from 'axios'; +import useUserStore from '@/stores/authStore'; +import BookingInterface from '@/components/FloatingBox/BookingInterface'; +import { privateInstance } from '@/apis/privateInstance'; +import { GroupedSchedule } from '@/types/activityDetailType'; +import { padMonth } from '../utils/MonthFormatChange'; + +export default function BookingSection({ + activityId, + userId, + price, +}: { + activityId: string; + userId: number; + price: number; +}) { + const [year, setYear] = useState(new Date().getFullYear()); + const [month, setMonth] = useState(new Date().getMonth() + 1); + + const currentUserId = useUserStore((state) => + state.user ? state.user.id : null, + ); + const isOwner = currentUserId != null && userId != null && currentUserId === userId; + + const { data: schedulesData } = useQuery({ + queryKey: ['available-schedule', activityId, 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 currentResponse = await privateInstance.get<GroupedSchedule[]>( + `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`, + ); + + + const sideResults = await Promise.allSettled([ + privateInstance.get<GroupedSchedule[]>( + `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`, + ), + privateInstance.get<GroupedSchedule[]>( + `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`, + ), + ]); + + const sideData = sideResults + .filter( + (r): r is PromiseFulfilledResult<AxiosResponse<GroupedSchedule[]>> => r.status === 'fulfilled', + ) + .flatMap((r) => r.value.data); + + return [...sideData, ...currentResponse.data]; + }, + enabled: !!activityId && !!year && !!month && !isOwner, + }); + + const handleMonthChange = useCallback((year: number, month: number) => { + setTimeout(() => { + setYear(year); + setMonth(month); + }); + }, []); + + if (isOwner) return null; + + return ( + <div className='md:row-span-2'> + <BookingInterface + schedules={schedulesData ?? []} + onMonthChange={handleMonthChange} + isOwner={isOwner} + price={price} + /> + </div> + ); +} diff --git a/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx b/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx index a993a3d0..9adfc0ad 100644 --- a/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx +++ b/src/app/(with-header)/activities/[id]/components/ImageGrid.tsx @@ -67,9 +67,9 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { src={image[currentIndex]} alt={`${currentIndex + 1}`} fill + sizes='100vw' className='rounded-lg object-cover' priority - unoptimized onError={() => handleImageError(currentIndex)} /> </motion.div> @@ -113,6 +113,8 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { src={image[0]} alt='메인이미지' fill + sizes='50vw' + priority className='rounded-lg object-cover' onError={() => handleImageError(0)} /> @@ -127,6 +129,7 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { src={image} alt={`서브이미지 ${index + 1}`} fill + sizes='25vw' className='rounded-lg object-cover' onError={() => handleImageError(index + 1)} /> @@ -145,6 +148,8 @@ function ImageGrid({ mainImage, subImages }: ImageGridProps) { src={selectedImage} alt='확대 이미지' fill + sizes='(max-width: 1200px) 100vw, 1200px' + quality={85} className='rounded-lg object-cover p-18' /> )} diff --git a/src/app/(with-header)/activities/[id]/components/Title.tsx b/src/app/(with-header)/activities/[id]/components/Title.tsx index 4f618076..c2c5d5d4 100644 --- a/src/app/(with-header)/activities/[id]/components/Title.tsx +++ b/src/app/(with-header)/activities/[id]/components/Title.tsx @@ -12,6 +12,7 @@ import { useQueryClient } from '@tanstack/react-query'; import { useDeleteActivity } from '../hooks/useDeleteActivity'; import Popup from '@/components/Popup'; import { TitleProps } from '@/types/activityDetailType'; +import useUserStore from '@/stores/authStore'; function Title({ title, @@ -19,10 +20,15 @@ function Title({ rating, reviewCount, address, - isOwner, + userId, }: TitleProps) { const [isPopupOpen, setIsPopupOpen] = useState(false); + const currentUserId = useUserStore((state) => + state.user ? state.user.id : null, + ); + const isOwner = currentUserId != null && userId != null && currentUserId === userId; + const { id } = useParams(); const router = useRouter(); const queryClient = useQueryClient(); diff --git a/src/app/(with-header)/activities/[id]/loading.tsx b/src/app/(with-header)/activities/[id]/loading.tsx new file mode 100644 index 00000000..c908f291 --- /dev/null +++ b/src/app/(with-header)/activities/[id]/loading.tsx @@ -0,0 +1,72 @@ +import ReviewCardSkeleton from './components/Skeletons/ReviewCardSkeleton'; +import SkeletonBookingInterface from './components/Skeletons/BookingInterfaceSkeleton'; + +export default function Loading() { + return ( + <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'> + {/* 타이틀 */} + <div className='mb-6 flex items-start justify-between'> + <div className='flex w-full flex-col gap-10'> + <div className='h-16 w-24 rounded bg-gray-300' /> + <div className='h-42 w-3/4 rounded bg-gray-300' /> + <div className='flex gap-10'> + <div className='h-20 w-50 rounded bg-gray-300' /> + <div className='h-20 w-170 rounded bg-gray-300' /> + </div> + </div> + </div> + + {/* 이미지그리드 */} + <div className='relative block aspect-square h-[300px] w-full overflow-hidden rounded-lg bg-gray-300 md:hidden' /> + <div className='hidden h-[500px] grid-cols-4 grid-rows-4 gap-6 md:grid'> + <div className='col-span-2 row-span-4 rounded-lg bg-gray-300' /> + {[...Array(4)].map((_, i) => ( + <div + key={i} + className='col-span-1 row-span-2 rounded-lg bg-gray-300' + /> + ))} + </div> + + {/* 설명/예약인터페이스/장소 */} + <div className='mt-86 grid gap-10 grid-cols-1 md:grid-cols-3'> + {/* 설명 */} + <div className='md:col-span-2'> + <div className='mb-10 h-34 w-90 rounded bg-gray-300' /> + <div className='mb-4 h-180 w-full rounded bg-gray-300' /> + </div> + + {/* 예약인터페이스 */} + <div className='md:row-span-2'> + <SkeletonBookingInterface /> + </div> + + {/* 체험 장소/리뷰 */} + <div className='md:col-span-2 space-y-8'> + {/* 장소 */} + <div className='mb-40'> + <div className='mb-10 h-34 w-90 rounded bg-gray-300' /> + <div className='h-[480px] w-full rounded-lg bg-gray-400 shadow-md' /> + <div className='mt-8 flex items-center space-x-3'> + <div className='h-6 w-6 rounded-full bg-gray-300' /> + <div className='h-20 w-1/2 rounded bg-gray-300' /> + </div> + </div> + + {/* 리뷰 */} + <div> + <div className='mt-10 flex flex-col space-y-8'> + <div className='mb-10 h-34 w-50 rounded bg-gray-300' /> + <div className='mb-5 h-50 w-120 rounded bg-gray-300' /> + <div className='relative min-h-450 flex-col gap-30'> + {[...Array(3)].map((_, index) => ( + <ReviewCardSkeleton key={index} /> + ))} + </div> + </div> + </div> + </div> + </div> + </div> + ); +} diff --git a/src/app/(with-header)/activities/[id]/page.tsx b/src/app/(with-header)/activities/[id]/page.tsx index f62da97b..02732bb4 100644 --- a/src/app/(with-header)/activities/[id]/page.tsx +++ b/src/app/(with-header)/activities/[id]/page.tsx @@ -1,5 +1,81 @@ -import ActivityDetailForm from './components/ActivityDetailForm'; +import { notFound } from 'next/navigation'; +import Title from './components/Title'; +import ImageGrid from './components/ImageGrid'; +import BookingSection from './components/BookingSection'; +import LocationMap from '@/components/LocationMap'; +import ReviewSection from './components/ReviewSection'; +import { ActivityDetail } from '@/types/activityDetailType'; -export default function ActivityDetailPage() { - return <ActivityDetailForm />; +export default async function ActivityDetailPage({ + params, +}: { + params: Promise<{ id: string }>; +}) { + const { id } = await params; + + let activityData: ActivityDetail; + + try { + const res = await fetch( + `${process.env.NEXT_PUBLIC_API_SERVER_URL}/activities/${id}`, + { next: { tags: [`activity-${id}`] } }, + ); + + if (!res.ok) { + if (res.status === 404) notFound(); + throw new Error('활동 상세 데이터 조회 실패'); + } + + activityData = await res.json(); + } catch (error) { + if (error instanceof Error && 'digest' in error) throw error; + throw new Error('활동 상세 데이터 조회 실패'); + } + + const subImageUrls = activityData.subImages.map( + (image) => image.imageUrl, + ); + + return ( + <div className='mx-auto mt-30 max-w-1200 p-4 px-20 sm:px-20 lg:p-8'> + <Title + title={activityData.title} + category={activityData.category} + rating={activityData.rating} + reviewCount={activityData.reviewCount} + address={activityData.address ?? ''} + userId={activityData.userId} + /> + <ImageGrid + mainImage={activityData.bannerImageUrl} + subImages={subImageUrls} + /> + + <div className='mt-86 grid gap-15 grid-cols-1 md:grid-cols-3'> + <div className='md:col-span-2'> + <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 설명</h2> + <p className='leading-relaxed whitespace-pre-line'> + {activityData.description} + </p> + </div> + + <BookingSection + activityId={id} + userId={activityData.userId} + price={activityData.price} + /> + + <div className='md:col-span-2'> + <h2 className='mb-4 pb-2 text-2xl font-bold'>체험 장소</h2> + <LocationMap address={activityData.address ?? ''} /> + + <ReviewSection + activityId={Number(id)} + reviewCount={activityData.reviewCount} + rating={activityData.rating} + /> + </div> + </div> + </div> + ); } diff --git a/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts index 9a79d610..03a69fe5 100644 --- a/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts +++ b/src/app/(with-header)/myactivity/[id]/hooks/useEditActivityForm.ts @@ -184,6 +184,9 @@ export const useEditActivityForm = () => { onSuccess: () => { toast.success('수정되었습니다!'); queryClient.invalidateQueries({ queryKey: ['activity', id] }); + queryClient.invalidateQueries({ + queryKey: ['available-schedule', id], + }); queryClient.invalidateQueries({ queryKey: ['experiences'] }); queryClient.invalidateQueries({ queryKey: ['popularExperiences'] }); router.push(`/activities/${id}`); diff --git a/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts index e21806d6..7a997877 100644 --- a/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts +++ b/src/app/(with-header)/myactivity/hooks/useCreateActivityForm.ts @@ -36,6 +36,23 @@ export const useCreateActivityForm = () => { throw new Error('유효한 가격을 입력해주세요.'); } + let bannerImageUrl = ''; + if (typeof mainImage === 'string') { + bannerImageUrl = mainImage; + } else if (mainImage instanceof File) { + bannerImageUrl = await uploadImage(mainImage); + } + + const subImageUrls: string[] = []; + for (const img of subImage) { + if (img instanceof File) { + const url = await uploadImage(img); + subImageUrls.push(url); + } else if (typeof img === 'string') { + subImageUrls.push(img); + } + } + const payload = { title, category, @@ -43,8 +60,8 @@ export const useCreateActivityForm = () => { address, price: parsedPrice, schedules: dates, - bannerImageUrl: mainImage, - subImageUrls: subImage, + bannerImageUrl, + subImageUrls, }; const res = await privateInstance.post('/addActivity', payload); @@ -89,30 +106,18 @@ export const useCreateActivityForm = () => { ); }; - const handleMainImageSelect = async (file: File) => { - try { - const url = await uploadImage(file); - setMainImage(url); - } catch { - toast.error('메인 이미지 업로드에 실패했습니다.'); - } + const handleMainImageSelect = (file: File) => { + setMainImage(file); }; const handleMainImageRemove = () => { setMainImage(null); }; - const handleSubImagesAdd = async (newFiles: File[]) => { + const handleSubImagesAdd = (newFiles: File[]) => { const remaining = 4 - subImage.length; - const filesToUpload = newFiles.slice(0, remaining); - try { - const uploadedUrls = await Promise.all( - filesToUpload.map((file) => uploadImage(file)), - ); - setSubImage((prev) => [...prev, ...uploadedUrls]); - } catch { - toast.error('서브 이미지 업로드 중 문제가 발생했습니다.'); - } + const filesToAdd = newFiles.slice(0, remaining); + setSubImage((prev) => [...prev, ...filesToAdd]); }; const handleSubImageRemove = (index: number) => { diff --git a/src/app/api/deleteActivity/[id]/route.ts b/src/app/api/deleteActivity/[id]/route.ts index d70aafa7..73ca4c15 100644 --- a/src/app/api/deleteActivity/[id]/route.ts +++ b/src/app/api/deleteActivity/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { revalidateTag } from 'next/cache'; import axios, { AxiosError } from 'axios'; const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; @@ -38,6 +39,7 @@ export async function DELETE( }, ); + revalidateTag(`activity-${id}`); return NextResponse.json(response.data, { status: 200 }); } catch (error: unknown) { console.error('체험 삭제 에러:', error); diff --git a/src/app/api/editActivity/[id]/route.ts b/src/app/api/editActivity/[id]/route.ts index 021a2479..2960c1be 100644 --- a/src/app/api/editActivity/[id]/route.ts +++ b/src/app/api/editActivity/[id]/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { revalidateTag } from 'next/cache'; import axios, { AxiosError } from 'axios'; const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; @@ -36,6 +37,7 @@ export async function PATCH( }, ); + revalidateTag(`activity-${id}`); return NextResponse.json(response.data, { status: 200 }); } catch (error: unknown) { console.error('체험 수정 에러:', error); diff --git a/src/app/api/reservations/[id]/reviews/route.ts b/src/app/api/reservations/[id]/reviews/route.ts index a10625be..95f38d8d 100644 --- a/src/app/api/reservations/[id]/reviews/route.ts +++ b/src/app/api/reservations/[id]/reviews/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from 'next/server'; import { cookies } from 'next/headers'; +import { revalidateTag } from 'next/cache'; import axios from 'axios'; const BACKEND_BASE_URL = process.env.NEXT_PUBLIC_API_SERVER_URL; @@ -38,6 +39,9 @@ export async function POST( }, ); + if (response.data?.activityId) { + revalidateTag(`activity-${response.data.activityId}`); + } return NextResponse.json(response.data); } catch (error) { if (axios.isAxiosError(error)) { diff --git a/src/components/DatePicker/CalendarHeader.tsx b/src/components/DatePicker/CalendarHeader.tsx index d27a6b74..86dce1f5 100644 --- a/src/components/DatePicker/CalendarHeader.tsx +++ b/src/components/DatePicker/CalendarHeader.tsx @@ -5,12 +5,18 @@ import { CalendarHeaderProps } from '@/types/datePickerTypes'; export default function CalendarHeader({ viewDate, onMonthChange, + isPrevDisabled, }: CalendarHeaderProps) { return ( <div className='mb-6 flex items-center justify-between'> <button - className='flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white text-gray-600 transition-all duration-200 hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none' + className={`flex h-9 w-9 items-center justify-center rounded-lg border border-gray-200 bg-white transition-all duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 focus:outline-none ${ + isPrevDisabled + ? 'cursor-not-allowed text-gray-300' + : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900 hover:shadow-sm' + }`} onClick={() => onMonthChange('subtract')} + disabled={isPrevDisabled} > ◀ </button> diff --git a/src/components/DatePicker/DatePicker.tsx b/src/components/DatePicker/DatePicker.tsx index 82cd0283..0093113d 100644 --- a/src/components/DatePicker/DatePicker.tsx +++ b/src/components/DatePicker/DatePicker.tsx @@ -64,7 +64,12 @@ export default function DatePicker({ console.log('뷰데이트', viewDate.format('YYYY-MM-DD')); }, [availableDates, viewDate]); + const isPrevDisabled = + viewDate.year() === today.year() && viewDate.month() === today.month(); + const changeMonth = (direction: 'add' | 'subtract') => { + if (direction === 'subtract' && isPrevDisabled) return; + setViewDate((prev) => { const newDate = direction === 'add' ? prev.add(1, 'month') : prev.subtract(1, 'month'); @@ -83,7 +88,7 @@ export default function DatePicker({ return ( <div className='max-h-[746px] w-full max-w-md rounded-2xl bg-white p-6'> - <CalendarHeader viewDate={viewDate} onMonthChange={changeMonth} /> + <CalendarHeader viewDate={viewDate} onMonthChange={changeMonth} isPrevDisabled={isPrevDisabled} /> <CalendarBody viewDate={viewDate} today={today} diff --git a/src/hooks/useBookingMutation.ts b/src/hooks/useBookingMutation.ts deleted file mode 100644 index e69de29b..00000000 diff --git a/src/types/activityDetailType.ts b/src/types/activityDetailType.ts index 8fbe42f0..5a32ec5f 100644 --- a/src/types/activityDetailType.ts +++ b/src/types/activityDetailType.ts @@ -11,14 +11,7 @@ export interface ReviewCardProps { isBlured?: boolean; } -export interface TitleProps { - title: string; - category: string; - rating: number; - reviewCount: number; - address: string; - isDropDown?: boolean; -} + export interface ActivitySchedule { id: number; @@ -93,7 +86,7 @@ export interface TitleProps { rating: number; reviewCount: number; address: string; - isOwner: boolean; + userId: number; } export interface ReviewTitleProps { diff --git a/src/types/datePickerTypes.ts b/src/types/datePickerTypes.ts index f64e0377..317ac9b3 100644 --- a/src/types/datePickerTypes.ts +++ b/src/types/datePickerTypes.ts @@ -11,4 +11,5 @@ export interface CalendarBodyProps { export interface CalendarHeaderProps { viewDate: dayjs.Dayjs; onMonthChange: (direction: 'add' | 'subtract') => void; + isPrevDisabled?: boolean; }