diff --git a/public/assets/img/main-banner.jpg b/public/assets/img/main-banner.jpg new file mode 100644 index 0000000..43adf03 Binary files /dev/null and b/public/assets/img/main-banner.jpg differ diff --git a/public/assets/svg/github.tsx b/public/assets/svg/github.tsx new file mode 100644 index 0000000..1101e02 --- /dev/null +++ b/public/assets/svg/github.tsx @@ -0,0 +1,20 @@ +import React from 'react'; + +const IconGithub = ({ size = 24, color = '#fff', ...props }) => ( + + + +); + +export default IconGithub; diff --git a/src/app/(with-header)/components/BannerSection.tsx b/src/app/(with-header)/components/BannerSection.tsx index 5a6762a..5e18d2a 100644 --- a/src/app/(with-header)/components/BannerSection.tsx +++ b/src/app/(with-header)/components/BannerSection.tsx @@ -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 (
{/* 배경 이미지 */} @@ -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' /> {/* 어두운 오버레이 */} @@ -24,15 +24,15 @@ export default function BannerSection({ onSearch }: BannerSectionProps) { {/* 텍스트 콘텐츠 */}

- 함께 배우면 즐거운
- 스트릿 댄스 + 오로라와 함께하는
+ 여름의 북극 감성 체험

- 1월의 인기 경험 BEST 🔥 + 자연 속에서 즐기는 이색 액티비티 추천 ❤️

- +
); diff --git a/src/app/(with-header)/components/BasePage.tsx b/src/app/(with-header)/components/BasePage.tsx new file mode 100644 index 0000000..1421060 --- /dev/null +++ b/src/app/(with-header)/components/BasePage.tsx @@ -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 ( +
+ + {isSearchMode ? ( + + ) : ( + <> + + + + )} +
+ ); +} diff --git a/src/app/(with-header)/components/ExperienceList.tsx b/src/app/(with-header)/components/ExperienceList.tsx index f2d4cf1..441ea65 100644 --- a/src/app/(with-header)/components/ExperienceList.tsx +++ b/src/app/(with-header)/components/ExperienceList.tsx @@ -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 { @@ -26,15 +27,14 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList const [selectedCategory, setSelectedCategory] = useState(ACTIVITY_CATEGORIES[0]); const [sortOption, setSortOption] = useState(''); - // TanStack Query 사용 (타입 명시 필수) - const { data, isLoading, error } = useQuery({ + const { data, isLoading } = useQuery({ queryKey: ['experiences', currentPage, selectedCategory, sortOption, keyword], queryFn: () => getExperiences({ page: currentPage, sort: sortOption, category: selectedCategory, - keyword, + keyword: keyword || undefined, }), placeholderData: (prev) => prev, }); @@ -44,7 +44,8 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList const totalPage = Math.ceil(totalCount / 8); return ( -
+
+ {/* 🔍 검색 모드일 때 문구 표시 */} {isSearchMode && keyword && ( <>

@@ -53,7 +54,7 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList

{totalCount}개의 결과

- {experiences.length === 0 && ( + {experiences.length === 0 && !isLoading && (

검색 결과가 없습니다.

)} @@ -87,13 +88,12 @@ export default function ExperienceList({ keyword, isSearchMode }: ExperienceList

🛼 모든 체험

)} - {isLoading ? ( -

체험을 불러오는 중입니다...

- ) : error ? ( -

체험 데이터를 불러오는 데 실패했습니다 😢

- ) : ( -
- {experiences.map((exp) => ( + {/* 체험 카드 목록 */} +
+ {isLoading ? ( + Array.from({ length: 8 }).map((_, idx) => ) + ) : ( + experiences.map((exp) => ( - ))} -
- )} + )) + )} +
{experiences.length > 0 && ( diff --git a/src/app/(with-header)/components/PopularExperiences.tsx b/src/app/(with-header)/components/PopularExperiences.tsx index df91811..b2f3f87 100644 --- a/src/app/(with-header)/components/PopularExperiences.tsx +++ b/src/app/(with-header)/components/PopularExperiences.tsx @@ -1,18 +1,20 @@ '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(null); - // TanStack Query 도입 const { data, isLoading, error } = useQuery({ queryKey: ['popularExperiences'], queryFn: getPopularExperiences, @@ -20,6 +22,7 @@ export default function PopularExperiences() { const scrollByCard = (direction: 'left' | 'right') => { if (!sliderRef.current) return; + const card = sliderRef.current.querySelector('.card'); if (!(card instanceof HTMLElement)) return; @@ -33,11 +36,9 @@ export default function PopularExperiences() { }); }; - if (isLoading) return

인기 체험을 불러오는 중입니다...

; - if (error || !data) return

데이터를 불러오는 데 실패했어요 😢

; - return (
+ {/* 제목 + 버튼 */}

🔥 인기 체험

@@ -46,23 +47,37 @@ export default function PopularExperiences() {
+ {/* 카드 영역 */}
- {data.activities.map((exp) => ( -
- - - -
- ))} + {error ? ( +

인기 체험을 불러오는 데 실패했습니다 😢

+ ) : isLoading || !data ? ( + Array.from({ length: 4 }).map((_, idx) => ( +
+ +
+ )) + ) : ( + data.activities.map((exp) => ( +
+ + + +
+ )) + )}
); diff --git a/src/app/(with-header)/components/SearchBar.tsx b/src/app/(with-header)/components/SearchBar.tsx index 868e435..9ad9f5b 100644 --- a/src/app/(with-header)/components/SearchBar.tsx +++ b/src/app/(with-header)/components/SearchBar.tsx @@ -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 (
diff --git a/src/app/(with-header)/components/Skeletons/ExperienceCardSkeleton.tsx b/src/app/(with-header)/components/Skeletons/ExperienceCardSkeleton.tsx new file mode 100644 index 0000000..3fd338d --- /dev/null +++ b/src/app/(with-header)/components/Skeletons/ExperienceCardSkeleton.tsx @@ -0,0 +1,15 @@ +export default function ExperienceCardSkeleton() { + return ( +
+ {/* 썸네일 영역 */} +
+ + {/* 텍스트 영역 */} +
+
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(with-header)/components/Skeletons/PopularCardSkeleton.tsx b/src/app/(with-header)/components/Skeletons/PopularCardSkeleton.tsx new file mode 100644 index 0000000..b6f01b5 --- /dev/null +++ b/src/app/(with-header)/components/Skeletons/PopularCardSkeleton.tsx @@ -0,0 +1,15 @@ +export default function PopularCardSkeleton() { + return ( +
+ {/* 어두운 오버레이 레이어 */} +
+ + {/* 텍스트 정보 블록 위치 */} +
+
{/* 별점 */} +
{/* 타이틀 */} +
{/* 가격 */} +
+
+ ); +} \ No newline at end of file diff --git a/src/app/(with-header)/page.tsx b/src/app/(with-header)/page.tsx index 9c3148d..cc00366 100644 --- a/src/app/(with-header)/page.tsx +++ b/src/app/(with-header)/page.tsx @@ -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 ( -
- - - {searchKeyword ? ( - - ) : ( - <> - - - - )} -
- ); -} +export default function Page() { + return ; +} \ No newline at end of file diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 3b7b6cb..81d0837 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,7 +1,4 @@ -import IconFacebook from '@assets/svg/facebook'; -import IconInstagram from '@assets/svg/instagram'; -import IconYouTube from '@assets/svg/youtube'; -import IconTwitter from '@assets/svg/twitter'; +import IconGithub from '@assets/svg/github'; export default function Footer() { return ( @@ -14,44 +11,35 @@ export default function Footer() { {/* 중앙: 내비게이션 */}
{/* pc,tablet 버전 */} {/* 좌측: 저작권 */}
- ©codeit - 2023 + @NoSleepNoBugs - 2025
{/* 중앙: 내비게이션 */} {/* 우측: SNS 아이콘 */}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 125b230..4ee4d55 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,10 +1,10 @@ 'use client'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import IconLogo from '@assets/svg/logo'; import IconBell from '@assets/svg/bell'; import useUserStore from '@/stores/authStore'; -import { useRouter } from 'next/navigation'; import ProfileDropdown from '@/components/ProfileDropdown'; import useLogout from '@/hooks/useLogout'; import { toast } from 'sonner'; @@ -13,14 +13,17 @@ import NotificationDropdown from './Notification/NotificationDropdown'; export default function Header() { const router = useRouter(); - const user = useUserStore((state) => state.user); - const setUser = useUserStore((state) => state.setUser); + const { user, hasHydrated, setUser } = useUserStore(); const isLoggedIn = !!user; const logout = useLogout(); const [isOpen, setIsOpen] = useState(false); const toggleOpen = () => setIsOpen((prev) => !prev); + const handleLogoClick = () => { + router.push('/'); // 쿼리 제거됨 → 검색어 초기화됨 + }; + // 로그아웃 처리 const handleLogout = async () => { try { @@ -32,6 +35,30 @@ export default function Header() { } }; + // hydration 되기 전엔 skeleton 헤더 렌더링 + if (!hasHydrated) { + return ( +
+
+ {/* 로고는 항상 표시 */} + + + + + {/* 우측 placeholder (스켈레톤 박스) */} +
+
+
+
+
+
+ ); + } + return (
diff --git a/src/components/ProfileDropdown.tsx b/src/components/ProfileDropdown.tsx index c30a456..bd7afac 100644 --- a/src/components/ProfileDropdown.tsx +++ b/src/components/ProfileDropdown.tsx @@ -42,7 +42,7 @@ export default function ProfileDropdown({ nickname, profileImageUrl, onLogout }: alt='프로필 이미지' width={32} height={32} - className='rounded-full border border-gray-300' + className='w-32 h-32 rounded-full border border-gray-300 object-cover' /> ) : (
diff --git a/src/hooks/useMyPageQueries.ts b/src/hooks/useMyPageQueries.ts index 55f1340..45ce17a 100644 --- a/src/hooks/useMyPageQueries.ts +++ b/src/hooks/useMyPageQueries.ts @@ -9,6 +9,7 @@ import { import { UpdateProfileRequest } from '@/types/mypageTypes'; import useMyPageStore from '@/stores/MyPage/useMyPageStore'; import { useEffect } from 'react'; +import useUserStore from '@/stores/authStore'; export const QUERY_KEYS = { PROFILE: ['mypage', 'profile'] as const, @@ -47,6 +48,7 @@ export const useMyProfile = () => { export const useUpdateProfile = () => { const queryClient = useQueryClient(); const { setUser, setLoading, setError } = useMyPageStore(); + const setGlobalUser = useUserStore((state) => state.setUser); // 헤더 상태 갱신 함수 const mutation = useMutation({ mutationFn: (data: UpdateProfileRequest) => updateMyProfile(data), @@ -81,6 +83,7 @@ export const useUpdateProfile = () => { setUser, setLoading, setError, + setGlobalUser, // [추가] 헤더 상태(authStore)도 동기화 ]); return mutation; @@ -91,6 +94,8 @@ export const useUploadProfileImage = () => { const queryClient = useQueryClient(); const { setUser, setLoading, setError } = useMyPageStore(); + const setGlobalUser = useUserStore((state) => state.setUser); // 헤더 상태 갱신 함수 + const mutation = useMutation({ mutationFn: async (file: File) => { // 이미지 업로드 @@ -121,6 +126,11 @@ export const useUploadProfileImage = () => { setUser(updatedUser); queryClient.setQueryData(QUERY_KEYS.PROFILE, updatedUser); + setLoading(false); + setGlobalUser(updatedUser); // [추가] 헤더 상태(authStore)도 동기화 + + queryClient.setQueryData(QUERY_KEYS.PROFILE, updatedUser); + setLoading(false); alert('프로필 이미지가 성공적으로 업로드되었습니다!'); } @@ -138,6 +148,7 @@ export const useUploadProfileImage = () => { mutation.error, queryClient, setUser, + setGlobalUser, // [추가] 의존성에 포함 setLoading, setError, ]); diff --git a/src/middleware.ts b/src/middleware.ts index f34fe26..6dab8a9 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -4,6 +4,8 @@ import { NextRequest, NextResponse } from 'next/server'; * Next.js Middleware 함수로, 인증 상태에 따라 사용자의 접근을 제어합니다. * * - 로그인/회원가입 페이지에 접근 시 이미 accessToken 또는 refreshToken이 존재하면 메인 페이지로 리디렉트 + * 👉 이 조건은 accessToken이 있다고 무조건 로그인 상태로 판단되어 버튼 클릭이 막히는 문제가 있어 제거 또는 완화함 + * * - 보호된 페이지(`/mypage` 하위) 접근 시 토큰이 모두 없으면 로그인 페이지로 리디렉트 * - 그 외에는 요청을 그대로 통과시킴 * @@ -16,13 +18,10 @@ export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl; - // 로그인/회원가입 페이지 접근 시 이미 로그인 상태면 메인으로 리디렉트 - if ( - (pathname === '/login' || pathname === '/signup') && - (accessToken || refreshToken) - ) { - return NextResponse.redirect(new URL('/', request.url)); - } + // 기존 코드에서 로그인/회원가입 접근 차단 조건 제거 + // 이유: accessToken이 있어도 user 상태는 아직 하이드레이션 전일 수 있어서, + // 버튼이 보이지만 클릭 시 리다이렉트되어 이동이 안 되는 현상이 발생함 + // 따라서 로그인 여부 판단은 클라이언트에서 하고, middleware는 보호 경로만 책임지도록 수정 // 보호 경로 설정 및 검사 const protectedPaths = ['/mypage'];