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 (
-
-
-
-
-
-
-
체험 설명
-
- {activityData.description}
-
-
-
- {!isOwner && (
-
-
-
- )}
-
-
-
체험 장소
-
-
-
-
-
-
- );
-}
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(
+ `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`,
+ );
+
+
+ const sideResults = await Promise.allSettled([
+ privateInstance.get(
+ `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`,
+ ),
+ privateInstance.get(
+ `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`,
+ ),
+ ]);
+
+ const sideData = sideResults
+ .filter(
+ (r): r is PromiseFulfilledResult> => 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 (
+
+
+
+ );
+}
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)}
/>
@@ -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 (
+
+ {/* 타이틀 */}
+
+
+ {/* 이미지그리드 */}
+
+
+
+ {[...Array(4)].map((_, i) => (
+
+ ))}
+
+
+ {/* 설명/예약인터페이스/장소 */}
+
+ {/* 설명 */}
+
+
+ {/* 예약인터페이스 */}
+
+
+
+
+ {/* 체험 장소/리뷰 */}
+
+ {/* 장소 */}
+
+
+ {/* 리뷰 */}
+
+
+
+
+
+ {[...Array(3)].map((_, index) => (
+
+ ))}
+
+
+
+
+
+
+ );
+}
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 ;
+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 (
+
+
+
+
+
+
+
체험 설명
+
+ {activityData.description}
+
+
+
+
+
+
+
체험 장소
+
+
+
+
+
+
+ );
}
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 (
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 (
-
+
void;
+ isPrevDisabled?: boolean;
}