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
Binary file added public/assets/img/main-banner.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions public/assets/svg/github.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react';

const IconGithub = ({ size = 24, color = '#fff', ...props }) => (
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

TypeScript 타입 정의 추가가 필요합니다.

컴포넌트의 props에 대한 TypeScript 인터페이스가 없어 타입 안전성이 보장되지 않습니다.

다음과 같이 props 타입을 정의해주세요:

+interface IconGithubProps extends React.SVGProps<SVGSVGElement> {
+  size?: number;
+  color?: string;
+}
+
-const IconGithub = ({ size = 24, color = '#fff', ...props }) => (
+const IconGithub: React.FC<IconGithubProps> = ({ size = 24, color = '#fff', ...props }) => (
📝 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 IconGithub = ({ size = 24, color = '#fff', ...props }) => (
interface IconGithubProps extends React.SVGProps<SVGSVGElement> {
size?: number;
color?: string;
}
const IconGithub: React.FC<IconGithubProps> = ({ size = 24, color = '#fff', ...props }) => (
🤖 Prompt for AI Agents
In public/assets/svg/github.tsx at line 3, the IconGithub component lacks
TypeScript type definitions for its props, reducing type safety. Define a
TypeScript interface for the props specifying size as a number and color as a
string, then apply this interface to the component's props parameter to ensure
proper type checking.

<svg
width={size}
height={size}
viewBox="0 0 98 96"
fill={color}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a47 47 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0"
/>
</svg>
Comment on lines +4 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

접근성 개선을 위해 title 요소 또는 aria-label 추가가 필요합니다.

정적 분석 도구에서 지적한 대로, 접근성을 위해 SVG에 대한 대체 텍스트가 필요합니다.

다음 중 하나의 방법으로 해결할 수 있습니다:

  <svg
    width={size}
    height={size}
    viewBox="0 0 98 96"
    fill={color}
    xmlns="http://www.w3.org/2000/svg"
+   aria-label="GitHub"
    {...props}
  >
+   <title>GitHub</title>
    <path
📝 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
<svg
width={size}
height={size}
viewBox="0 0 98 96"
fill={color}
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a47 47 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0"
/>
</svg>
<svg
width={size}
height={size}
viewBox="0 0 98 96"
fill={color}
xmlns="http://www.w3.org/2000/svg"
aria-label="GitHub"
{...props}
>
<title>GitHub</title>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a47 47 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0"
/>
</svg>
🧰 Tools
🪛 Biome (2.1.2)

[error] 4-11: Alternative text title element cannot be empty

For accessibility purposes, SVGs should have an alternative text, provided via title element. If the svg element has role="img", you should add the aria-label or aria-labelledby attribute.

(lint/a11y/noSvgWithoutTitle)

🤖 Prompt for AI Agents
In public/assets/svg/github.tsx around lines 4 to 17, the SVG element lacks
accessibility features such as a title element or aria-label, which are needed
for screen readers. To fix this, add a <title> element inside the SVG with a
descriptive text or add an aria-label attribute to the SVG tag describing the
image. This will provide alternative text for assistive technologies and improve
accessibility.

);

export default IconGithub;
14 changes: 7 additions & 7 deletions src/app/(with-header)/components/BannerSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@ import Image from 'next/image';
import SearchBar from '@/app/(with-header)/components/SearchBar';

interface BannerSectionProps {
onSearch: (keyword: string) => void;
keyword: string;
}

export default function BannerSection({ onSearch }: BannerSectionProps) {
export default function BannerSection({ keyword }: BannerSectionProps) {
return (
<section className='relative w-full h-240 md:h-550 mb-93'>
{/* 배경 이미지 */}
Expand All @@ -15,7 +15,7 @@ export default function BannerSection({ onSearch }: BannerSectionProps) {
priority
alt='스트릿 댄스'
className='object-cover'
src='/test/image1.png'
src='/assets/img/main-banner.jpg'
/>

{/* 어두운 오버레이 */}
Expand All @@ -24,15 +24,15 @@ export default function BannerSection({ onSearch }: BannerSectionProps) {
{/* 텍스트 콘텐츠 */}
<div className='relative z-10 flex flex-col items-start w-220 max-w-1200 md:w-440 lg:w-full pl-24 pt-74 md:pl-32 lg:pl-0 md:pt-144 lg:pt-159 lg:ml-auto lg:mr-auto gap-8 lg:gap-20 h-full text-white font-bold break-keep'>
<h2 className='text-2xl md:text-[54px] md:leading-[64px] lg:text-[68px] lg:leading-[78px]'>
함께 배우면 즐거운<br />
스트릿 댄스
오로라와 함께하는<br />
여름의 북극 감성 체험
</h2>
<p className='text-md md:text-xl lg:text-2xl'>
1월의 인기 경험 BEST 🔥
자연 속에서 즐기는 이색 액티비티 추천 ❤️
</p>
</div>
<div className='absolute -bottom-100 left-0 right-0'>
<SearchBar onSearch={onSearch} />
<SearchBar keyword={keyword} />
</div>
</section>
);
Expand Down
26 changes: 26 additions & 0 deletions src/app/(with-header)/components/BasePage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
'use client';

import { useSearchParams } from 'next/navigation';
import BannerSection from '@/app/(with-header)/components/BannerSection';
import PopularExperiences from '@/app/(with-header)/components/PopularExperiences';
import ExperienceList from '@/app/(with-header)/components/ExperienceList';

export default function BasePage() {
const searchParams = useSearchParams();
const keyword = searchParams.get('q') ?? '';
const isSearchMode = Boolean(keyword.trim());

return (
<main>
<BannerSection keyword={keyword} />
{isSearchMode ? (
<ExperienceList keyword={keyword} isSearchMode />
) : (
<>
<PopularExperiences />
<ExperienceList />
</>
)}
</main>
);
}
30 changes: 15 additions & 15 deletions src/app/(with-header)/components/ExperienceList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import Dropdown from '@components/Dropdown';
import Pagination from '@components/Pagination';
import CategoryFilter from '@/app/(with-header)/components/CategoryFilter';
import ExperienceCard from '@/app/(with-header)/components/ExperienceCard';
import ExperienceCardSkeleton from '@/app/(with-header)/components/Skeletons/ExperienceCardSkeleton';
import { getExperiences, ExperienceListResult } from '@/app/api/experiences/getExperiences';
import { ACTIVITY_CATEGORIES, ActivityCategory } from '@/constants/categories';
import {
Expand All @@ -26,15 +27,14 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
const [selectedCategory, setSelectedCategory] = useState<ActivityCategory>(ACTIVITY_CATEGORIES[0]);
const [sortOption, setSortOption] = useState<string | undefined>('');

// TanStack Query 사용 (타입 명시 필수)
const { data, isLoading, error } = useQuery<ExperienceListResult>({
const { data, isLoading } = useQuery<ExperienceListResult>({
queryKey: ['experiences', currentPage, selectedCategory, sortOption, keyword],
queryFn: () =>
getExperiences({
page: currentPage,
sort: sortOption,
category: selectedCategory,
keyword,
keyword: keyword || undefined,
}),
placeholderData: (prev) => prev,
});
Comment on lines +30 to 40
Copy link

Choose a reason for hiding this comment

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

💡 Verification agent

🧩 Analysis chain

에러 처리 고려사항

error 구조분해를 제거했는데, 사용자에게 API 에러 상황을 알려주는 방법을 고려해보시기 바랍니다. 현재는 로딩이 실패해도 빈 목록만 표시됩니다.

다음 스크립트로 에러 처리 패턴을 확인해보겠습니다:


🏁 Script executed:

#!/bin/bash
# Description: Check error handling patterns in similar components

# Search for error handling in other list components
rg -A 10 -B 5 "useQuery.*error" --type tsx

Length of output: 72


다음 스크립트로 TSX 파일들에서 useQuery 호출 및 error 구조분해/사용 패턴을 확인해 보겠습니다.


🏁 Script executed:

#!/bin/bash
# Description: TSX 컴포넌트에서 useQuery 호출 및 error 구조분해 패턴 검색

# 1. useQuery 사용 파일 목록
echo "■ useQuery 사용 TSX 파일:"
rg -l "useQuery(" -g '*.tsx' || true

echo ""
# 2. useQuery 호출 내 error 구조분해 또는 error 사용 패턴
echo "■ useQuery 호출에서 error 구조분해/사용 패턴:"
rg -n -C2 "{[^}]*error" -g '*.tsx' || true

Length of output: 378


TSX 파일에서 useQuery 호출과 error 구조분해/사용 패턴을 올바르게 검색하도록 정규식을 수정한 스크립트를 실행해 보겠습니다.


🏁 Script executed:

#!/bin/bash
# TSX 컴포넌트에서 useQuery 호출 및 error 구조분해/사용 패턴 검색

echo "■ useQuery 사용 파일:"
rg -l "useQuery" -g '*.tsx' || true

echo ""
echo "■ useQuery 호출 라인:"
rg -n "useQuery" -g '*.tsx' || true

echo ""
echo "■ error 구조분해 사용 패턴 (예: const { data, error } = useQuery(...)):"
rg -n "const \\{[^\\}]*error[^\\}]*\\}" -g '*.tsx' || true

Length of output: 2270


ExperienceList.tsx에 에러 핸들링 추가 필요

다른 목록 컴포넌트(e.g. PopularExperiences.tsx)에서는 useQuery 호출 시 error를 구조분해하고 에러 UI를 렌더링하고 있습니다. ExperienceList도 동일하게 처리해 주세요.

• 파일: src/app/(with-header)/components/ExperienceList.tsx

  • 30번째 줄:
    - const { data, isLoading } = useQuery<ExperienceListResult>({
    + const { data, isLoading, error } = useQuery<ExperienceListResult>({
  • error가 있을 때 사용자에게 안내 메시지나 에러 컴포넌트를 보여주도록 추가 처리
    if (error) {
      return <ErrorMessage message="경험 목록을 불러오는 중 오류가 발생했습니다. 다시 시도해 주세요." />;
    }
🤖 Prompt for AI Agents
In src/app/(with-header)/components/ExperienceList.tsx around lines 30 to 40,
the useQuery hook destructures data and isLoading but does not handle the error
state. Update the destructuring to also include error from useQuery, then add a
conditional check for error. If error exists, return an ErrorMessage component
with a user-friendly message indicating that loading the experience list failed
and suggesting to retry.

Expand All @@ -44,7 +44,8 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
const totalPage = Math.ceil(totalCount / 8);

return (
<section className='max-w-1200 m-auto px-24 md:px-0 pb-83'>
<section className='max-w-1200 m-auto px-24 lg:px-0 pb-83'>
{/* 🔍 검색 모드일 때 문구 표시 */}
{isSearchMode && keyword && (
<>
<p className="text-left text-lg font-semibold ml-4 md:ml-0 mt-32">
Expand All @@ -53,7 +54,7 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
<p className="text-left text-sm font-normal ml-4 md:ml-0 mt-8 mb-16">
총 <span className="font-semibold">{totalCount}</span>개의 결과
</p>
{experiences.length === 0 && (
{experiences.length === 0 && !isLoading && (
<p className="text-center text-gray-500 mt-32">검색 결과가 없습니다.</p>
)}
</>
Expand Down Expand Up @@ -87,13 +88,12 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
<h2 className='text-xl md:text-3xl font-bold'>🛼 모든 체험</h2>
)}

{isLoading ? (
<p className="text-center">체험을 불러오는 중입니다...</p>
) : error ? (
<p className="text-center text-red-500">체험 데이터를 불러오는 데 실패했습니다 😢</p>
) : (
<div className='grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-8 md:gap-16 lg:gap-24 mt-24'>
{experiences.map((exp) => (
{/* 체험 카드 목록 */}
<div className='grid grid-cols-2 grid-rows-2 md:grid-cols-3 md:grid-rows-3 lg:grid-cols-4 lg:grid-rows-2 gap-8 md:gap-16 lg:gap-24 mt-24'>
{isLoading ? (
Array.from({ length: 8 }).map((_, idx) => <ExperienceCardSkeleton key={idx} />)
) : (
experiences.map((exp) => (
<Link key={exp.id} href={`/activities/${exp.id}`}>
<ExperienceCard
imageUrl={exp.bannerImageUrl}
Expand All @@ -103,9 +103,9 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList
title={exp.title}
/>
</Link>
))}
</div>
)}
))
)}
</div>
</div>

{experiences.length > 0 && (
Expand Down
53 changes: 34 additions & 19 deletions src/app/(with-header)/components/PopularExperiences.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,28 @@
'use client';

import IconArrowLeft from '@assets/svg/left-arrow';
import IconArrowRight from '@assets/svg/right-arrow';
import { useRef } from 'react';
import Link from 'next/link';
import { useQuery } from '@tanstack/react-query';

import IconArrowLeft from '@assets/svg/left-arrow';
import IconArrowRight from '@assets/svg/right-arrow';

import PopularCard from '@/app/(with-header)/components/PopularCard';
import PopularCardSkeleton from '@/app/(with-header)/components/Skeletons/PopularCardSkeleton';

import { getPopularExperiences } from '@/app/api/experiences/getPopularExperiences';

export default function PopularExperiences() {
const sliderRef = useRef<HTMLDivElement>(null);

// TanStack Query 도입
const { data, isLoading, error } = useQuery({
queryKey: ['popularExperiences'],
queryFn: getPopularExperiences,
});

const scrollByCard = (direction: 'left' | 'right') => {
if (!sliderRef.current) return;

const card = sliderRef.current.querySelector('.card');
if (!(card instanceof HTMLElement)) return;

Expand All @@ -33,11 +36,9 @@ export default function PopularExperiences() {
});
};

if (isLoading) return <p className="text-center">인기 체험을 불러오는 중입니다...</p>;
if (error || !data) return <p className="text-center text-red-500">데이터를 불러오는 데 실패했어요 😢</p>;

return (
<section className='pt-24 md:pt-34 pl-24 lg:pl-0 pb-40 lg:pb-33 lg:max-w-1200 lg:w-full mx-auto'>
{/* 제목 + 버튼 */}
<div className='flex justify-between items-center pb-16 md:pb-32 mb-6'>
<h2 className='text-xl md:text-3xl font-bold'>🔥 인기 체험</h2>
<div className='flex gap-2'>
Expand All @@ -46,23 +47,37 @@ export default function PopularExperiences() {
</div>
</div>

{/* 카드 영역 */}
<div
ref={sliderRef}
className='flex gap-16 md:gap-32 lg:gap-24 overflow-x-auto scroll-smooth no-scrollbar'
>
{data.activities.map((exp) => (
<div key={exp.id} className='flex-shrink-0 card'>
<Link href={`/activities/${exp.id}`} className='flex-shrink-0 card'>
<PopularCard
imageUrl={exp.bannerImageUrl}
price={exp.price}
rating={exp.rating}
reviews={exp.reviewCount}
title={exp.title}
/>
</Link>
</div>
))}
{error ? (
<p className="text-red-500 text-sm">인기 체험을 불러오는 데 실패했습니다 😢</p>
) : isLoading || !data ? (
Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="flex-shrink-0 card">
<PopularCardSkeleton />
</div>
))
) : (
data.activities.map((exp) => (
<div key={exp.id} className='flex-shrink-0 card'>
<Link
href={`/activities/${exp.id}`}
className='flex-shrink-0 card'
>
<PopularCard
imageUrl={exp.bannerImageUrl}
price={exp.price}
rating={exp.rating}
reviews={exp.reviewCount}
title={exp.title}
/>
</Link>
</div>
))
)}
Comment on lines +55 to +80
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick (assertive)

로딩 상태 처리 개선이 우수합니다.

early return 패턴에서 조건부 렌더링으로 변경한 것이 좋습니다. 다음과 같은 장점이 있습니다:

  • 일관된 레이아웃 구조 유지
  • 로딩 중 4개의 스켈레톤 UI로 사용자 경험 향상
  • 에러 상태에 대한 명확한 피드백 제공

다만 한 가지 개선사항이 있습니다:

65-68번 라인에서 card 클래스가 중복 적용되고 있습니다:

-            <div key={exp.id} className='flex-shrink-0 card'>
-              <Link
-                href={`/activities/${exp.id}`}
-                className='flex-shrink-0 card'
-              >
+            <div key={exp.id} className='flex-shrink-0 card'>
+              <Link href={`/activities/${exp.id}`}>
📝 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
{error ? (
<p className="text-red-500 text-sm">인기 체험을 불러오는 데 실패했습니다 😢</p>
) : isLoading || !data ? (
Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="flex-shrink-0 card">
<PopularCardSkeleton />
</div>
))
) : (
data.activities.map((exp) => (
<div key={exp.id} className='flex-shrink-0 card'>
<Link
href={`/activities/${exp.id}`}
className='flex-shrink-0 card'
>
<PopularCard
imageUrl={exp.bannerImageUrl}
price={exp.price}
rating={exp.rating}
reviews={exp.reviewCount}
title={exp.title}
/>
</Link>
</div>
))
)}
{error ? (
<p className="text-red-500 text-sm">인기 체험을 불러오는 데 실패했습니다 😢</p>
) : isLoading || !data ? (
Array.from({ length: 4 }).map((_, idx) => (
<div key={idx} className="flex-shrink-0 card">
<PopularCardSkeleton />
</div>
))
) : (
data.activities.map((exp) => (
<div key={exp.id} className='flex-shrink-0 card'>
<Link href={`/activities/${exp.id}`}>
<PopularCard
imageUrl={exp.bannerImageUrl}
price={exp.price}
rating={exp.rating}
reviews={exp.reviewCount}
title={exp.title}
/>
</Link>
</div>
))
)}
🤖 Prompt for AI Agents
In src/app/(with-header)/components/PopularExperiences.tsx between lines 55 and
80, the 'card' class is applied twice on the same element in lines 65-68,
causing redundant class usage. Remove the duplicate 'card' class from either the
wrapping div or the Link component to avoid unnecessary repetition while
preserving the intended styling and layout.

</div>
</section>
);
Expand Down
21 changes: 15 additions & 6 deletions src/app/(with-header)/components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,30 @@

import Button from '@components/Button';
import Input from '@components/Input';
import { FormEvent,useState } from 'react';
import { useRouter } from 'next/navigation';
import { FormEvent, useState, useEffect } from 'react';

interface SearchBarProps {
onSearch: (keyword: string) => void;
keyword: string;
}

export default function SearchBar({ onSearch }: SearchBarProps) {
const [searchValue, setSearchValue] = useState('');
export default function SearchBar({ keyword }: SearchBarProps) {
const [searchValue, setSearchValue] = useState(keyword);
const router = useRouter(); // useRouter는 반드시 클라이언트에서 선언되어야 함

// 검색 버튼 클릭 시 쿼리 파라미터 변경
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
onSearch(searchValue); // 부모(HomePage)로 검색어 전달
setSearchValue(''); // 선택 사항: 검색어 초기화
const trimmed = searchValue.trim();
if (!trimmed) return;
router.push(`/?q=${encodeURIComponent(trimmed)}`);
};

// 외부에서 keyword prop이 바뀌면 input도 동기화
useEffect(() => {
setSearchValue(keyword);
}, [keyword]);

return (
<section className='flex lg:w-full lg:max-w-1200 lg:ml-auto lg:mr-auto justify-center px-16 lg:px-0'>
<div className='flex flex-col w-full gap-15 md:gap-32 px-24 py-16 md:px-24 md:py-32 rounded-[16px] bg-white shadow-md'>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function ExperienceCardSkeleton() {
return (
<div className="flex flex-col w-full gap-16 animate-pulse">
{/* 썸네일 영역 */}
<div className="w-full h-168 md:h-221 lg:h-283 rounded-[20px] bg-gray-200" />

{/* 텍스트 영역 */}
<div className="flex flex-col gap-10">
<div className="w-1/2 h-16 bg-gray-200 rounded" />
<div className="w-full h-20 bg-gray-200 rounded" />
<div className="w-2/3 h-16 bg-gray-200 rounded" />
</div>
</div>
);
}
15 changes: 15 additions & 0 deletions src/app/(with-header)/components/Skeletons/PopularCardSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function PopularCardSkeleton() {
return (
<div className="relative w-186 h-186 md:w-384 md:h-384 rounded-[20px] overflow-hidden shadow-md bg-gray-200 animate-pulse">
{/* 어두운 오버레이 레이어 */}
<div className="absolute inset-0 bg-gradient-to-r from-black to-transparent opacity-50" />

{/* 텍스트 정보 블록 위치 */}
<div className="absolute bottom-12 flex flex-col gap-6 md:gap-20 px-20 py-12">
<div className="w-24 h-16 rounded bg-gray-300" /> {/* 별점 */}
<div className="w-40 h-24 md:w-60 md:h-28 rounded bg-gray-300" /> {/* 타이틀 */}
<div className="w-32 h-16 md:w-40 md:h-20 rounded bg-gray-300" /> {/* 가격 */}
</div>
</div>
);
}
28 changes: 5 additions & 23 deletions src/app/(with-header)/page.tsx
Original file line number Diff line number Diff line change
@@ -1,25 +1,7 @@
'use client';
export const dynamic = 'force-dynamic';

import { useState } from 'react';
import BannerSection from '@/app/(with-header)/components/BannerSection';
import PopularExperiences from '@/app/(with-header)/components/PopularExperiences';
import ExperienceList from '@/app/(with-header)/components/ExperienceList';
import BasePage from '@/app/(with-header)/components/BasePage';

export default function HomePage() {
const [searchKeyword, setSearchKeyword] = useState('');

return (
<main>
<BannerSection onSearch={setSearchKeyword} />

{searchKeyword ? (
<ExperienceList keyword={searchKeyword} isSearchMode />
) : (
<>
<PopularExperiences />
<ExperienceList />
</>
)}
</main>
);
}
export default function Page() {
return <BasePage />;
}
Loading