Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

userId != null 조건이 항상 true이므로 제거 가능합니다.

userId prop이 number로 선언되어 있으므로 userId != null 가드는 불필요합니다.

♻️ 수정 제안
-const isOwner = currentUserId != null && userId != null && currentUserId === userId;
+const isOwner = currentUserId != null && currentUserId === userId;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const isOwner = currentUserId != null && userId != null && currentUserId === userId;
const isOwner = currentUserId != null && currentUserId === userId;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx at line
27, The condition userId != null is redundant because the userId prop is typed
as number; update the isOwner calculation by removing that redundant guard so it
reads a single check comparing currentUserId and userId while still guarding
currentUserId (e.g., replace the expression in the isOwner constant with a
null-check only for currentUserId and direct equality to userId); target the
isOwner declaration in BookingSection.tsx and the userId prop usage to implement
this simplification.


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];
Comment on lines +32 to +58
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

현재 월 요청이 이전/다음 월 요청을 블로킹하여 직렬 레이턴시가 발생합니다.

현재 흐름에서 currentResponseawait한 뒤에야 sideResults의 두 요청이 시작됩니다. 세 요청을 Promise.allSettled로 동시에 실행하면 총 소요 시간이 T(현재) + max(T(이전), T(다음))에서 max(T(현재), T(이전), T(다음))으로 단축됩니다. 현재 월 요청 실패 시 전체 쿼리를 실패시키는 기존 동작도 그대로 유지할 수 있습니다.

⚡ 수정 제안: 세 요청 병렬화
-      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 [currentResult, ...sideResults] = await Promise.allSettled([
+        privateInstance.get<GroupedSchedule[]>(
+          `/activities/${activityId}/available-schedule?year=${year}&month=${padMonth(month)}`,
+        ),
+        privateInstance.get<GroupedSchedule[]>(
+          `/activities/${activityId}/available-schedule?year=${prevYear}&month=${padMonth(prevMonth)}`,
+        ),
+        privateInstance.get<GroupedSchedule[]>(
+          `/activities/${activityId}/available-schedule?year=${nextYear}&month=${padMonth(nextMonth)}`,
+        ),
+      ]);
+
+      if (currentResult.status === 'rejected') throw currentResult.reason;

       const sideData = sideResults
         .filter(
           (r): r is PromiseFulfilledResult<AxiosResponse<GroupedSchedule[]>> => r.status === 'fulfilled',
         )
         .flatMap((r) => r.value.data);

-      return [...sideData, ...currentResponse.data];
+      return [...sideData, ...currentResult.value.data];
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx around
lines 32 - 58, The code currently awaits currentResponse before starting
prev/next requests causing serial latency; change to start all three requests
concurrently by creating promises with privateInstance.get for the current month
and the two side months (using activityId and padMonth) before awaiting; then
await them together (e.g., via Promise.allSettled), ensure you check the current
month's promise result first and if it rejected rethrow the error to preserve
previous failure behavior, and finally collect fulfilled side responses
(filtering PromiseFulfilledResult for the two side promises) and merge their
data with the currentResponse.data.

},
enabled: !!activityId && !!year && !!month && !isOwner,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

!!year && !!month 조건이 항상 true이므로 제거 가능합니다.

yearnew Date().getFullYear()(예: 2026), month1~12 범위의 값으로 초기화되어 둘 다 항상 truthy입니다.

♻️ 수정 제안
-    enabled: !!activityId && !!year && !!month && !isOwner,
+    enabled: !!activityId && !isOwner,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
enabled: !!activityId && !!year && !!month && !isOwner,
enabled: !!activityId && !isOwner,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx at line
60, The enabled condition includes redundant checks (!!year && !!month) that are
always truthy since year and month are initialized, so remove them and change
the enabled expression to only depend on activityId and isOwner (e.g., use
activityId presence and !isOwner) in the same object where enabled is defined
(referencing enabled, activityId, year, month, isOwner to locate the line).

});

const handleMonthChange = useCallback((year: number, month: number) => {
setTimeout(() => {
setYear(year);
setMonth(month);
});
}, []);
Comment on lines +63 to +68
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

setTimeout 래핑이 불필요할 수 있습니다.

React 18+에서는 상태 업데이트가 자동으로 배치 처리되므로, setTimeout으로 감싸지 않아도 setYearsetMonth가 하나의 리렌더링으로 처리됩니다. 특별한 이유가 없다면 제거를 고려해 보세요.

♻️ 수정 제안
  const handleMonthChange = useCallback((year: number, month: number) => {
-    setTimeout(() => {
-      setYear(year);
-      setMonth(month);
-    });
+    setYear(year);
+    setMonth(month);
   }, []);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleMonthChange = useCallback((year: number, month: number) => {
setTimeout(() => {
setYear(year);
setMonth(month);
});
}, []);
const handleMonthChange = useCallback((year: number, month: number) => {
setYear(year);
setMonth(month);
}, []);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/components/BookingSection.tsx around
lines 61 - 66, Remove the unnecessary setTimeout wrapper in handleMonthChange
inside BookingSection.tsx: call setYear(year) and setMonth(month) directly (no
delayed callback) since React 18+ batches state updates; keep the useCallback
for handleMonthChange but you can leave the dependency array empty (setState
setters are stable) or include setYear/setMonth if you prefer explicit deps.


if (isOwner) return null;

return (
<div className='md:row-span-2'>
<BookingInterface
schedules={schedulesData ?? []}
onMonthChange={handleMonthChange}
isOwner={isOwner}
price={price}
/>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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)}
/>
Expand All @@ -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)}
/>
Expand All @@ -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'
/>
)}
Expand Down
8 changes: 7 additions & 1 deletion src/app/(with-header)/activities/[id]/components/Title.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,23 @@ 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,
category,
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;
Comment on lines +27 to +30
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

소유자 판별 로직이 여러 컴포넌트에 중복되어 있습니다.

Title.tsx, BookingSection.tsx, ActivityDetailSkeleton.tsx에서 동일한 useUserStore + isOwner 계산 패턴이 반복됩니다. 간단한 커스텀 훅으로 추출하면 일관성과 유지보수성을 높일 수 있습니다.

♻️ 리팩토링 제안
// hooks/useIsOwner.ts
import useUserStore from '@/stores/authStore';

export function useIsOwner(userId: number): boolean {
  const currentUserId = useUserStore((state) =>
    state.user ? state.user.id : null,
  );
  return currentUserId != null && currentUserId === userId;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/components/Title.tsx around lines 27 -
30, Extract the duplicated owner-check logic (currently using useUserStore and
computing currentUserId and isOwner in Title.tsx, BookingSection.tsx, and
ActivityDetailSkeleton.tsx) into a small reusable hook named useIsOwner(userId)
that returns a boolean; implement useIsOwner to read currentUserId from
useUserStore (same selector used today) and return currentUserId != null &&
currentUserId === userId, then replace the inline logic in Title.tsx,
BookingSection.tsx, and ActivityDetailSkeleton.tsx to call useIsOwner(userId)
instead of duplicating the selector and comparison.


const { id } = useParams();
const router = useRouter();
const queryClient = useQueryClient();
Expand Down
72 changes: 72 additions & 0 deletions src/app/(with-header)/activities/[id]/loading.tsx
Original file line number Diff line number Diff line change
@@ -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'>
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

외부 컨테이너의 animate-pulse가 내부 자식 요소의 animate-pulse와 중첩됩니다.

Line 6의 외부 divanimate-pulse가 적용되어 있지만, ReviewCardSkeletonSkeletonBookingInterface 내부 요소들에도 각각 animate-pulse가 개별적으로 적용되어 있습니다. 이로 인해 애니메이션이 중첩되어 의도치 않은 시각적 깜빡임이 발생할 수 있습니다.

외부 animate-pulse를 제거하거나, 내부 컴포넌트의 개별 animate-pulse를 제거하여 한 레벨에서만 적용하는 것을 권장합니다.

🛠️ 수정 제안
-    <div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'>
+    <div className='mx-auto max-w-1200 p-4 sm:px-20 lg:p-8'>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div className='mx-auto max-w-1200 animate-pulse p-4 sm:px-20 lg:p-8'>
<div className='mx-auto max-w-1200 p-4 sm:px-20 lg:p-8'>
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/loading.tsx at line 6, The outer
container div that currently has the class 'animate-pulse' is causing nested
animation with the child skeleton components (ReviewCardSkeleton and
SkeletonBookingInterface); remove the 'animate-pulse' class from the outer div
(the div with className 'mx-auto max-w-1200 ...') so that only the inner
components control their own pulse animation, ensuring no overlapping/duplicated
animation occurs and keeping ReviewCardSkeleton and SkeletonBookingInterface as
the sole owners of their skeleton animation.

{/* 타이틀 */}
<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>
);
}
Comment on lines +4 to +72
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

ActivityDetailSkeleton과의 코드 중복을 고려해 보세요.

src/app/(with-header)/activities/[id]/components/Skeletons/ActivityDetailSkeleton.tsx에 거의 동일한 스켈레톤 마크업이 존재합니다. loading.tsx는 서버 컴포넌트라 소유자 판별이 불가하지만, 공통 레이아웃 부분을 공유 컴포넌트로 추출하면 유지보수성을 높일 수 있습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/app/`(with-header)/activities/[id]/loading.tsx around lines 4 - 72,
Extract the duplicated skeleton markup into a single shared presentational
component (e.g., ActivityDetailSkeleton or SharedActivitySkeleton) and have both
the server Loading() and the existing ActivityDetailSkeleton.tsx consume it;
move purely UI pieces (the grid, placeholders, and repeated ReviewCardSkeleton
usage) into the new shared component and keep client-only bits like
SkeletonBookingInterface or ReviewCardSkeleton usage either inside a small
client wrapper component or passed as slots/props so Loading (a server
component) can render the shared layout without importing client-only modules
directly; update Loading and src/app/.../ActivityDetailSkeleton.tsx to import
and render the new shared component, preserving keys like ReviewCardSkeleton and
SkeletonBookingInterface by wrapping them in a client component if needed.

Loading