Skip to content

Commit 20917f7

Browse files
author
jyn
committed
Merge branch 'dev'
2 parents 9fb14d7 + 5d6a0e0 commit 20917f7

File tree

67 files changed

+1606
-790
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

67 files changed

+1606
-790
lines changed

next.config.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,21 @@ const nextConfig: NextConfig = {
1010
pathname: '/**',
1111
},
1212
],
13-
// 이미지 최적화 설정 강화
13+
// 이미지 최적화
1414
formats: ['image/webp', 'image/avif'],
15-
deviceSizes: [640, 750, 828, 1080, 1200, 1920, 2048, 3840],
15+
deviceSizes: [640, 750, 828, 1080, 1200, 1920],
1616
imageSizes: [16, 32, 48, 64, 96, 128, 256, 384],
17-
minimumCacheTTL: 60 * 60 * 24 * 30, // 30일 캐시
17+
minimumCacheTTL: 60 * 60 * 24 * 30,
18+
},
19+
20+
experimental: {
21+
// 외부 패키지 최적화
22+
optimizePackageImports: [
23+
'swiper',
24+
'lucide-react',
25+
'@radix-ui/react-*',
26+
'date-fns',
27+
],
1828
},
1929
};
2030

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
'use client';
2+
3+
import { useQuery } from '@tanstack/react-query';
4+
import dynamic from 'next/dynamic';
5+
import { notFound } from 'next/navigation';
6+
import { useEffect } from 'react';
7+
8+
import Header from '@/features/activityId/components/header';
9+
import AddressWithMap from '@/features/activityId/components/map/address-with-map';
10+
import SubImages from '@/features/activityId/components/sub-images';
11+
import { getActivityId } from '@/features/activityId/libs/api/getActivityId';
12+
import { textStyle } from '@/features/activityId/libs/constants/variants';
13+
import { useCalendarStore } from '@/shared/components/calendar/libs/stores/useCalendarStore';
14+
import LoadingSpinner from '@/shared/components/loading-spinner/loading-spinner';
15+
import { useModalStore } from '@/shared/components/modal/libs/stores/useModalStore';
16+
import { cn } from '@/shared/libs/cn';
17+
const ReservationModal = dynamic(
18+
() => import('@/features/activityId/components/reservation-modal'),
19+
{
20+
ssr: false,
21+
loading: () => <LoadingSpinner />,
22+
},
23+
);
24+
const Reviews = dynamic(
25+
() => import('@/features/activityId/components/reviews'),
26+
{
27+
ssr: false,
28+
loading: () => <LoadingSpinner />,
29+
},
30+
);
31+
32+
const ActivityPageContent = ({ id }: { id: string }) => {
33+
const { closeModal } = useModalStore();
34+
const { setYear, setMonth, resetSelectedDate, resetDate } =
35+
useCalendarStore();
36+
37+
const { data, isLoading, isError } = useQuery({
38+
queryKey: ['activityId', id],
39+
queryFn: () => getActivityId(id),
40+
staleTime: 1000 * 60 * 30,
41+
});
42+
43+
// notfound 페이지로 에러처리
44+
if (isError) {
45+
notFound();
46+
}
47+
48+
// 언마운트 시 클린업
49+
useEffect(() => {
50+
return () => {
51+
// 모달 닫기
52+
closeModal();
53+
// 캘린더 리셋
54+
const today = new Date();
55+
setYear(today.getFullYear());
56+
setMonth(today.getMonth());
57+
resetSelectedDate();
58+
resetDate();
59+
};
60+
}, [closeModal, setMonth, setYear, resetDate, resetSelectedDate]);
61+
62+
if (isLoading || !data) return <LoadingSpinner />;
63+
64+
return (
65+
<div className="mx-auto w-full justify-center p-[2.4rem] md:px-[4rem] lg:max-w-[120rem] lg:pt-[1.6rem]">
66+
{/* 체험 이미지 */}
67+
<SubImages images={data?.subImages} />
68+
<div className={cn('lg:grid lg:grid-cols-[1fr_41.9rem] lg:gap-[4rem]')}>
69+
<div>
70+
{/* 헤더 영역(분류, 제목, 별점, 주소, 드롭다운) */}
71+
<Header data={data} />
72+
{/* 체험 설명 */}
73+
<hr className="mt-[2rem] mb-[2rem]" />
74+
<section>
75+
<h2 className={textStyle.h2}>체험 설명</h2>
76+
<p className={textStyle.content}>{data?.description}</p>
77+
</section>
78+
{/* 오시는 길 */}
79+
<hr className="mt-[2rem] mb-[2rem]" />
80+
<AddressWithMap address={data?.address} />
81+
{/* 체험 후기 */}
82+
<hr className="mb-[2rem] lg:mb-[4rem]" />
83+
<Reviews activityId={Number(id)} />
84+
</div>
85+
{/* 체험 예약 캘린더 */}
86+
<ReservationModal price={data?.price} activityId={Number(id)} />
87+
</div>
88+
</div>
89+
);
90+
};
91+
export default ActivityPageContent;

src/app/activities/[id]/page.tsx

Lines changed: 19 additions & 87 deletions
Original file line numberDiff line numberDiff line change
@@ -1,93 +1,25 @@
1-
'use client';
1+
import {
2+
dehydrate,
3+
HydrationBoundary,
4+
QueryClient,
5+
} from '@tanstack/react-query';
26

3-
import { AxiosError } from 'axios';
4-
import Image from 'next/image';
5-
import { useParams, useRouter } from 'next/navigation';
7+
import ActivityPageContent from '@/app/activities/[id]/page-content';
8+
import { getActivityId } from '@/features/activityId/libs/api/getActivityId';
69

7-
import AddressWithMap from '@/features/activityId/components/map/address-with-map';
8-
import OwnerDropdown from '@/features/activityId/components/owner-drop-down';
9-
import ReservationModal from '@/features/activityId/components/reservation-modal';
10-
import Reviews from '@/features/activityId/components/reviews';
11-
import SubImages from '@/features/activityId/components/sub-images';
12-
import { activityIdStyle } from '@/features/activityId/libs/constants/variants';
13-
import { useActivityIdQuery } from '@/features/activityId/libs/hooks/useActivityIdQuery';
14-
import LoadingSpinner from '@/shared/components/loading-spinner/loading-spinner';
15-
import StarImage from '@/shared/components/star/star';
16-
17-
const ActivityPage = () => {
18-
const { id } = useParams();
19-
const { data, isLoading, isError, error } = useActivityIdQuery(id);
20-
const router = useRouter();
21-
if (isError) {
22-
if (error instanceof AxiosError && error.response) {
23-
const status = error.response.status;
24-
25-
if (status === 404 || status === 500) {
26-
router.push('/not-found');
27-
} else {
28-
throw new Error(String(status));
29-
}
30-
}
31-
}
32-
33-
if (isLoading || !data) return <LoadingSpinner />;
10+
const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
11+
const { id } = await params;
12+
const queryClient = new QueryClient();
13+
await queryClient.prefetchQuery({
14+
queryKey: ['activityId', id],
15+
queryFn: () => getActivityId(id),
16+
});
3417

3518
return (
36-
<div className="flex-center flex-col p-[2.4rem]">
37-
<div>
38-
<div className="flex flex-col gap-[2rem] md:gap-[2.4rem] lg:flex-row lg:gap-[4rem]">
39-
<SubImages images={data?.subImages} />
40-
<div className="relative">
41-
{/* 타이틀 헤더 */}
42-
<header className="order-2 lg:w-[41rem]">
43-
<div className="mt-[2rem] flex items-start justify-between">
44-
<div>
45-
<div className="text-[1.4rem] font-medium text-gray-700">
46-
{data?.category}
47-
</div>
48-
<h1 className={activityIdStyle.h1}>{data?.title}</h1>
49-
</div>
50-
<OwnerDropdown ownerId={data?.userId} activityId={Number(id)} />
51-
</div>
52-
{/* 별점 & 후기 & 구분선 */}
53-
<div className="mb-[1rem] flex items-center gap-[0.6rem] text-[1.4rem] leading-none text-gray-700">
54-
<StarImage />
55-
<p>
56-
{data?.rating.toFixed(1)} ({data?.reviewCount})
57-
</p>
58-
</div>
59-
<div className="flex items-center gap-[0.2rem] text-[1.4rem] leading-none text-gray-700">
60-
<Image
61-
src="/images/icons/map-spot.svg"
62-
width={16}
63-
height={16}
64-
alt={'address'}
65-
/>
66-
<p>{data?.address}</p>
67-
</div>
68-
</header>
69-
{/* 체험 예약 캘린더 */}
70-
<section className="lg:absolute lg:top-[21rem] lg:left-0">
71-
<ReservationModal price={data?.price} activityId={Number(id)} />
72-
</section>
73-
</div>
74-
</div>
75-
<div className="lg:w-[67rem]">
76-
<hr className="mt-[2rem] mb-[2rem]" />
77-
{/* 체험 설명 */}
78-
<section>
79-
<h2 className={activityIdStyle.h2}>체험 설명</h2>
80-
<p className={activityIdStyle.content}>{data?.description}</p>
81-
</section>
82-
<hr className="mt-[2rem] mb-[2rem]" />
83-
{/* 오시는 길 */}
84-
<AddressWithMap address={data?.address} />
85-
<hr className="mb-[2rem] lg:mb-[4rem]" />
86-
{/* 체험 후기 */}
87-
<Reviews activityId={Number(id)} />
88-
</div>
89-
</div>
90-
</div>
19+
<HydrationBoundary state={dehydrate(queryClient)}>
20+
<ActivityPageContent id={id} />
21+
</HydrationBoundary>
9122
);
9223
};
93-
export default ActivityPage;
24+
25+
export default Page;

src/app/activities/page-content.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,59 @@
11
'use client';
22

3+
import dynamic from 'next/dynamic';
34
import { useSearchParams } from 'next/navigation';
5+
import { Suspense } from 'react';
46

5-
import AllActivities from '@/features/activities/components/all-activities';
7+
// 즉시 로드
68
import BannerCarousel from '@/features/activities/components/banner-carousel';
7-
import BestActivities from '@/features/activities/components/best-activities';
89
import Search from '@/features/activities/components/search';
9-
import SearchResults from '@/features/activities/components/search-result';
10+
import LoadingSpinner from '@/shared/components/loading-spinner/loading-spinner';
11+
12+
// 지연 로드 (성능 최적화) - Next.js dynamic 사용
13+
const BestActivities = dynamic(
14+
() => import('@/features/activities/components/best-activities'),
15+
);
16+
17+
const AllActivities = dynamic(
18+
() => import('@/features/activities/components/all-activities'),
19+
);
20+
21+
const SearchResults = dynamic(
22+
() => import('@/features/activities/components/search-result'),
23+
);
1024

1125
const ActivitiesPageContent = () => {
1226
const searchParams = useSearchParams();
13-
const keyword = searchParams.get('search')?.trim() || '';
14-
const isSearching = keyword.trim().length > 0;
27+
28+
const keyword = searchParams.get('keyword')?.trim() || '';
29+
const region = searchParams.get('region')?.trim() || '';
30+
const category = searchParams.get('category')?.trim() || '';
31+
32+
const isSearching =
33+
keyword.length > 0 || region.length > 0 || category.length > 0;
1534

1635
return (
1736
<main className="bg-background flex w-full flex-col gap-10">
1837
<div className="mx-auto w-full max-w-[120rem]">
38+
{/* 즉시 로드*/}
1939
<BannerCarousel />
2040
<Search />
2141

2242
{isSearching ? (
23-
<SearchResults keyword={keyword} />
43+
<Suspense fallback={<LoadingSpinner />}>
44+
<SearchResults />
45+
</Suspense>
2446
) : (
2547
<>
26-
<BestActivities />
27-
<AllActivities />
48+
{/* 지연 로드: 인기 체험 */}
49+
<Suspense fallback={<LoadingSpinner />}>
50+
<BestActivities />
51+
</Suspense>
52+
53+
{/* 지연 로드: 전체 체험 목록 */}
54+
<Suspense fallback={<LoadingSpinner />}>
55+
<AllActivities />
56+
</Suspense>
2857
</>
2958
)}
3059
</div>

src/app/globals.css

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,3 +348,12 @@
348348
.btn-action-gray {
349349
@apply hover:bg-gray-300 hover:text-white active:border-gray-300;
350350
}
351+
.btn-action-carousel {
352+
@apply hover:bg-sub-300 hover:text-main active:text-main-600 active:bg-sub-300;
353+
}
354+
.btn-action-landing-blue {
355+
@apply hover:bg-main-600 active:bg-main-700 hover:text-white;
356+
}
357+
.btn-action-landing-white {
358+
@apply hover:bg-sub-300 hover:text-main-700 active:text-main-600 active:bg-sub-50;
359+
}

src/app/my/layout.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ export default function Layout({ children }: { children: React.ReactNode }) {
3030
}, []);
3131

3232
const handleBackClick = () => {
33-
router.back();
33+
router.push('/my');
3434
};
3535

3636
if (!isClient) {
@@ -43,13 +43,13 @@ export default function Layout({ children }: { children: React.ReactNode }) {
4343

4444
return (
4545
<div
46-
className={`mx-auto flex justify-center px-[2.4rem] py-[3rem] md:max-w-[68.4rem] md:gap-[3rem] lg:max-w-[98rem] lg:gap-[5rem]`}
46+
className={`mx-auto flex justify-center px-[2.4rem] py-[3rem] md:w-[73.2rem] md:gap-[3rem] lg:min-w-[102.4rem] lg:gap-[5rem]`}
4747
>
4848
{/* 모바일에서만 보이는 뒤로가기 버튼 */}
4949
{isMobile && !isMyPageRoot && (
5050
<button
5151
onClick={handleBackClick}
52-
className="absolute top-[5rem] left-[2.4rem]"
52+
className="absolute top-[5.8rem] left-[2.4rem]"
5353
aria-label="뒤로 가기"
5454
>
5555
<Image

0 commit comments

Comments
 (0)