diff --git a/apps/nowait-user/src/assets/icon/cancel.svg b/apps/nowait-user/src/assets/icon/cancel.svg index b3dfb7df..2767b326 100644 --- a/apps/nowait-user/src/assets/icon/cancel.svg +++ b/apps/nowait-user/src/assets/icon/cancel.svg @@ -1,4 +1,4 @@ - - + + diff --git a/apps/nowait-user/src/assets/icon/search.svg b/apps/nowait-user/src/assets/icon/search.svg index adcd7125..0cae67f4 100644 --- a/apps/nowait-user/src/assets/icon/search.svg +++ b/apps/nowait-user/src/assets/icon/search.svg @@ -1,3 +1,3 @@ - - + + diff --git a/apps/nowait-user/src/components/Header.tsx b/apps/nowait-user/src/components/Header.tsx index 1e9016d2..f05eb8b2 100644 --- a/apps/nowait-user/src/components/Header.tsx +++ b/apps/nowait-user/src/components/Header.tsx @@ -3,12 +3,14 @@ import { AnimatePresence, motion } from "framer-motion"; import { useNavigate } from "react-router-dom"; import Logo from "../assets/logo.svg?react"; import Menu from "../assets/icon/menu.svg?react"; -import Search from "../assets/icon/search_black.svg?react"; +import Search from "../assets/icon/search.svg?react"; import Cancel from "../assets/icon/cancel.svg?react"; import Portal from "./common/modal/Portal"; +import SearchModal from "./common/modal/SearchModal"; const HomeHeader = () => { const [isMenuOpen, setIsMenuOpen] = useState(false); + const [isSearchOpen, setIsSearchOpen] = useState(false); const navigate = useNavigate(); const toggleMenu = () => { @@ -19,6 +21,14 @@ const HomeHeader = () => { setIsMenuOpen(false); }; + const openSearch = () => { + setIsSearchOpen(true); + }; + + const closeSearch = () => { + setIsSearchOpen(false); + }; + const handleBookmarkClick = () => { closeMenu(); navigate("/bookmark"); @@ -34,7 +44,7 @@ const HomeHeader = () => {
- + + + {/* 검색 내용 영역 */} + + {searchQuery.trim() ? ( + // 검색 결과 표시 +
+
+ 검색 결과{" "} + {searchResults.length > 0 && `${searchResults.length}`} +
+ {isSearching ? ( +
+
+ 검색 중... +
+
+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map((store) => ( + + ))} +
+ ) : ( +
+
+ 검색 결과가 없습니다 +
+
+ 다른 키워드로 검색해보세요 +
+
+ )} +
+ ) : ( + // 최근 검색 표시 +
+
+ 최근 검색 +
+ {recentSearches.length > 0 ? ( + recentSearches.map((searchTerm, index) => ( +
+ +
+ +
+
+ )) + ) : ( +
+ 최근 검색어가 없습니다 +
+ )} +
+ )} +
+
+ + )} + + + ); +}; + +export default SearchModal; diff --git a/apps/nowait-user/src/global.css b/apps/nowait-user/src/global.css index 8c273798..cd22f186 100644 --- a/apps/nowait-user/src/global.css +++ b/apps/nowait-user/src/global.css @@ -103,6 +103,28 @@ body { } } +@keyframes number-slide-up { + 0% { + transform: translateY(40%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes number-slide-down { + 0% { + transform: translateY(-40%); + opacity: 0; + } + 100% { + transform: translateY(0); + opacity: 1; + } +} + @keyframes slide-up-out { 0% { transform: translateY(0); @@ -145,6 +167,14 @@ body { animation: slide-down-out 0.3s ease-out forwards; } +.animate-number-slide-up { + animation: number-slide-up 0.3s ease-out forwards; +} + +.animate-number-slide-down { + animation: number-slide-down 0.3s ease-out forwards; +} + @media (prefers-reduced-motion: no-preference) { a:nth-of-type(2) .logo { animation: logo-spin infinite 20s linear; @@ -202,7 +232,7 @@ body { max-width: 11.25rem; max-height: 17px; color: var(--navy-70); - background-color: #F2F6F9; + background-color: #f2f6f9; padding: unset !important; justify-content: center; text-align: center; diff --git a/apps/nowait-user/src/hooks/useInfiniteStores.ts b/apps/nowait-user/src/hooks/useInfiniteStores.ts index 5a095ed1..4eb7ef2d 100644 --- a/apps/nowait-user/src/hooks/useInfiniteStores.ts +++ b/apps/nowait-user/src/hooks/useInfiniteStores.ts @@ -33,7 +33,7 @@ const fetchStores = async ({ try { // UserApi 사용으로 헤더 설정 자동화 (인터셉터에서 최신 토큰 처리) const response = await UserApi.get( - "/v1/stores/all-stores/infinite-scroll", + "/v1/stores/all-stores", { params: { page: pageParam, diff --git a/apps/nowait-user/src/pages/home/components/MyWaitingDetail.tsx b/apps/nowait-user/src/pages/home/components/MyWaitingDetail.tsx index 23eb4f7a..855eadf7 100644 --- a/apps/nowait-user/src/pages/home/components/MyWaitingDetail.tsx +++ b/apps/nowait-user/src/pages/home/components/MyWaitingDetail.tsx @@ -19,13 +19,36 @@ const MyWaitingDetail = ({ // 현재 활성 카드 인덱스 상태 const [currentIndex, setCurrentIndex] = useState(0); - const [prevIndex, setPrevIndex] = useState(0); + const [scrollOffset, setScrollOffset] = useState(0); + const [animationKey, setAnimationKey] = useState(0); const [animationDirection, setAnimationDirection] = useState< "up" | "down" | null >(null); - const [isAnimating, setIsAnimating] = useState(false); - const [scrollOffset, setScrollOffset] = useState(0); const scrollContainerRef = useRef(null); + const prevIndexRef = useRef(0); + + // 숫자 애니메이션 효과 + useEffect(() => { + if (currentIndex !== prevIndexRef.current) { + // 슬라이드 방향 판단 + const isRightSlide = currentIndex > prevIndexRef.current; + + // 애니메이션 방향 설정 + setAnimationDirection(isRightSlide ? "up" : "down"); + + // 강제 리렌더링을 위한 키 변경 + setAnimationKey((prev) => prev + 1); + + // 애니메이션 완료 후 방향 리셋 + const timer = setTimeout(() => { + setAnimationDirection(null); + }, 300); + + prevIndexRef.current = currentIndex; + + return () => clearTimeout(timer); + } + }, [currentIndex]); // 스크롤 이벤트 핸들러 useEffect(() => { @@ -44,23 +67,7 @@ const MyWaitingDetail = ({ setScrollOffset(offset); if (boundedIndex !== currentIndex) { - setPrevIndex(currentIndex); - - // 애니메이션 방향 결정 - if (boundedIndex > currentIndex) { - setAnimationDirection("up"); - } else { - setAnimationDirection("down"); - } - - setIsAnimating(true); setCurrentIndex(boundedIndex); - - // 애니메이션 완료 후 상태 초기화 - setTimeout(() => { - setIsAnimating(false); - setAnimationDirection(null); - }, 300); } }; @@ -68,91 +75,6 @@ const MyWaitingDetail = ({ return () => container.removeEventListener("scroll", handleScroll); }, [items.length, currentIndex]); - // 숫자 애니메이션 컴포넌트 (각 자릿수별 애니메이션) - const AnimatedDigits = ({ - value, - prevValue, - direction, - isAnimating, - }: { - value: number; - prevValue: number; - direction: "up" | "down" | null; - isAnimating: boolean; - }) => { - // 숫자를 문자열로 변환 후 각 자릿수로 분리 - const currentDigits = value.toString().split("").map(Number); - const prevDigits = prevValue.toString().split("").map(Number); - - // 단일 자릿수 애니메이션 컴포넌트 - const SingleDigit = ({ - digit, - prevDigit, - digitIndex, - totalDigits, - }: { - digit: number; - prevDigit: number; - digitIndex: number; - totalDigits: number; - }) => { - // 오른쪽부터 애니메이션 (delay 계산: 일의자리가 먼저, 십의자리가 나중에) - const reverseIndex = totalDigits - 1 - digitIndex; - const animationDelay = reverseIndex * 100; // 100ms씩 지연 - - // 이 자릿수가 실제로 변경되었는지 확인 - const isDigitChanged = digit !== prevDigit; - const shouldAnimate = isAnimating && isDigitChanged; - - return ( - - - {digit} - - {shouldAnimate && ( - - {prevDigit} - - )} - - ); - }; - - return ( - - {currentDigits.map((digit, index) => ( - - ))} - - ); - }; - return (
@@ -176,12 +98,18 @@ const MyWaitingDetail = ({
내 앞 대기 - + + {items[currentIndex]?.waitingCount || 0} +
diff --git a/apps/nowait-user/src/types/search.ts b/apps/nowait-user/src/types/search.ts new file mode 100644 index 00000000..a05151a8 --- /dev/null +++ b/apps/nowait-user/src/types/search.ts @@ -0,0 +1,18 @@ +export interface SearchStore { + storeId: number; + departmentId: number; + departmentName: string; + name: string; + location: string; + description: string; + profileImage: string | null; + bannerImages: any[]; + isActive: boolean; + deleted: boolean; + createdAt: string; +} + +export interface SearchResponse { + success: boolean; + response: SearchStore[]; +} diff --git a/apps/nowait-user/src/utils/UserApi.tsx b/apps/nowait-user/src/utils/UserApi.tsx index 9e955f3e..cbef028a 100644 --- a/apps/nowait-user/src/utils/UserApi.tsx +++ b/apps/nowait-user/src/utils/UserApi.tsx @@ -63,6 +63,7 @@ const refreshToken = async (): Promise => { } localStorage.removeItem("accessToken"); + localStorage.removeItem("recentSearches"); return null; } }; @@ -89,14 +90,36 @@ UserApi.interceptors.response.use( async (error) => { const originalRequest = error.config; - // 토큰 만료 에러 체크 (401 상태 코드 또는 "access token expired" 메시지) - if ( - error.response?.status === 401 || - (error.response?.data && - (error.response.data.includes?.("access token expired") || - error.response.data.message?.includes?.("access token expired") || - error.response.data === "access token expired")) - ) { + // 토큰 갱신 조건: access token 만료이지만 refresh token은 정상일 때만 + const isAccessTokenExpired = + error.response?.data && + (error.response.data.includes?.("expired access token") || + error.response.data.message?.includes?.("expired access token") || + error.response.data === "expired access token"); + + // refresh token 문제가 있는 경우 토큰 갱신하지 않음 + const isRefreshTokenInvalid = + error.response?.data && + (error.response.data.includes?.("expired refresh token") || + error.response.data.message?.includes?.("expired refresh token") || + error.response.data.includes?.("invalid refresh token") || + error.response.data.message?.includes?.("invalid refresh token") || + error.response.data.includes?.("Invalid or expired refresh token") || + error.response.data.message?.includes?.( + "Invalid or expired refresh token" + )); + + // refresh token에 문제가 있으면 바로 로그인 페이지로 이동 + if (isRefreshTokenInvalid) { + console.log("Refresh token 문제 감지 - 재로그인 필요"); + localStorage.removeItem("accessToken"); + localStorage.removeItem("recentSearches"); + window.location.href = "/login"; + return Promise.reject(error); + } + + // access token만 만료된 경우에만 토큰 갱신 시도 + if (isAccessTokenExpired) { // 이미 재시도한 요청이면 더 이상 재시도하지 않음 if (originalRequest._retry) { console.log("토큰 갱신 재시도 실패, 로그인 페이지로 이동"); @@ -123,6 +146,7 @@ UserApi.interceptors.response.use( } catch (refreshError) { console.log("토큰 갱신 중 오류 발생:", refreshError); localStorage.removeItem("accessToken"); + localStorage.removeItem("recentSearches"); window.location.href = "/login"; return Promise.reject(error); }