Repository: codeit-globalnomad/globalnomad
Pull request #67: [#47] feat/체험상세페이지 구조화
Author: login: llllliii88iiilllll
Labels: name: Priority : Normal 🔵
description: 우선 순위 보통
name: Type : Feature ✨
description: 새로운 기능 추가
Created: 2025-03-24T04:07:27Z | Merged: 2025-03-24T12:15:35Z

## :hash: Issue

- #47 
## :memo: Description

- 체험 상세 페이지의 부모 레이아웃과 사용될 컴포넌트를 분리하여 생성했습니다.
- api 연결을 위해 기존 page.tsx 로직을 ActivityPage.tsx로 이동하였고, page.tsx는 서버 컴포넌트로 분리하였습니다.
- 부모 페이지([Id]/page.tsx)에서 데스크탑, 태블릿, 모바일 등 세 가지 경우에 맞는 레이아웃을 구현했습니다.
![image](https://github.com/user-attachments/assets/78ae9bbf-cc7d-47e7-82d8-b1c8fe17f9c4)
![image](https://github.com/user-attachments/assets/4655e616-3a6d-4986-9ff0-154b1dda6d2b)
![image](https://github.com/user-attachments/assets/33999285-ce30-46ba-917b-f85bada9ee6b)
- intersectionObserver를 커스텀 훅으로 분리하여 작성했습니다.
- 상단 탭을 클릭하면 해당 영역으로 이동하고, 스크롤 시 해당 영역의 탭이 활성화되도록 구현했습니다.
- 새로고침 시 페이지가 무조건 상단으로 이동하도록 구현하였습니다.
: 스크롤시 탭 활성화 부분도 연결이 되어 있어, 탭 영역에서 새로고침시 탭 활성화가 체험 설명 -> 해당 영역으로 이동하는 이슈 발견
: 새로고침시 페이지를 상단으로 이동시키면 스크롤이 아래에서 위로 이동하는 이슈 발견
: 로딩 스피너를 구현하여 로딩 스피너 영역을 calc(100vh - 230px)으로 설정하여 스크롤 움직임 없게 함
- 푸터의 mt-[40%] 값을 삭제했습니다.

## :white_check_mark: Checklist

### PR Checklist

<!-- PR이 다음 요구 사항을 충족하는지 확인하세요. -->

- [x] Base Branch 확인
- [x] 커밋 메시지 컨벤션 준수
- [x] Assignee 및 Reviewer, 적절한 Label 지정

### Additional Notes

<!-- 추가 사항이 있을 경우, 작성해 주세요. -->

- [x] (없음)



Diff:
diff --git a/package-lock.json b/package-lock.json
index 8b836e5f..3f4aef6d 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -13729,6 +13729,7 @@
           "url": "http://opencollective.com/swiper"
         }
       ],
+      "license": "MIT",
       "engines": {
         "node": ">= 4.7.0"
       }
diff --git a/src/app/(with-nav-footer)/activities/[id]/.gitkeep b/src/app/(with-nav-footer)/activities/[id]/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityBanner.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityBanner.tsx
new file mode 100644
index 00000000..0378d1be
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityBanner.tsx
@@ -0,0 +1,15 @@
+export default function ActivityBanner() {
+  // const [category, setCategory] = useState<string | null>(null);
+  // const [emoji, setEmoji] = useState<string | null>(null);
+
+  return (
+    <>
+      <div className='pt-[40px] md:pt-[50px]'></div>
+      <h3 className='text-xl font-bold'>
+        {/* {emoji} {category ? `${category} 추천 체험` : '로딩 중...'} */}
+        카테고리 추천 체험
+      </h3>
+      <div className='h-[140px] w-full rounded-[12px] bg-gray-50'>배너</div>
+    </>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescription.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescription.tsx
new file mode 100644
index 00000000..1b944797
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescription.tsx
@@ -0,0 +1,7 @@
+export default function ActivityDescription() {
+  return (
+    <>
+      <p className='h-[300px] w-full bg-gray-50'>설명</p>
+    </>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescriptionSection.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescriptionSection.tsx
new file mode 100644
index 00000000..3a37d07d
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityDescriptionSection.tsx
@@ -0,0 +1,12 @@
+import { useRef } from 'react';
+import ActivityDescription from './ActivityDescription';
+
+export default function DescriptionSection() {
+  const descriptionRef = useRef<HTMLDivElement>(null);
+  return (
+    <div id='description' ref={descriptionRef}>
+      <div className='pt-[40px] md:pt-[50px]'></div>
+      <ActivityDescription />
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityGallery.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityGallery.tsx
new file mode 100644
index 00000000..1df050de
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityGallery.tsx
@@ -0,0 +1,3 @@
+export default function ActivityGallery() {
+  return <div className='h-[410px] w-full bg-gray-50 md:h-[540px] md:rounded-lg lg:h-[550px]'></div>;
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityHeader.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityHeader.tsx
new file mode 100644
index 00000000..e0a1eb6c
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityHeader.tsx
@@ -0,0 +1,31 @@
+import Image from 'next/image';
+import starRating from '@/assets/icons/star-rating.svg';
+import marker from '@/assets/icons/marker.svg';
+// import Dropdown from "@/components/Dropdown";
+// import Modal from "@/components/Modal"
+
+export default function ActivityHeader() {
+  return (
+    <div className='flex flex-col gap-2'>
+      <span className='text-md font-regular opacity-75'>카테고리명</span>
+      <div className='flex justify-between'>
+        <h1 className='w-[80%] text-2xl font-bold md:text-3xl'>체험 제목</h1>
+        {/* 공유하기 <Modal /> */}
+        {/* 더보기 <Dropdown /> */}
+      </div>
+      <div className='align-center flex flex-row gap-4'>
+        {/* Rating */}
+        <div className='font-regular text-md flex gap-1 md:text-lg'>
+          <Image src={starRating} alt='별점 아이콘' />
+          <span>평균 별점</span>
+          <span>(후기 개수)</span>
+        </div>
+        {/* Address */}
+        <div className='text-md font-regular flex gap-1 md:text-lg'>
+          <Image src={marker} alt='지도 마커 아이콘' />
+          <span className='opacity-75'>주소</span>
+        </div>
+      </div>
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocation.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocation.tsx
new file mode 100644
index 00000000..d2fcf8f1
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocation.tsx
@@ -0,0 +1,14 @@
+import marker from '@/assets/icons/marker.svg';
+import Image from 'next/image';
+export default function ActivityLocation() {
+  return (
+    <>
+      <div className='h-[300px] rounded-[12px] bg-gray-50 md:h-[310px] lg:h-[450px]'></div>
+      <div className='md:text-2lg flex gap-1 text-lg'>
+        <Image src={marker} alt='지도 마커 아이콘' />
+        <span className='font-regular flex gap-1 opacity-75'>주소</span>
+        <button className='text-blue-100 underline'>복사</button>
+      </div>
+    </>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocationSection.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocationSection.tsx
new file mode 100644
index 00000000..efa5bd7c
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityLocationSection.tsx
@@ -0,0 +1,15 @@
+import { useRef } from 'react';
+import ActivityLocation from './ActivityLocation';
+
+export default function LocationSection() {
+  const locationRef = useRef<HTMLDivElement>(null);
+  return (
+    <div id='location' ref={locationRef}>
+      <div className='pt-[40px] md:pt-[50px]'></div>
+      <div className='flex flex-col gap-3'>
+        <h3 className='text-2lg font-bold md:text-xl'>위치 안내</h3>
+        <ActivityLocation />
+      </div>
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/DesktopReservation.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/DesktopReservation.tsx
new file mode 100644
index 00000000..dddbdea8
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/DesktopReservation.tsx
@@ -0,0 +1,3 @@
+export default function DesktopReservation() {
+  return <div className='hidden bg-gray-50 md:hidden lg:block'>데스크탑 예약창</div>;
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/MobileReservation.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/MobileReservation.tsx
new file mode 100644
index 00000000..44b6991d
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/MobileReservation.tsx
@@ -0,0 +1,3 @@
+export default function MobileReservation() {
+  return <div className='h-[84px] rounded-t-[12px] border-t-1 border-gray-200 bg-white md:hidden'>모바일 예약창</div>;
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/TabletReservation.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/TabletReservation.tsx
new file mode 100644
index 00000000..cee20e69
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/TabletReservation.tsx
@@ -0,0 +1,3 @@
+export default function TabletReservation() {
+  return <div className='hidden bg-gray-50 md:block lg:hidden'>태블릿 예약창</div>;
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/index.ts b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/index.ts
new file mode 100644
index 00000000..5662cb53
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReservation/index.ts
@@ -0,0 +1,3 @@
+export { default as MobileReservation } from './MobileReservation';
+export { default as TabletReservation } from './TabletReservation';
+export { default as DesktopReservation } from './DesktopReservation';
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviews.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviews.tsx
new file mode 100644
index 00000000..eacc5df3
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviews.tsx
@@ -0,0 +1,9 @@
+export default function ActivityReviews() {
+  return (
+    <>
+      <div className='h-[600px] bg-gray-50'>
+        <p className=''>리뷰</p>
+      </div>
+    </>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviewsSection.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviewsSection.tsx
new file mode 100644
index 00000000..d263b70b
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityReviewsSection.tsx
@@ -0,0 +1,23 @@
+import { useRef } from 'react';
+import Image from 'next/image';
+import starRating from '@/assets/icons/star-rating.svg';
+import ActivityReviews from './ActivityReviews';
+
+export default function ReviewsSection() {
+  const reviewsRef = useRef<HTMLDivElement>(null);
+  return (
+    <div id='reviews' ref={reviewsRef}>
+      <div className='pt-[40px] md:pt-[50px]'></div>
+      <div className='flex flex-col gap-3'>
+        <div className='flex items-center justify-between'>
+          <h3 className='text-2lg font-bold md:text-xl'>체험 후기 000개</h3>
+          <div className='flex gap-1'>
+            <Image src={starRating} alt='별점 아이콘' />
+            <span className='md:text-2lg text-lg'>매우 만족</span>
+          </div>
+        </div>
+        <ActivityReviews />
+      </div>
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityTab.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityTab.tsx
new file mode 100644
index 00000000..5e7497f5
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ActivityTab.tsx
@@ -0,0 +1,31 @@
+interface ActivityTabProps {
+  tabs: { label: string; targetId: string }[];
+  currentTab: string;
+  onTabClick: (targetId: string) => void;
+}
+
+export default function ActivityTab({ tabs, currentTab, onTabClick }: ActivityTabProps) {
+  const handleTabClick = (targetId: string) => {
+    const element = document.getElementById(targetId);
+    if (element) {
+      element.scrollIntoView({ behavior: 'smooth', block: 'start' });
+    }
+    onTabClick(targetId);
+  };
+
+  return (
+    <div className='align-center flex h-[40px] justify-around border-b-1 border-b-gray-400 md:h-[50px]'>
+      {tabs.map((tab) => (
+        <button
+          key={tab.targetId}
+          onClick={() => handleTabClick(tab.targetId)}
+          className={`md:text-2lg w-1/3 cursor-pointer text-lg ${
+            currentTab === tab.targetId ? 'border-b-2 border-gray-900 font-bold' : 'text-gray-700'
+          }`}
+        >
+          {tab.label}
+        </button>
+      ))}
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/LoadingSpinner.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/LoadingSpinner.tsx
new file mode 100644
index 00000000..dd4b4f46
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/LoadingSpinner.tsx
@@ -0,0 +1,15 @@
+const LoadingSpinner = () => (
+  <div
+    className='flex items-center justify-center'
+    style={{
+      minHeight: 'calc(100vh - 230px)',
+      position: 'relative',
+    }}
+  >
+    <div className='absolute inset-0 top-[-70px] flex items-center justify-center'>
+      <div className='spinner-border h-16 w-16 animate-spin rounded-full border-4 border-solid border-green-100 border-t-transparent'></div>
+    </div>
+  </div>
+);
+
+export default LoadingSpinner;
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ScrollToTopButton.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ScrollToTopButton.tsx
new file mode 100644
index 00000000..92dec778
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/_components/ScrollToTopButton.tsx
@@ -0,0 +1,33 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import Image from 'next/image';
+import arrowTop from '@/assets/icons/top-arrow.svg';
+
+type ScrollToTopButtonProps = {
+  onClick: () => void;
+};
+
+export default function ScrollToTopButton({ onClick }: ScrollToTopButtonProps) {
+  const [showButton, setShowButton] = useState(false);
+
+  useEffect(() => {
+    const handleScroll = () => {
+      setShowButton(window.scrollY > 200);
+    };
+
+    window.addEventListener('scroll', handleScroll);
+    return () => window.removeEventListener('scroll', handleScroll);
+  }, []);
+
+  return (
+    showButton && (
+      <button
+        onClick={onClick}
+        className='fixed right-5 bottom-[70px] z-70 cursor-pointer rounded-full border-1 border-gray-200 bg-white p-3 md:bottom-5'
+      >
+        <Image src={arrowTop} alt='상단으로 이동' className='h-[16px] w-[16px]' />
+      </button>
+    )
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/index.tsx b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/index.tsx
new file mode 100644
index 00000000..10ef66a1
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/ActivityDetail/index.tsx
@@ -0,0 +1,101 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { ActivityDetailResponse } from '@/lib/types/activities';
+import { useIntersectionObserver } from '@/lib/utils/useIntersectionObserver';
+import ActivityHeader from './_components/ActivityHeader';
+import ActivityGallery from './_components/ActivityGallery';
+import ActivityTab from './_components/ActivityTab';
+import DescriptionSection from './_components/ActivityDescriptionSection';
+import LocationSection from './_components/ActivityLocationSection';
+import ReviewsSection from './_components/ActivityReviewsSection';
+import { MobileReservation, TabletReservation, DesktopReservation } from './_components/ActivityReservation';
+import ActivityBanner from './_components/ActivityBanner';
+import ScrollToTopButton from './_components/ScrollToTopButton';
+import LoadingSpinner from './_components/LoadingSpinner';
+
+const wrapper = 'mt-3 flex w-full flex-col gap-4 md:gap-6 max-w-[1200px]';
+const tabItems = [
+  { label: '체험 설명', targetId: 'description' },
+  { label: '위치', targetId: 'location' },
+  { label: '후기', targetId: 'reviews' },
+];
+
+export default function ActivityDetailPage({ activityDetail }: { activityDetail: ActivityDetailResponse }) {
+  console.log(activityDetail);
+  const [currentTab, setCurrentTab] = useState('description');
+  const [isLoading, setIsLoading] = useState(true);
+  const [hasReservationSection, setHasReservationSection] = useState(false);
+
+  // 임의의 더미 데이터
+  const currentUserId = 1; // 현재 로그인된 유저 ID
+  const activityCreatorId = 2; // 활동 생성자 ID
+
+  useIntersectionObserver((id) => setCurrentTab(id));
+
+  const scrollToTop = () => {
+    window.requestAnimationFrame(() => {
+      window.scrollTo({ top: 0, behavior: 'smooth' });
+    });
+    setCurrentTab('description');
+  };
+
+  useEffect(() => {
+    if (Number(currentUserId) !== Number(activityCreatorId)) {
+      setHasReservationSection(true);
+    }
+
+    scrollToTop();
+    const timer = setTimeout(() => {
+      setIsLoading(false);
+    }, 500);
+
+    return () => clearTimeout(timer);
+  }, []);
+
+  if (isLoading) {
+    return <LoadingSpinner />;
+  }
+
+  return (
+    <div className='relative flex flex-col items-center justify-center scroll-smooth'>
+      <div className={`${wrapper} flex-col px-5 lg:mt-10`}>
+        <section className='flex flex-col gap-4'>
+          <ActivityHeader />
+        </section>
+      </div>
+      <div className={`${wrapper} flex-col md:px-5`}>
+        <ActivityGallery />
+      </div>
+      <div className={`md:${wrapper} px-5 md:flex-row md:gap-[2%] lg:mb-16`}>
+        <section className={`mt-6 mb-6 w-full ${hasReservationSection ? 'md:w-[70%]' : 'md:w-full'}`}>
+          <div className='sticky top-0 z-10 bg-white'>
+            <ActivityTab
+              tabs={tabItems}
+              currentTab={currentTab}
+              onTabClick={(id) => {
+                setCurrentTab(id);
+              }}
+            />
+          </div>
+          <div className='w-full'>
+            <DescriptionSection />
+            <LocationSection />
+            <ReviewsSection />
+            <ActivityBanner />
+          </div>
+        </section>
+        {hasReservationSection && (
+          <section className='md: fixed bottom-0 left-0 z-50 w-full md:relative md:top-0 md:right-0 md:w-[28%]'>
+            <MobileReservation />
+            <div className='sticky top-3 md:mt-6 md:mb-3'>
+              <TabletReservation />
+              <DesktopReservation />
+            </div>
+          </section>
+        )}
+      </div>
+      <ScrollToTopButton onClick={scrollToTop} />
+    </div>
+  );
+}
diff --git a/src/app/(with-nav-footer)/activities/[id]/_compontents/.gitkeep b/src/app/(with-nav-footer)/activities/[id]/_compontents/.gitkeep
deleted file mode 100644
index e69de29b..00000000
diff --git a/src/app/(with-nav-footer)/activities/[id]/page.tsx b/src/app/(with-nav-footer)/activities/[id]/page.tsx
new file mode 100644
index 00000000..8c3a2bb9
--- /dev/null
+++ b/src/app/(with-nav-footer)/activities/[id]/page.tsx
@@ -0,0 +1,21 @@
+import { notFound } from 'next/navigation';
+import axiosServerHelper from '@/lib/network/axiosServerHelper';
+import { safeResponse } from '@/lib/network/safeResponse';
+import { Activity, activityDetailSchema } from '@/lib/types/activities';
+import ActivityDetailPage from './ActivityDetail';
+
+export default async function Page({ params }: { params: Promise<{ id: string }> }) {
+  const id = (await params).id;
+
+  try {
+    const response = await axiosServerHelper<Activity>(`/activities/${id}`);
+    const activityDetail = safeResponse(response.data, activityDetailSchema);
+
+    if (!activityDetail) {
+      return notFound();
+    }
+    return <ActivityDetailPage activityDetail={activityDetail} />;
+  } catch {
+    return notFound();
+  }
+}
diff --git a/src/assets/icons/marker.svg b/src/assets/icons/marker.svg
new file mode 100644
index 00000000..335f3895
--- /dev/null
+++ b/src/assets/icons/marker.svg
@@ -0,0 +1,4 @@
+<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9 7.875C9.62132 7.875 10.125 7.37132 10.125 6.75C10.125 6.12868 9.62132 5.625 9 5.625C8.37868 5.625 7.875 6.12868 7.875 6.75C7.875 7.37132 8.37868 7.875 9 7.875Z" fill="#112211"/>
+<path d="M9 1.125C5.89852 1.125 3.375 3.53848 3.375 6.50391C3.375 7.91613 4.01871 9.79418 5.2882 12.086C6.30773 13.9261 7.48723 15.59 8.1007 16.418C8.20437 16.5594 8.33991 16.6745 8.49633 16.7538C8.65276 16.8331 8.82567 16.8744 9.00105 16.8744C9.17644 16.8744 9.34935 16.8331 9.50578 16.7538C9.6622 16.6745 9.79774 16.5594 9.90141 16.418C10.5138 15.59 11.6944 13.9261 12.7139 12.086C13.9813 9.79488 14.625 7.91684 14.625 6.50391C14.625 3.53848 12.1015 1.125 9 1.125ZM9 9C8.55499 9 8.11998 8.86804 7.74997 8.62081C7.37996 8.37357 7.09157 8.02217 6.92127 7.61104C6.75097 7.1999 6.70642 6.7475 6.79323 6.31105C6.88005 5.87459 7.09434 5.47368 7.40901 5.15901C7.72368 4.84434 8.12459 4.63005 8.56105 4.54323C8.9975 4.45642 9.4499 4.50097 9.86104 4.67127C10.2722 4.84157 10.6236 5.12996 10.8708 5.49997C11.118 5.86998 11.25 6.30499 11.25 6.75C11.2493 7.34654 11.0121 7.91846 10.5903 8.34027C10.1685 8.76209 9.59654 8.99935 9 9Z" fill="#112211"/>
+</svg>
diff --git a/src/assets/icons/top-arrow.svg b/src/assets/icons/top-arrow.svg
new file mode 100644
index 00000000..d088af02
--- /dev/null
+++ b/src/assets/icons/top-arrow.svg
@@ -0,0 +1,3 @@
+<svg width="26" height="15" viewBox="0 0 26 15" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M24 13L13.7071 2.70711C13.3166 2.31658 12.6834 2.31658 12.2929 2.70711L2 13" stroke="black" stroke-width="3" stroke-linecap="round"/>
+</svg>
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index d677bde3..fdb97194 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -7,7 +7,7 @@ import instagram from '@/assets/sns/instagram.svg';
 
 export default function Footer() {
   return (
-    <div className='bg-black-200 mt-[40%] h-[160px] w-full'>
+    <div className='bg-black-200 h-[160px] w-full'>
       <div className='grid w-full max-w-[1200px] grid-cols-3 grid-rows-1 px-5 py-[32px] max-[520px]:grid-cols-2 md:w-full md:grid-cols-3 md:px-5 lg:mx-auto lg:grid-cols-3'>
         <p className='cursor-default justify-self-start text-gray-800'>©codeit - 2023</p>
         <div className='flex gap-[30px] justify-self-center max-[520px]:justify-self-end md:justify-self-center lg:justify-self-center'>
diff --git a/src/lib/utils/useIntersectionObserver.ts b/src/lib/utils/useIntersectionObserver.ts
new file mode 100644
index 00000000..2e18c8ef
--- /dev/null
+++ b/src/lib/utils/useIntersectionObserver.ts
@@ -0,0 +1,38 @@
+'use client';
+
+import { useEffect } from 'react';
+
+export const useIntersectionObserver = (setCurrentTab: (tabId: string) => void) => {
+  useEffect(() => {
+    const observerOptions = {
+      root: null,
+      rootMargin: '0px 0px -50% 0px',
+      threshold: 0.5,
+    };
+
+    const observer = new IntersectionObserver((entries) => {
+      entries.forEach((entry) => {
+        if (entry.isIntersecting) {
+          const id = entry.target.getAttribute('id');
+          if (id) {
+            setCurrentTab(id);
+          }
+        }
+      });
+    }, observerOptions);
+
+    const descriptionEl = document.getElementById('description');
+    const locationEl = document.getElementById('location');
+    const reviewsEl = document.getElementById('reviews');
+
+    if (descriptionEl) observer.observe(descriptionEl);
+    if (locationEl) observer.observe(locationEl);
+    if (reviewsEl) observer.observe(reviewsEl);
+
+    return () => {
+      if (descriptionEl) observer.unobserve(descriptionEl);
+      if (locationEl) observer.unobserve(locationEl);
+      if (reviewsEl) observer.unobserve(reviewsEl);
+    };
+  }, [setCurrentTab]);
+};


Reviews:
user: login: qpalkim
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T08:13:34Z
user: login: llllliii88iiilllll
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T11:49:27Z
user: login: qpalkim
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-24T11:57:32Z

Review comments:
diff_hunk: @@ -0,0 +1,15 @@
+const LoadingSpinner = () => (
path: src/app/(with-nav-footer)/activities/[id]/_components/LoadingSpinner.tsx
user: login: qpalkim
body: 로딩 스피너는 각자 맡은 페이지 작업이 전반적으로 마무리 됐을 때 로딩 처리를 어떤 방식으로 표시할지 얘기를 나눠봐야할 것 같아서 지금 당장은 로딩 처리 로직을 빼셔도 될 것 같습니다
created_at: 2025-03-24T08:13:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,15 @@
+const LoadingSpinner = () => (
path: src/app/(with-nav-footer)/activities/[id]/_components/LoadingSpinner.tsx
user: login: llllliii88iiilllll
body: 해당 부분은 어쩔수 없이 로딩스피너를 사용해야할 것 같습니다.
관련 내용은 디엠으로 전달 드렸습니다.
created_at: 2025-03-24T11:49:27Z
author_association: CONTRIBUTOR

PR comments:
user: login: vercel[bot]
created_at: 2025-03-24T04:07:31Z
body: [vc]: #AsxEwjj2x/tOdhsCpNxUgQUQVbN1WN4NpVn6aa/FNew=:eyJpc01vbm9yZXBvIjp0cnVlLCJ0eXBlIjoiZ2l0aHViIiwicHJvamVjdHMiOlt7Im5hbWUiOiJnbG9iYWxub21hZDEyLTEiLCJsaXZlRmVlZGJhY2siOnsicmVzb2x2ZWQiOjAsInVucmVzb2x2ZWQiOjAsInRvdGFsIjowLCJsaW5rIjoiZ2xvYmFsbm9tYWQxMi0xLWdpdC1mZWF0LW5hZ3l1bXMtcHJvamVjdHMudmVyY2VsLmFwcCJ9LCJpbnNwZWN0b3JVcmwiOiJodHRwczovL3ZlcmNlbC5jb20vbmFneXVtcy1wcm9qZWN0cy9nbG9iYWxub21hZDEyLTEvMzREQkRKYlJ5MnNITUZDdzJMQWNteXRMMnVWMyIsInByZXZpZXdVcmwiOiJnbG9iYWxub21hZDEyLTEtZ2l0LWZlYXQtbmFneXVtcy1wcm9qZWN0cy52ZXJjZWwuYXBwIiwibmV4dENvbW1pdFN0YXR1cyI6IkRFUExPWUVEIiwicm9vdERpcmVjdG9yeSI6bnVsbH1dfQ==
**The latest updates on your projects**. Learn more about [Vercel for Git ↗︎](https://vercel.link/github-learn-more)

| Name | Status | Preview | Comments | Updated (UTC) |
| :--- | :----- | :------ | :------- | :------ |
| **globalnomad12-1** | ✅ Ready ([Inspect](https://vercel.com/nagyums-projects/globalnomad12-1/34DBDJbRy2sHMFCw2LAcmytL2uV3)) | [Visit Preview](https://globalnomad12-1-git-feat-nagyums-projects.vercel.app) | 💬 [**Add feedback**](https://vercel.live/open-feedback/globalnomad12-1-git-feat-nagyums-projects.vercel.app?via=pr-comment-feedback-link) | Mar 24, 2025 11:48am |


author_association: NONE
user: login: qpalkim
created_at: 2025-03-24T08:14:11Z
body: 레이아웃 작업 수고하셨습니다! 탭 눌렀을 때 이동하는 거 확인했고, 새로고침 시 페이지 상단으로 이동하는 것도 확인했습니다👍
author_association: CONTRIBUTOR