-
+
{
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onClick();
+ }
+ }}
+ aria-label={`${templestayName} 로 이동`}
+ onDragStart={(e) => e.preventDefault()}>
+
+
+
{templestayName}
@@ -64,12 +72,13 @@ const PopularCard = ({
{templeName}
+
-
+
);
};
diff --git a/src/components/card/popularCard/popularCard.css.ts b/src/components/card/popularCard/popularCard.css.ts
index 8f49f021..d595b513 100644
--- a/src/components/card/popularCard/popularCard.css.ts
+++ b/src/components/card/popularCard/popularCard.css.ts
@@ -1,20 +1,30 @@
import theme from '@styles/theme.css';
import { style } from '@vanilla-extract/css';
-export const cardWrapper = style({
- width: '33.5rem',
- cursor: 'pointer',
+export const container = style({
+ width: '100%',
+ overflow: 'hidden',
+ touchAction: 'pan-y',
});
-export const cardContainer = style({
+export const slideList = style({
display: 'flex',
- flexDirection: 'column',
- gap: '0.8rem',
+ width: '33.5rem',
+ gap: '2rem',
});
-export const templeInfoBox = style({
- display: 'flex',
- flexDirection: 'column',
+export const slideItem = style({
+ minWidth: '100%',
+ height: '100%',
+ cursor: 'pointer',
+});
+
+export const imageWrapper = style({
+ position: 'relative',
+ width: '100%',
+ height: '137px',
+ overflow: 'hidden',
+ borderRadius: 8,
});
export const templestayName = style({
@@ -22,6 +32,12 @@ export const templestayName = style({
textAlign: 'left',
});
+export const slideContent = style({
+ display: 'flex',
+
+ flexDirection: 'column',
+});
+
export const bottomBox = style({
display: 'flex',
color: theme.COLORS.gray8,
@@ -34,17 +50,6 @@ export const likeBtn = style({
padding: '1rem',
});
-export const imgBox = style({
- height: '13.7rem',
- borderRadius: 8,
- display: 'flex',
- justifyContent: 'flex-end',
- color: theme.COLORS.white,
- overflow: 'hidden',
- backgroundPosition: 'center',
- position: 'relative',
-});
-
export const bottomContainer = style({
display: 'flex',
flexDirection: 'column',
diff --git a/src/components/card/popularCard/rankBtn.css.ts b/src/components/card/popularCard/rankBtn.css.ts
index 2b9e278f..02015df3 100644
--- a/src/components/card/popularCard/rankBtn.css.ts
+++ b/src/components/card/popularCard/rankBtn.css.ts
@@ -10,7 +10,8 @@ const rankBox = style({
borderRadius: '0 0 8px 8px',
backgroundColor: theme.COLORS.black60,
...theme.FONTS.c2R14,
- marginRight: '2rem',
+ color: theme.COLORS.white,
+ right: '2rem',
position: 'absolute',
zIndex: 1,
});
diff --git a/src/components/card/templeStayCard/infoSection.css.ts b/src/components/card/templeStayCard/infoSection.css.ts
index d63040ea..54024016 100644
--- a/src/components/card/templeStayCard/infoSection.css.ts
+++ b/src/components/card/templeStayCard/infoSection.css.ts
@@ -60,7 +60,6 @@ export const title = recipe({
variants: {
size: {
default: {
- height: '4.8rem',
...theme.FONTS.h5Sb16,
},
small: {
diff --git a/src/components/carousel/popularCarousel/CarouselIndex.tsx b/src/components/carousel/popularCarousel/CarouselIndex.tsx
index ece63382..73f08d64 100644
--- a/src/components/carousel/popularCarousel/CarouselIndex.tsx
+++ b/src/components/carousel/popularCarousel/CarouselIndex.tsx
@@ -3,16 +3,16 @@ import React from 'react';
interface CarouselIndexProps {
total: number;
- currentIndex: number;
+ displayIndex: number;
}
-const CarouselIndex = ({ total, currentIndex }: CarouselIndexProps) => {
+const CarouselIndex = ({ total, displayIndex }: CarouselIndexProps) => {
return (
{Array.from({ length: total }).map((_, index) => (
))}
diff --git a/src/components/carousel/popularCarousel/PopularCarousel.tsx b/src/components/carousel/popularCarousel/PopularCarousel.tsx
index 85afb7fd..4f2e9ff3 100644
--- a/src/components/carousel/popularCarousel/PopularCarousel.tsx
+++ b/src/components/carousel/popularCarousel/PopularCarousel.tsx
@@ -3,11 +3,11 @@ import { useAddWishlistV2, useRemoveWishlistV2 } from '@apis/wish';
import PopularCard from '@components/card/popularCard/PopularCard';
import CarouselIndex from '@components/carousel/popularCarousel/CarouselIndex';
import ExceptLayout from '@components/except/exceptLayout/ExceptLayout';
-import useCarousel from '@hooks/useCarousel';
+import { useRouter } from 'next/navigation';
import { useQueryClient } from '@tanstack/react-query';
-import registDragEvent from '@utils/registDragEvent';
import { getCookie } from 'cookies-next';
import useEventLogger from 'src/gtm/hooks/useEventLogger';
+import useInfiniteCarousel from '@hooks/useInfiniteCarousel';
import * as styles from './popularCarousel.css';
@@ -19,24 +19,26 @@ const PopularCarousel = ({ onRequireLogin }: PopularCarouselProps) => {
const queryClient = useQueryClient();
const addWishlistMutation = useAddWishlistV2();
const removeWishlistMutation = useRemoveWishlistV2();
+ const { logClickEvent } = useEventLogger('home_popularity_component');
+ const router = useRouter();
const { data, isLoading, isError } = useGetRanking();
- const { currentIndex, carouselRef, transformStyle, handleDragChange, handleDragEnd } =
- useCarousel({
- itemCount: data?.length || 0,
- moveDistance: 355,
- });
-
- const { logClickEvent } = useEventLogger('home_popularity_component');
-
- if (isLoading) {
- return
;
- }
+ const {
+ slides,
+ currentIndex,
+ isAnimate,
+ dragOffset,
+ displayIndex,
+ totalOriginalSlides,
+ isSwiped,
+ handlers,
+ } = useInfiniteCarousel({
+ data: data || [],
+ });
- if (isError) {
- return
;
- }
+ if (isLoading) return
;
+ if (isError) return
;
const handleLikeToggle = (templestayId: number) => {
const userNickname = getCookie('userNickname');
@@ -59,39 +61,41 @@ const PopularCarousel = ({ onRequireLogin }: PopularCarouselProps) => {
};
return (
-
-
-
- {data &&
- data.map((temple) => (
-
{
- logClickEvent('click_popularity_card', {
- label: temple.id,
- });
- }}
- />
- ))}
-
+
+
+ {slides.map((temple, index) => (
+
{
+ if (isSwiped) return;
+
+ logClickEvent('click_popularity_card', {
+ label: temple.id,
+ });
+
+ router.push(`/detail/${temple.id}`);
+ }}
+ priority={index === 1}
+ />
+ ))}
-
-
+
+
+
);
};
diff --git a/src/components/carousel/popularCarousel/popularCarousel.css.ts b/src/components/carousel/popularCarousel/popularCarousel.css.ts
index e17c1252..5d45f697 100644
--- a/src/components/carousel/popularCarousel/popularCarousel.css.ts
+++ b/src/components/carousel/popularCarousel/popularCarousel.css.ts
@@ -1,30 +1,46 @@
+import theme from '@styles/theme.css';
+
import { style } from '@vanilla-extract/css';
-export const carouselWrapper = style({
+export const container = style({
+ width: '100%',
+ overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
- justifyContent: 'center',
- gap: '1.5rem',
});
-export const carouselContainer = style({
- width: '33.5rem',
- overflow: 'hidden',
+export const slideList = style({
display: 'flex',
+ width: '33.5rem',
+ gap: '2rem',
});
-export const carouselBox = style({
- display: 'flex',
- gap: '2rem',
+export const slideItem = style({
+ minWidth: '100%',
+ height: '100%',
+ cursor: 'pointer',
+});
+
+export const imageWrapper = style({
+ position: 'relative',
+
+ width: '100%',
+
+ height: '137px',
+
+ overflow: 'hidden',
+
+ borderRadius: 8,
});
-export const carouselItem = style({
- flexShrink: 0,
+export const templestayName = style({
+ ...theme.FONTS.h3Sb18,
+ textAlign: 'left',
});
-export const emptyBox = style({
- width: '2rem',
- height: '100%',
- flexShrink: 0,
+export const slideContent = style({
+ display: 'flex',
+
+ flexDirection: 'column',
});
diff --git a/src/components/common/button/buttonBar/ButtonBar.tsx b/src/components/common/button/buttonBar/ButtonBar.tsx
index 541c78b1..7d75b928 100644
--- a/src/components/common/button/buttonBar/ButtonBar.tsx
+++ b/src/components/common/button/buttonBar/ButtonBar.tsx
@@ -1,8 +1,9 @@
-import buttonBarContainer from '@components/common/button/buttonBar/buttonBar.css';
import FlowerBtn from '@components/common/button/flowerBtn/FlowerBtn';
import PageBottomBtn from '@components/common/button/pageBottomBtn/PageBottomBtn';
import TextBtn from '@components/common/button/textBtn/TextBtn';
+import { buttonBarWrapper, buttonBarContainer } from './buttonBar.css';
+
interface ButtonBarProps {
type: 'reset' | 'wish';
label: string;
@@ -10,6 +11,7 @@ interface ButtonBarProps {
handleResetFilter?: () => void;
liked?: boolean;
onToggleWishlist?: () => void;
+ isDisabled?: boolean;
}
const ButtonBar = ({
@@ -19,6 +21,7 @@ const ButtonBar = ({
handleResetFilter = () => {},
liked,
onToggleWishlist,
+ isDisabled = false,
}: ButtonBarProps) => {
const renderLeftButton = () =>
type === 'wish' ? (
@@ -34,11 +37,17 @@ const ButtonBar = ({
);
return (
-
- {renderLeftButton()}
-
+
+
+ {renderLeftButton()}
+
+
);
};
-
export default ButtonBar;
diff --git a/src/components/common/button/buttonBar/buttonBar.css.ts b/src/components/common/button/buttonBar/buttonBar.css.ts
index 890a3dee..22f68932 100644
--- a/src/components/common/button/buttonBar/buttonBar.css.ts
+++ b/src/components/common/button/buttonBar/buttonBar.css.ts
@@ -1,23 +1,24 @@
import theme from '@styles/theme.css';
import { style } from '@vanilla-extract/css';
-const buttonBarContainer = style({
+export const buttonBarWrapper = style({
position: 'fixed',
bottom: 0,
- left: '50%',
- transform: 'translateX(-50%)',
+ width: '100%',
+ display: 'flex',
+ justifyContent: 'center',
zIndex: 100,
+});
+export const buttonBarContainer = style({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '37.5rem',
+ maxWidth: '100%',
height: '7.2rem',
padding: '1rem 2rem',
boxSizing: 'border-box',
background: theme.COLORS.white,
-
boxShadow: `0px -4px 16px 0px rgba(0, 0, 0, 0.05)`,
});
-
-export default buttonBarContainer;
diff --git a/src/components/filter/filterBottomSheetModal/FilterModalContent.tsx b/src/components/filter/filterBottomSheetModal/FilterModalContent.tsx
index 1872ad69..3e783d98 100644
--- a/src/components/filter/filterBottomSheetModal/FilterModalContent.tsx
+++ b/src/components/filter/filterBottomSheetModal/FilterModalContent.tsx
@@ -93,9 +93,10 @@ const FilterModalContent = ({ onComplete, scrollRef, searchText }: Props) => {
>
);
diff --git a/src/components/filter/filterBottomSheetModal/filterModalContent.css.ts b/src/components/filter/filterBottomSheetModal/filterModalContent.css.ts
index 6d49e37d..cb623482 100644
--- a/src/components/filter/filterBottomSheetModal/filterModalContent.css.ts
+++ b/src/components/filter/filterBottomSheetModal/filterModalContent.css.ts
@@ -13,4 +13,10 @@ export const main = style({
overflowY: 'auto',
maxHeight: 'calc(100vh - 280px)',
height: '100vh',
+
+ selectors: {
+ '&::-webkit-scrollbar': {
+ display: 'none',
+ },
+ },
});
diff --git a/src/components/search/searchBar/SearchBar.tsx b/src/components/search/searchBar/SearchBar.tsx
index c4b3d817..a55269b5 100644
--- a/src/components/search/searchBar/SearchBar.tsx
+++ b/src/components/search/searchBar/SearchBar.tsx
@@ -3,17 +3,25 @@
import Icon from '@assets/svgs';
import useFilter from '@hooks/useFilter';
import { usePathname } from 'next/navigation';
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import useEventLogger from 'src/gtm/hooks/useEventLogger';
import * as styles from './searchBar.css';
interface SearchBarProps {
searchText?: string;
+ inputAutoFocus?: boolean;
}
-const SearchBar = ({ searchText }: SearchBarProps) => {
+const SearchBar = ({ searchText, inputAutoFocus = false }: SearchBarProps) => {
const [inputValue, setInputValue] = useState(searchText || '');
+ const inputRef = useRef
(null);
+
+ useEffect(() => {
+ if (inputAutoFocus && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, []);
const { handleSearch } = useFilter();
const { logClickEvent } = useEventLogger('search_bar');
@@ -27,8 +35,11 @@ const SearchBar = ({ searchText }: SearchBarProps) => {
const handleClearInput = () => {
setInputValue('');
-
logClickEvent('click_delete', { label: inputValue });
+
+ if (inputRef.current) {
+ inputRef.current.focus();
+ }
};
const handleClickSearch = () => {
@@ -66,6 +77,7 @@ const SearchBar = ({ searchText }: SearchBarProps) => {
{
+const SearchHeader = ({ searchText, prevPath, inputAutoFocus = false }: SearchHeader) => {
const handleToBack = useNavigateTo(prevPath);
return (
@@ -16,7 +17,7 @@ const SearchHeader = ({ searchText, prevPath }: SearchHeader) => {
-
+
);
};
diff --git a/src/hooks/useFilter.ts b/src/hooks/useFilter.ts
index e4b20a85..cfeba1be 100644
--- a/src/hooks/useFilter.ts
+++ b/src/hooks/useFilter.ts
@@ -1,9 +1,10 @@
import useLocalStorage from '@hooks/useLocalStorage';
import { getCookie } from 'cookies-next';
+import { useSetAtom } from 'jotai';
import { useRouter } from 'next/navigation';
import { useCallback } from 'react';
import queryClient from 'src/queryClient';
-import { filterListInstance } from 'src/store/store';
+import { filterListInstance, priceAtom } from 'src/store/store';
type FilterQueryParams = {
region?: string[];
@@ -22,6 +23,7 @@ const isLoggedIn = getCookie('userNickname');
const useFilter = () => {
const { addStorageValue } = useLocalStorage();
+ const setPrice = useSetAtom(priceAtom);
// 필터 상태 토글
const toggleFilter = async (filterName: string) => {
@@ -37,6 +39,9 @@ const useFilter = () => {
const handleSearch = useCallback(
(params: FilterQueryParams = {}) => {
+ filterListInstance.resetAllStates();
+ setPrice({ minPrice: 0, maxPrice: 30 });
+
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
diff --git a/src/hooks/useInfiniteCarousel.ts b/src/hooks/useInfiniteCarousel.ts
new file mode 100644
index 00000000..7d87892b
--- /dev/null
+++ b/src/hooks/useInfiniteCarousel.ts
@@ -0,0 +1,146 @@
+import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
+
+interface UseInfiniteCarouselProps
{
+ data: T[];
+ autoPlayInterval?: number;
+}
+
+const useInfiniteCarousel = ({ data, autoPlayInterval }: UseInfiniteCarouselProps) => {
+ const totalOriginalSlides = data.length;
+ const hasSlides = totalOriginalSlides > 0;
+
+ const [currentIndex, setCurrentIndex] = useState(1);
+ const [isAnimate, setIsAnimate] = useState(true);
+ const [isDragging, setIsDragging] = useState(false);
+ const [startX, setStartX] = useState(0);
+ const [startY, setStartY] = useState(0);
+ const [currentX, setCurrentX] = useState(0);
+
+ const timerRef = useRef(null);
+
+ const slides = useMemo(() => {
+ if (!hasSlides) return [];
+
+ return [
+ { ...data[data.length - 1], uniqueKey: 'clone-last' },
+ ...data.map((item, idx) => ({ ...item, uniqueKey: `real-${idx}` })),
+ { ...data[0], uniqueKey: 'clone-first' },
+ ];
+ }, [data, hasSlides]);
+
+ const moveNext = useCallback(() => {
+ if (!hasSlides) return;
+
+ setIsAnimate(true);
+ setCurrentIndex((prev) => prev + 1);
+ }, [hasSlides]);
+
+ const movePrev = useCallback(() => {
+ if (!hasSlides) return;
+
+ setIsAnimate(true);
+ setCurrentIndex((prev) => prev - 1);
+ }, [hasSlides]);
+
+ const stopAutoSlide = useCallback(() => {
+ if (timerRef.current) clearInterval(timerRef.current);
+ }, []);
+
+ const startAutoSlide = useCallback(() => {
+ if (!autoPlayInterval || !hasSlides) return;
+
+ stopAutoSlide();
+ timerRef.current = setInterval(moveNext, autoPlayInterval);
+ }, [moveNext, stopAutoSlide, autoPlayInterval, hasSlides]);
+
+ useEffect(() => {
+ if (!autoPlayInterval || !hasSlides) return;
+
+ startAutoSlide();
+ return () => stopAutoSlide();
+ }, [startAutoSlide, stopAutoSlide, autoPlayInterval, hasSlides]);
+
+ const handleTransitionEnd = () => {
+ if (!hasSlides) return;
+
+ if (currentIndex === 0) {
+ setIsAnimate(false);
+ setCurrentIndex(totalOriginalSlides);
+ } else if (currentIndex === slides.length - 1) {
+ setIsAnimate(false);
+ setCurrentIndex(1);
+ }
+ };
+
+ const handleDragStart = (clientX: number, clientY = 0) => {
+ if (!hasSlides) return;
+
+ stopAutoSlide();
+ setIsDragging(true);
+ setStartX(clientX);
+ setStartY(clientY);
+ setCurrentX(clientX);
+ setIsAnimate(false);
+ };
+
+ const handleDragMove = (clientX: number, clientY = 0) => {
+ if (!isDragging) return;
+
+ const diffX = Math.abs(clientX - startX);
+ const diffY = Math.abs(clientY - startY);
+ if (diffY > diffX && diffY > 10) {
+ setIsDragging(false);
+ return;
+ }
+
+ setCurrentX(clientX);
+ };
+
+ const handleDragEnd = () => {
+ if (!isDragging) return;
+
+ const diff = currentX - startX;
+ const threshold = 50;
+
+ setIsDragging(false);
+ if (autoPlayInterval) startAutoSlide();
+
+ if (diff < -threshold) moveNext();
+ else if (diff > threshold) movePrev();
+ else setIsAnimate(true);
+ };
+
+ const handlers = {
+ onMouseDown: (e: React.MouseEvent) => handleDragStart(e.clientX),
+ onMouseMove: (e: React.MouseEvent) => handleDragMove(e.clientX),
+ onMouseUp: handleDragEnd,
+ onMouseLeave: handleDragEnd,
+ onTouchStart: (e: React.TouchEvent) =>
+ handleDragStart(e.touches[0].clientX, e.touches[0].clientY),
+ onTouchMove: (e: React.TouchEvent) =>
+ handleDragMove(e.touches[0].clientX, e.touches[0].clientY),
+ onTouchEnd: handleDragEnd,
+ onTransitionEnd: handleTransitionEnd,
+ };
+
+ const displayIndex = !hasSlides
+ ? 0
+ : currentIndex === 0
+ ? totalOriginalSlides
+ : currentIndex === slides.length - 1
+ ? 1
+ : currentIndex;
+
+ return {
+ slides,
+ currentIndex: hasSlides ? currentIndex : 0,
+ isAnimate: hasSlides ? isAnimate : false,
+ dragOffset: isDragging ? currentX - startX : 0,
+ displayIndex,
+ totalOriginalSlides,
+ isSwiped: hasSlides && Math.abs(currentX - startX) > 5,
+ handlers: hasSlides ? handlers : {},
+ };
+};
+
+export default useInfiniteCarousel;
diff --git a/src/stories/CarouselIndex.stories.ts b/src/stories/CarouselIndex.stories.ts
index 12937cd9..e1936ef1 100644
--- a/src/stories/CarouselIndex.stories.ts
+++ b/src/stories/CarouselIndex.stories.ts
@@ -12,13 +12,13 @@ const meta = {
total: {
control: { type: 'number' },
},
- currentIndex: {
+ displayIndex: {
control: { type: 'number' },
},
},
args: {
total: 3,
- currentIndex: 2,
+ displayIndex: 2,
},
} satisfies Meta;
diff --git a/src/stories/PopularCard.stories.ts b/src/stories/PopularCard.stories.ts
index 8bc33195..0ebb7dcc 100644
--- a/src/stories/PopularCard.stories.ts
+++ b/src/stories/PopularCard.stories.ts
@@ -39,7 +39,6 @@ const meta = {
templeImg:
'https://img.danawa.com/images/descFiles/6/110/5109431_agiLaciMHn_1659098198501.jpeg',
templeName: '봉선사',
- link: 'https://www.gototemplestay.com/',
isLiked: false,
templestayId: 123,
onLikeToggle: (id) => alert(`Toggled like for ID: ${id}`),
diff --git a/src/styles/global.css.ts b/src/styles/global.css.ts
index 490d956b..30f0726c 100644
--- a/src/styles/global.css.ts
+++ b/src/styles/global.css.ts
@@ -4,6 +4,14 @@ globalStyle('*', {
boxSizing: 'border-box',
});
+globalStyle('::-webkit-scrollbar', {
+ display: 'none',
+});
+
+globalStyle('html', {
+ paddingRight: '0px !important',
+});
+
globalStyle('html, body', {
width: '100%',
fontSize: '62.5%',