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
3 changes: 3 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Comment on lines +6 to +8
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

외부 이미지 도메인 설정 승인 및 보안 고려사항

S3 버킷의 이미지 최적화를 위한 올바른 설정입니다.

보안과 환경 분리를 위해 환경변수 사용을 고려해보세요:

  images: {
-    domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'],
+    domains: [process.env.NEXT_PUBLIC_IMAGE_DOMAIN || 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com'],
  },
📝 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
images: {
domains: ['sprint-fe-project.s3.ap-northeast-2.amazonaws.com'],
},
images: {
domains: [process.env.NEXT_PUBLIC_IMAGE_DOMAIN || 'sprint-fe-project.s3.ap-northeast-2.amazonaws.com'],
},
🤖 Prompt for AI Agents
In next.config.ts around lines 6 to 8, the external image domain is hardcoded
for S3 bucket access. To improve security and environment separation, replace
the hardcoded domain with an environment variable. Define the domain in an
environment variable and reference it in the images.domains array to allow
different domains per environment and avoid exposing sensitive info in code.

output: 'standalone',
};

Expand Down
79 changes: 0 additions & 79 deletions src/app/(with-header)/activities/[activitiesId]/page.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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);
Comment on lines +16 to +17
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue

하드코딩된 년도와 월을 현재 날짜로 초기화하세요.

2025년 7월로 하드코딩되어 있어, 다른 시기에 사용할 때 문제가 될 수 있습니다.

-  const [year, setYear] = useState(2025);
-  const [month, setMonth] = useState(7);
+  const [year, setYear] = useState(new Date().getFullYear());
+  const [month, setMonth] = useState(new Date().getMonth() + 1);
📝 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 [year, setYear] = useState(2025);
const [month, setMonth] = useState(7);
const [year, setYear] = useState(new Date().getFullYear());
const [month, setMonth] = useState(new Date().getMonth() + 1);
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx
around lines 16 to 17, the year and month state variables are hardcoded to 2025
and 7. Replace these hardcoded values by initializing year and month with the
current date's year and month using JavaScript's Date object to ensure the
component uses the current date dynamically.

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('니가 작성한 체험임');
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

디버그용 console.log를 제거하세요.

프로덕션 코드에서 디버그 로그는 제거해야 합니다.

-      console.log('니가 작성한 체험임');
📝 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
console.log('니가 작성한 체험임');
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx at
line 40, remove the console.log statement used for debugging to ensure no debug
logs remain in the production code.

} 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,
});
Comment on lines +46 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 (assertive)

복잡한 스케줄 조회 로직을 별도 함수로 분리하는 것을 고려해보세요.

3개월치 스케줄을 조회하는 로직이 복잡합니다. 가독성과 재사용성을 위해 유틸 함수로 분리하는 것을 권장합니다.

별도 유틸 함수 예시:

// utils/scheduleUtils.ts
export const fetchMultipleMonthSchedules = async (
  id: string, 
  year: number, 
  month: number
) => {
  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)}`),
  ]);

  return results
    .filter((r) => r.status === 'fulfilled')
    .flatMap((r) => (r.status === 'fulfilled' ? r.value.data : []));
};
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/ActivityDetailForm.tsx
around lines 46 to 72, the logic for fetching schedules for three months is
complex and embedded directly in the useQuery hook. To improve readability and
reusability, extract this logic into a separate utility function, for example in
a utils/scheduleUtils.ts file. Move the calculation of previous, current, and
next month/year and the Promise.allSettled API calls into this function, then
call this utility function inside the queryFn of useQuery.


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>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions src/app/(with-header)/activities/[id]/components/Skeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@

export function Skeleton({ className = '' }: { className?: string }) {
return <div className={`animate-pulse bg-gray-200 ${className}`} />;
}
Comment on lines +2 to +4
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

기본적인 스켈레톤 컴포넌트가 잘 구현되었습니다.

로딩 상태를 위한 간단하고 효과적인 구현입니다.

더 나은 className 처리와 유연성을 위해 다음과 같은 개선을 고려해보세요:

import { cn } from '@/lib/utils'; // or use clsx

export function Skeleton({ 
  className = '',
  ...props 
}: React.HTMLAttributes<HTMLDivElement>) {
  return (
    <div 
      className={cn("animate-pulse bg-gray-200", className)} 
      {...props}
    />
  );
}

이렇게 하면 더 안전한 className 병합과 추가 HTML 속성 지원이 가능합니다.

🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/Skeleton.tsx lines 2 to 4,
improve the Skeleton component by replacing the manual className string
concatenation with a utility function like cn or clsx for safer class merging,
and extend the component props to accept all standard HTML div attributes by
typing with React.HTMLAttributes<HTMLDivElement> and spreading ...props onto the
div element to support additional HTML properties.

Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
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';
Comment on lines +4 to +8
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

Import 구조 개선 가능

모든 ActivityDropdown 관련 컴포넌트들을 개별적으로 import하는 대신, 더 깔끔한 구조로 정리할 수 있습니다.

-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';
+import { ActivityDetail } from '@/types/activityDetailType';
+import ActivityDropdown from '@/components/ActivityDropdown';

그리고 사용 시에는:

-        <ActivityDropdown>
-          <Trigger>
+        <ActivityDropdown>
+          <ActivityDropdown.Trigger>
             <IconDropdown />
-          </Trigger>
-          <Menu>
-            <Item onClick={() => alert('수정')}>수정하기</Item>
-            <Item onClick={() => alert('삭제')}>삭제하기</Item>
-          </Menu>
+          </ActivityDropdown.Trigger>
+          <ActivityDropdown.Menu>
+            <ActivityDropdown.Item onClick={() => alert('수정')}>수정하기</ActivityDropdown.Item>
+            <ActivityDropdown.Item onClick={() => alert('삭제')}>삭제하기</ActivityDropdown.Item>
+          </ActivityDropdown.Menu>
         </ActivityDropdown>
📝 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
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';
// Before, we had multiple imports for each sub-component:
-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';
// Apply these changes:
+import { ActivityDetail } from '@/types/activityDetailType';
+import ActivityDropdown from '@/components/ActivityDropdown';
// Later in the JSX, replace individual sub-component tags:
-<ActivityDropdown>
- <Trigger>
- <IconDropdown />
- </Trigger>
- <Menu>
- <Item onClick={() => alert('수정')}>수정하기</Item>
- <Item onClick={() => alert('삭제')}>삭제하기</Item>
- </Menu>
-</ActivityDropdown>
+<ActivityDropdown>
+ <ActivityDropdown.Trigger>
+ <IconDropdown />
+ </ActivityDropdown.Trigger>
+ <ActivityDropdown.Menu>
+ <ActivityDropdown.Item onClick={() => alert('수정')}>
+ 수정하기
+ </ActivityDropdown.Item>
+ <ActivityDropdown.Item onClick={() => alert('삭제')}>
+ 삭제하기
+ </ActivityDropdown.Item>
+ </ActivityDropdown.Menu>
+</ActivityDropdown>
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/Title.tsx around lines 4 to
8, the imports for ActivityDropdown and its subcomponents are done individually,
which can be simplified. Refactor the imports by creating an index file inside
the ActivityDropdown folder that exports all related components, then import
them collectively from that index file to improve code cleanliness and
maintainability.


export default function Title({
title,
category,
rating,
reviewCount,
address,
isDropDown,
}: TitleProps) {
isOwner,
}: ActivityDetail) {
return (
<div className='mb-6 flex items-start justify-between'>
<div className='flex flex-col gap-8'>
Expand All @@ -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>
Comment on lines +42 to +43
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

임시 alert 핸들러를 실제 구현으로 교체 필요

현재 수정/삭제 기능이 alert로만 구현되어 있습니다. 실제 기능 구현이 필요합니다.

실제 수정/삭제 API 호출 로직을 구현하시겠습니까? 다음과 같은 구조를 제안드립니다:

const handleEdit = () => {
  // 수정 페이지로 라우팅 또는 모달 열기
  router.push(`/activities/${activityId}/edit`);
};

const handleDelete = async () => {
  if (confirm('정말 삭제하시겠습니까?')) {
    try {
      await deleteActivity(activityId);
      router.push('/activities');
    } catch (error) {
      // 에러 처리
    }
  }
};
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/components/Title.tsx at lines 42-43,
replace the temporary alert handlers for 수정하기 and 삭제하기 with actual
implementations. Implement a handleEdit function that routes to the edit page
for the current activity using router.push. Implement a handleDelete async
function that confirms deletion, calls the deleteActivity API with the
activityId, and on success routes back to the activities list; include error
handling for the API call. Update the onClick handlers of the Items to call
these new functions instead of alert.

</Menu>
</ActivityDropdown>
)}
</div>
);
Expand Down
5 changes: 5 additions & 0 deletions src/app/(with-header)/activities/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import ActivityDetailForm from './components/ActivityDetailForm';

export default function ActivityDetailPage() {
return <ActivityDetailForm />;
}
Comment on lines +1 to +5
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

깔끔한 페이지 구조가 잘 구현되었습니다.

Next.js 페이지 컴포넌트 패턴을 잘 따르고 있으며, 실제 렌더링 로직을 별도 컴포넌트로 분리한 것이 좋습니다.

향후 고려사항으로, ActivityDetailForm에서 발생할 수 있는 에러를 처리하기 위해 Error Boundary를 추가하는 것을 검토해보세요:

import { Suspense } from 'react';
import ActivityDetailForm from './components/ActivityDetailForm';
import { Skeleton } from './components/Skeleton';

export default function ActivityDetailPage() {
  return (
    <Suspense fallback={<Skeleton className="h-screen" />}>
      <ActivityDetailForm />
    </Suspense>
  );
}
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/page.tsx lines 1 to 5, wrap the
ActivityDetailForm component with React's Suspense component to handle loading
states gracefully. Import Suspense from 'react' and a Skeleton fallback
component, then return ActivityDetailForm inside Suspense with the Skeleton as
the fallback prop. This will prepare the page for better error and loading state
management in the future.

Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export function padMonth(m: number): string {
return m.toString().padStart(2, '0');
}
Comment on lines +1 to +3
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

월 포맷팅 유틸리티 함수 개선 제안

기본 로직은 올바르지만 개선할 부분이 있습니다.

함수명과 입력 검증을 개선해보세요:

-export function padMonth(m: number): string {
-  return m.toString().padStart(2, '0');
-}
+export function padMonthWithZero(month: number): string {
+  if (month < 1 || month > 12) {
+    throw new Error('Month must be between 1 and 12');
+  }
+  return month.toString().padStart(2, '0');
+}
📝 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
export function padMonth(m: number): string {
return m.toString().padStart(2, '0');
}
export function padMonthWithZero(month: number): string {
if (month < 1 || month > 12) {
throw new Error('Month must be between 1 and 12');
}
return month.toString().padStart(2, '0');
}
🤖 Prompt for AI Agents
In src/app/(with-header)/activities/[id]/utils/MonthFormatChange.ts lines 1 to
3, the function padMonth lacks input validation and the name could be more
descriptive. Rename the function to clearly indicate it formats a month number
as a two-digit string, and add input validation to ensure the input is a valid
month number (1-12). If the input is invalid, handle it appropriately, such as
throwing an error or returning a default value.

51 changes: 51 additions & 0 deletions src/app/api/activities/[id]/available-schedule/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
52 changes: 52 additions & 0 deletions src/app/api/activities/[id]/reservation/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading