From 51cf9428b2f1ce3c69028d8d2106bc06023b04b9 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Thu, 16 Jul 2026 09:00:11 +0900 Subject: [PATCH 01/12] =?UTF-8?q?refactor:=20access=20token=20JWT=EC=97=90?= =?UTF-8?q?=EC=84=9C=20role=20=EC=B6=94=EC=B6=9C=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9C=A0=ED=8B=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/utils/auth.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index fd8f8936..5fee1a15 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -1,3 +1,5 @@ +import type { UserIdentityTypeT } from '@/types/user'; + /** JWT 토큰 페이로드 추출 */ const decodeJwt = (token: string) => { try { @@ -29,3 +31,11 @@ export const isTokenValid = (token: string) => { return currentTime < expiryTime; }; + +/** access token 의 role(GUEST/MEMBER) 추출 — 만료·손상 토큰은 null */ +export const getRoleFromToken = (token?: string): UserIdentityTypeT | null => { + if (!token || !isTokenValid(token)) return null; + + const payload = decodeJwt(token); + return (payload?.role as UserIdentityTypeT | undefined) ?? null; +}; From 002f2c8b7db9be282b17687bd49b21d602668756 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Thu, 16 Jul 2026 09:00:50 +0900 Subject: [PATCH 02/12] =?UTF-8?q?refactor:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EA=B2=80=EC=82=AC(getMe)=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=ED=95=98=EA=B3=A0=20JWT=20role=20=EA=B8=B0=EB=B0=98?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/login/layout.tsx | 23 ----------------------- apps/web/src/app/login/page.tsx | 19 +++++++++++-------- 2 files changed, 11 insertions(+), 31 deletions(-) delete mode 100644 apps/web/src/app/login/layout.tsx diff --git a/apps/web/src/app/login/layout.tsx b/apps/web/src/app/login/layout.tsx deleted file mode 100644 index 62796a52..00000000 --- a/apps/web/src/app/login/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; -import { redirect } from 'next/navigation'; - -import { getMe } from '@/apis/getMe'; -import { ROUTES } from '@/consts/route'; -import { getQueryClient } from '@/utils/queryClient'; - -async function LoginLayout({ children }: { children: React.ReactNode }) { - const queryClient = getQueryClient(); - const user = await queryClient - .fetchQuery({ - queryKey: ['me'], - queryFn: getMe, - }) - .catch(() => null); - - /** 로그인 페이지에 유효한 멤버 접근 시 홈으로 리다이렉트 */ - if (user?.identityType === 'MEMBER') redirect(ROUTES.HOME); - - return {children}; -} - -export default LoginLayout; diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 965d12a7..b8751553 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -1,11 +1,12 @@ import { WEBVIEW_UA_TOKEN } from '@piki/core'; -import { headers } from 'next/headers'; +import { cookies, headers } from 'next/headers'; import Link from 'next/link'; +import { redirect } from 'next/navigation'; -import { getMe } from '@/apis/getMe'; import PikiLogo from '@/assets/images/piki-logo.svg'; +import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; -import { getQueryClient } from '@/utils/queryClient'; +import { getRoleFromToken } from '@/utils/auth'; import LoginButtons from './_components/LoginButtons'; @@ -16,11 +17,13 @@ type LoginPageProps = { async function LoginPage({ searchParams }: LoginPageProps) { const { redirect: redirectParam, action } = await searchParams; - /** 게스트 세션 재활용 가능 여부 판단 */ - const user = await getQueryClient() - .fetchQuery({ queryKey: ['me'], queryFn: getMe }) - .catch(() => null); - const canReuseGuestSession = user?.identityType === 'GUEST'; + /** 멤버가 로그인 페이지 직접 진입 시 홈으로 리다이렉트 */ + const accessToken = (await cookies()).get('access_token')?.value; + const role = getRoleFromToken(accessToken); + if (role === 'MEMBER' && action !== QUERY_ACTION.VALUE.SESSION_EXPIRED) redirect(ROUTES.HOME); + + /** 살아있는 게스트 세션 재활용 가능 여부 */ + const canReuseGuestSession = role === 'GUEST'; /** * Android 웹뷰에서는 Apple 로그인 미노출. From 4b05f6f3295e63368092977843b259a8e6e9e5e2 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Thu, 16 Jul 2026 09:09:15 +0900 Subject: [PATCH 03/12] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=84=B8=EC=85=98=20=EC=9E=AC=ED=99=9C=EC=9A=A9=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20=ED=81=B4=EB=A6=AD=20=EC=8B=9C=EC=A0=90=20?= =?UTF-8?q?refresh=EB=A1=9C=20=EC=9D=BC=EC=9B=90=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/login/_components/LoginButtons.tsx | 36 +++++++++---------- apps/web/src/app/login/page.tsx | 4 --- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/apps/web/src/app/login/_components/LoginButtons.tsx b/apps/web/src/app/login/_components/LoginButtons.tsx index 65e2596e..ad432cc2 100644 --- a/apps/web/src/app/login/_components/LoginButtons.tsx +++ b/apps/web/src/app/login/_components/LoginButtons.tsx @@ -28,18 +28,11 @@ import SocialLoginButton from './SocialLoginButton'; type LoginButtonsProps = { redirect: string | null; action: string | null; - /** 살아있는 게스트 세션 보유 여부 — 서버에서 httpOnly 쿠키 확인 후 전달 */ - canReuseGuestSession: boolean; /** Android 웹뷰에서는 false — 네이티브 Apple 로그인이 iOS 전용이라 미노출 */ showAppleLogin: boolean; }; -function LoginButtons({ - redirect, - action, - canReuseGuestSession, - showAppleLogin, -}: LoginButtonsProps) { +function LoginButtons({ redirect, action, showAppleLogin }: LoginButtonsProps) { const router = useRouter(); const validRedirect = isValidLoginRedirectPath(redirect) ? redirect : null; @@ -106,21 +99,24 @@ function LoginButtons({ const handleGoogleLogin = () => handleSocialLogin('google'); const handleAppleLogin = () => handleSocialLogin('apple'); + /** + * 게스트 로그인 + * + * - 기존 게스트 세션 재활용 시도 + * - 재활용 불가 시 새 게스트 발급 + */ const handleGuestLogin = async () => { setLoginRedirectPath(validRedirect); - /** 살아있는 게스트 세션이면 토큰만 갱신 */ - if (canReuseGuestSession) { - setIsGuestRefreshing(true); - try { - await refreshClientToken(); - router.replace(getPostLoginRedirectPath()); - return; - } catch { - /** 갱신 실패 시 새 게스트 발급 */ - } finally { - setIsGuestRefreshing(false); - } + setIsGuestRefreshing(true); + try { + await refreshClientToken(); + router.replace(getPostLoginRedirectPath()); + return; + } catch { + /** 세션 재활용 불가 */ + } finally { + setIsGuestRefreshing(false); } postGuestLoginMutation(); diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index b8751553..65e9573a 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -22,9 +22,6 @@ async function LoginPage({ searchParams }: LoginPageProps) { const role = getRoleFromToken(accessToken); if (role === 'MEMBER' && action !== QUERY_ACTION.VALUE.SESSION_EXPIRED) redirect(ROUTES.HOME); - /** 살아있는 게스트 세션 재활용 가능 여부 */ - const canReuseGuestSession = role === 'GUEST'; - /** * Android 웹뷰에서는 Apple 로그인 미노출. * 앱의 Apple 로그인은 iOS 전용 네이티브 모듈(expo-apple-authentication)로 처리되어 @@ -46,7 +43,6 @@ async function LoginPage({ searchParams }: LoginPageProps) { From 7497efbf0c0740a1416e1c18da760594c5e6e974 Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Thu, 16 Jul 2026 10:27:54 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20=EB=A3=A8=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=ED=94=8C=EB=9E=98=EC=8B=9C=20=EB=B0=B0=EA=B2=BD=20FOUC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/_components/SplashClient.tsx | 9 ++++++++- apps/web/src/app/_components/splash.css | 9 --------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/apps/web/src/app/_components/SplashClient.tsx b/apps/web/src/app/_components/SplashClient.tsx index 4517dd6e..3a218599 100644 --- a/apps/web/src/app/_components/SplashClient.tsx +++ b/apps/web/src/app/_components/SplashClient.tsx @@ -102,7 +102,14 @@ function SplashClient() { return (
{/** * 로그인 페이지와 동일한 레이아웃 앵커. 보이지 않지만 로고가 이동할 최종 좌표를 측정한다. diff --git a/apps/web/src/app/_components/splash.css b/apps/web/src/app/_components/splash.css index 8b728cc9..a9259ac4 100644 --- a/apps/web/src/app/_components/splash.css +++ b/apps/web/src/app/_components/splash.css @@ -13,18 +13,9 @@ animation: splash-fade-in 1.5s ease-in forwards; } -/** SplashClient SPLASH_SHRINK_MS(700)와 동기화 */ -.splash-bg-shift { - transition: background-color 0.7s ease-in-out; -} - @media (prefers-reduced-motion: reduce) { .splash-logo { opacity: 1; animation: none; } - - .splash-bg-shift { - transition: none; - } } From 3abd8f8cd2862924f23dc2d6d80f2d82447f48eb Mon Sep 17 00:00:00 2001 From: Jung Sun A Date: Thu, 16 Jul 2026 10:35:22 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=20=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=C2=B7?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=20fade-in=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/web/src/app/login/page.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/login/page.tsx b/apps/web/src/app/login/page.tsx index 65e9573a..b265bc77 100644 --- a/apps/web/src/app/login/page.tsx +++ b/apps/web/src/app/login/page.tsx @@ -34,12 +34,12 @@ async function LoginPage({ searchParams }: LoginPageProps) {
-

+

{'매일 쌓여만 가던\n위시리스트가 오늘의 결정으로'}

-
+
Date: Thu, 16 Jul 2026 10:53:32 +0900 Subject: [PATCH 06/12] =?UTF-8?q?refactor:=20/archive=20=ED=83=AD=EC=9D=84?= =?UTF-8?q?=20=EC=9C=84=EC=8B=9C=EB=A6=AC=EC=8A=A4=ED=8A=B8=C2=B7=EB=82=B4?= =?UTF-8?q?=20=ED=86=A0=EB=84=88=EB=A8=BC=ED=8A=B8=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20(#347)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: /archive 탭 쿼리 파라미터를 /archive/wish, /archive/tournament 경로로 분리 * refactor: 구버전 /archive(?tab=) 경로를 신규 경로로 리다이렉트 * refactor: 옛 /wish 페이지 잔재 정리 및 archive 콜로케이션 재배치 * fix: 위시 페이지 체류 중 후속 공유 인텐트가 무시되던 문제 수정 * fix: 보관 탭 활성 판정 경로 경계 추가 및 위시 추가 후 중복 라우팅 제거 * fix: 공유 인텐트 실패 URL 잠금 해제 및 링크 담기 실패 시 다이얼로그 유지 --- apps/app/app/+native-intent.ts | 2 +- apps/app/components/ShareBottomSheet.tsx | 2 +- apps/app/hooks/useShareIntent.ts | 18 ++---- apps/web/next.config.mjs | 21 +++++++ apps/web/src/apis/getWishlist.ts | 8 ++- .../_components/ArchivePageLayout.tsx} | 16 ++---- .../src/app/archive/_components/WishTab.tsx | 57 ------------------- .../tournament-history-content/client.tsx | 18 ------ apps/web/src/app/archive/_types/wish.ts | 18 ------ apps/web/src/app/archive/page.tsx | 27 --------- .../_components/ArchiveTournamentClient.tsx | 41 +++++++++++++ .../_components/TournamentHistoryContent.tsx} | 11 ++-- .../_components/TournamentHistoryList.tsx | 9 ++- .../_components/TournamentStatusTab.tsx | 40 +++++++++++++ apps/web/src/app/archive/tournament/page.tsx | 7 +++ .../archive/wish/[id]/_hooks/useDeleteWish.ts | 2 +- .../archive/wish/[id]/_hooks/usePatchWish.ts | 2 +- apps/web/src/app/archive/wish/[id]/layout.tsx | 5 +- .../archive/{ => wish}/_apis/deleteWishes.ts | 0 .../{ => wish}/_components/FabMenu.tsx | 0 .../{ => wish}/_components/WishAddDialog.tsx | 0 .../_components/WishCardSkeleton.tsx | 0 .../_components/WishContent.tsx} | 2 +- .../_components/WishContentClient.tsx} | 14 ++--- .../{ => wish}/_components/WishFab.tsx | 0 .../_components/WishGridContent.tsx} | 12 ++-- .../_components/WishlistBottomBar.tsx | 0 .../_components/WishlistFabArea.tsx | 0 .../{ => wish}/_components/WishlistList.tsx | 4 +- .../_components/wish-grid/WishFailedCard.tsx | 0 .../wish-grid/WishProcessingCard.tsx | 0 .../_components/wish-grid/index.tsx | 6 +- .../{ => wish}/_hooks/useDeleteWishes.ts | 2 +- .../{ => wish}/_hooks/useShareIntentWish.ts | 14 +++-- apps/web/src/app/archive/wish/page.tsx | 12 ++++ .../_utils/getNotificationRoute.ts | 2 +- .../by-wish/_components/WishSelectCard.tsx | 2 +- .../src/components/bottom-tab-bar/index.tsx | 10 +++- .../common/wish-card/index.tsx} | 0 .../get-item-dialog/ByLinkDialog.tsx | 12 ++-- .../tournament-error-dialog/index.tsx | 2 +- apps/web/src/consts/route.ts | 6 +- apps/web/src/hooks/useNotificationSSE.ts | 2 +- apps/web/src/hooks/usePostWishLink.ts | 2 +- apps/web/src/hooks/usePostWishOCR.ts | 5 +- apps/web/src/types/wish.ts | 11 +++- apps/web/src/utils/getRouteType.ts | 3 +- apps/web/src/utils/pushNotificationRoute.ts | 2 +- 48 files changed, 221 insertions(+), 208 deletions(-) rename apps/web/src/app/archive/{_components/WishlistLayout.tsx => _common/_components/ArchivePageLayout.tsx} (72%) delete mode 100644 apps/web/src/app/archive/_components/WishTab.tsx delete mode 100644 apps/web/src/app/archive/_components/tournament-history-content/client.tsx delete mode 100644 apps/web/src/app/archive/_types/wish.ts delete mode 100644 apps/web/src/app/archive/page.tsx create mode 100644 apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx rename apps/web/src/app/archive/{_components/tournament-history-content/index.tsx => tournament/_components/TournamentHistoryContent.tsx} (57%) rename apps/web/src/app/archive/{ => tournament}/_components/TournamentHistoryList.tsx (83%) create mode 100644 apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx create mode 100644 apps/web/src/app/archive/tournament/page.tsx rename apps/web/src/app/archive/{ => wish}/_apis/deleteWishes.ts (100%) rename apps/web/src/app/archive/{ => wish}/_components/FabMenu.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/WishAddDialog.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/WishCardSkeleton.tsx (100%) rename apps/web/src/app/archive/{_components/wish-content/index.tsx => wish/_components/WishContent.tsx} (93%) rename apps/web/src/app/archive/{_components/wish-content/client.tsx => wish/_components/WishContentClient.tsx} (75%) rename apps/web/src/app/archive/{ => wish}/_components/WishFab.tsx (100%) rename apps/web/src/app/archive/{_components/WishlistTabContent.tsx => wish/_components/WishGridContent.tsx} (75%) rename apps/web/src/app/archive/{ => wish}/_components/WishlistBottomBar.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/WishlistFabArea.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/WishlistList.tsx (95%) rename apps/web/src/app/archive/{ => wish}/_components/wish-grid/WishFailedCard.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/wish-grid/WishProcessingCard.tsx (100%) rename apps/web/src/app/archive/{ => wish}/_components/wish-grid/index.tsx (91%) rename apps/web/src/app/archive/{ => wish}/_hooks/useDeleteWishes.ts (97%) rename apps/web/src/app/archive/{ => wish}/_hooks/useShareIntentWish.ts (81%) create mode 100644 apps/web/src/app/archive/wish/page.tsx rename apps/web/src/{app/archive/_components/wish-grid/WishCard.tsx => components/common/wish-card/index.tsx} (100%) diff --git a/apps/app/app/+native-intent.ts b/apps/app/app/+native-intent.ts index dc7ee350..18dcc41a 100644 --- a/apps/app/app/+native-intent.ts +++ b/apps/app/app/+native-intent.ts @@ -20,7 +20,7 @@ export function redirectSystemPath({ path }: { path: string; initial: boolean }) if (webFromUrl) return `/?web=${encodeURIComponent(webFromUrl)}`; try { - /** share extension → openHostApp(`/?web=${encodeURIComponent('/archive?tab=wish')}`) */ + /** share extension → openHostApp(`/?web=${encodeURIComponent('/archive/wish')}`) */ const url = new URL(path, 'piki://app'); const web = url.searchParams.get('web'); diff --git a/apps/app/components/ShareBottomSheet.tsx b/apps/app/components/ShareBottomSheet.tsx index 6050edcb..f95033f0 100644 --- a/apps/app/components/ShareBottomSheet.tsx +++ b/apps/app/components/ShareBottomSheet.tsx @@ -19,7 +19,7 @@ function ShareBottomSheetContent({ url, text }: ShareExtensionProps) { const handleOpenWishlist = () => { /** openHostApp path 규칙: `/{path}?{query}` — `web=...`만 넘기면 `/web=...` 라우트로 해석됨 */ - openHostApp(`/?web=${encodeURIComponent('/archive?tab=wish')}`); + openHostApp(`/?web=${encodeURIComponent('/archive/wish')}`); }; useEffect(() => { diff --git a/apps/app/hooks/useShareIntent.ts b/apps/app/hooks/useShareIntent.ts index be8cbb9e..f563c61d 100644 --- a/apps/app/hooks/useShareIntent.ts +++ b/apps/app/hooks/useShareIntent.ts @@ -5,15 +5,9 @@ import { useCallback, useEffect, useRef } from 'react'; import { toShareIntentPayload } from '@/utils/serializeShareIntent'; import { WebBridge } from '@/utils/webBridge'; -const ARCHIVE_PATH = '/archive'; -const ARCHIVE_WISH_TAB = 'wish'; +const WISHLIST_PATH = '/archive/wish'; -const isArchiveWishTab = (uri: URL) => { - if (uri.pathname !== ARCHIVE_PATH) return false; - - const tab = uri.searchParams.get('tab'); - return tab === null || tab === ARCHIVE_WISH_TAB; -}; +const isWishlistPath = (uri: URL) => uri.pathname === WISHLIST_PATH; type Props = { onChangeWebviewUri: (uri: string) => void; @@ -47,14 +41,14 @@ export const useShareIntent = ({ onChangeWebviewUri, webviewUri }: Props) => { const nextUri = new URL(webviewUri); /** 이미 아카이브 위시 탭: 즉시 전송 */ - if (isArchiveWishTab(nextUri)) { + if (isWishlistPath(nextUri)) { sendShareIntent(); return; } - /** 다른 페이지: payload 보관 후 아카이브 위시 탭으로 이동 → WEB_REQ_READY 수신 시 전송 */ - nextUri.pathname = ARCHIVE_PATH; - nextUri.search = `?tab=${ARCHIVE_WISH_TAB}`; + /** 다른 페이지: payload 보관 후 아카이브 위시로 이동 → WEB_REQ_READY 수신 시 전송 */ + nextUri.pathname = WISHLIST_PATH; + nextUri.search = ''; nextUri.hash = ''; onChangeWebviewUri(nextUri.toString()); } catch { diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index b4f97220..c0f6173c 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -31,6 +31,27 @@ const nextConfig = async () => { ], }, + /** + * 구버전 앱 공유 시트·기존 딥링크의 /archive(?tab=) 경로 호환 + * + * NOTE: 구버전 앱 사라지면 이 설정 제거 필요 + */ + async redirects() { + return [ + { + source: '/archive', + has: [{ type: 'query', key: 'tab', value: 'tournament' }], + destination: '/archive/tournament', + permanent: false, + }, + { + source: '/archive', + destination: '/archive/wish', + permanent: false, + }, + ]; + }, + async rewrites() { return [ { diff --git a/apps/web/src/apis/getWishlist.ts b/apps/web/src/apis/getWishlist.ts index c8802993..3b5c61bf 100644 --- a/apps/web/src/apis/getWishlist.ts +++ b/apps/web/src/apis/getWishlist.ts @@ -4,9 +4,13 @@ import { clientApi } from '@/apis/client'; import { serverApi } from '@/apis/server'; import { ENDPOINTS } from '@/consts/api'; import type { ApiResponseT } from '@/types/api'; +import type { ItemT } from '@/types/item'; +import type { WishItemT, WishT } from '@/types/wish'; -import type { WishlistEntryT } from '../app/archive/_types/wish'; -import type { WishItemT } from '../app/archive/_types/wish'; +type WishlistEntryT = { + wish: WishT; + item: ItemT; +}; type WishlistApiResponseT = ApiResponseT & { pageResponse: { diff --git a/apps/web/src/app/archive/_components/WishlistLayout.tsx b/apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx similarity index 72% rename from apps/web/src/app/archive/_components/WishlistLayout.tsx rename to apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx index 66d5e3e7..3e36f736 100644 --- a/apps/web/src/app/archive/_components/WishlistLayout.tsx +++ b/apps/web/src/app/archive/_common/_components/ArchivePageLayout.tsx @@ -1,14 +1,11 @@ -import { Suspense } from 'react'; - import { Header, HeaderIcon } from '@/components/header'; -import WishTab from './WishTab'; - -type WishlistLayoutProps = { +type Props = { + title: string; children: React.ReactNode; }; -function WishlistLayout({ children }: WishlistLayoutProps) { +function ArchivePageLayout({ title, children }: Props) { return (
@@ -22,16 +19,13 @@ function WishlistLayout({ children }: WishlistLayoutProps) { } />

- 내 보관함 + {title}

- - -
{children}
); } -export default WishlistLayout; +export default ArchivePageLayout; diff --git a/apps/web/src/app/archive/_components/WishTab.tsx b/apps/web/src/app/archive/_components/WishTab.tsx deleted file mode 100644 index b3b3311f..00000000 --- a/apps/web/src/app/archive/_components/WishTab.tsx +++ /dev/null @@ -1,57 +0,0 @@ -'use client'; - -import { useRouter, useSearchParams } from 'next/navigation'; - -import { ROUTES } from '@/consts/route'; -import type { ItemTypeT } from '@/types/item'; -import { cn } from '@/utils/cn'; - -import type { WishTabT } from '../_types/wish'; - -const TABS: WishTabT[] = ['저장한 위시템', '토너먼트 기록']; - -const TAB_TYPE: Record = { - '저장한 위시템': 'wish', - '토너먼트 기록': 'tournament', -}; - -const TYPE_TAB: Record = { - wish: '저장한 위시템', - tournament: '토너먼트 기록', -}; - -function WishTab() { - const router = useRouter(); - const searchParams = useSearchParams(); - const tabParam = searchParams.get('tab'); - const activeTab: WishTabT = - tabParam === 'wish' || tabParam === 'tournament' ? TYPE_TAB[tabParam] : '저장한 위시템'; - - const handleTabChange = (tab: WishTabT) => { - router.replace(ROUTES.ARCHIVE(TAB_TYPE[tab])); - }; - - return ( -
-
- {TABS.map(tab => ( - - ))} -
-
- ); -} - -export default WishTab; diff --git a/apps/web/src/app/archive/_components/tournament-history-content/client.tsx b/apps/web/src/app/archive/_components/tournament-history-content/client.tsx deleted file mode 100644 index 10d8ff32..00000000 --- a/apps/web/src/app/archive/_components/tournament-history-content/client.tsx +++ /dev/null @@ -1,18 +0,0 @@ -'use client'; - -import BottomTabBar from '@/components/bottom-tab-bar'; - -import TournamentHistoryList from '../TournamentHistoryList'; - -function TournamentHistoryContentClient() { - return ( - <> - -
- -
- - ); -} - -export default TournamentHistoryContentClient; diff --git a/apps/web/src/app/archive/_types/wish.ts b/apps/web/src/app/archive/_types/wish.ts deleted file mode 100644 index 47673ab0..00000000 --- a/apps/web/src/app/archive/_types/wish.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ItemStatusT, ItemT } from '@/types/item'; -import type { WishT } from '@/types/wish'; - -export type WishTabT = '저장한 위시템' | '토너먼트 기록'; - -export type WishlistEntryT = { - wish: WishT; - item: ItemT; -}; - -export type WishItemT = { - id: number; - itemId: number; - name: string; - price: number; - imageUrl: string | null; - status: ItemStatusT; -}; diff --git a/apps/web/src/app/archive/page.tsx b/apps/web/src/app/archive/page.tsx deleted file mode 100644 index 04734902..00000000 --- a/apps/web/src/app/archive/page.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import WishlistLayout from './_components/WishlistLayout'; -import TournamentHistoryContent from './_components/tournament-history-content'; -import WishContent from './_components/wish-content'; -import type { WishTabT } from './_types/wish'; - -type ArchivePageProps = { - searchParams: Promise<{ tab?: string }>; -}; - -const getActiveWishTab = (tab?: string): WishTabT => { - if (tab === 'tournament') return '토너먼트 기록'; - return '저장한 위시템'; -}; - -async function ArchivePage({ searchParams }: ArchivePageProps) { - const { tab } = await searchParams; - const activeTab = getActiveWishTab(tab); - - return ( - - {activeTab === '저장한 위시템' && } - {activeTab === '토너먼트 기록' && } - - ); -} - -export default ArchivePage; diff --git a/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx b/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx new file mode 100644 index 00000000..9a44d929 --- /dev/null +++ b/apps/web/src/app/archive/tournament/_components/ArchiveTournamentClient.tsx @@ -0,0 +1,41 @@ +'use client'; + +import { Suspense, useState } from 'react'; + +import BottomTabBar from '@/components/bottom-tab-bar'; +import type { TournamentStatusT } from '@/types/tournament'; + +import ArchivePageLayout from '../../_common/_components/ArchivePageLayout'; +import TournamentHistoryList from './TournamentHistoryList'; +import TournamentStatusTab, { type TournamentStatusTabT } from './TournamentStatusTab'; + +const STATUS_BY_TAB: Record = { + 'in-progress': ['PENDING', 'IN_PROGRESS'], + completed: ['COMPLETED'], +}; + +function ArchiveTournamentClient() { + const [activeTab, setActiveTab] = useState('in-progress'); + + return ( + + + +

+ 토너먼트를 불러오는 중이에요 +

+
+ } + > + + +
+ +
+ + ); +} + +export default ArchiveTournamentClient; diff --git a/apps/web/src/app/archive/_components/tournament-history-content/index.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx similarity index 57% rename from apps/web/src/app/archive/_components/tournament-history-content/index.tsx rename to apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx index 2390d23d..ec562773 100644 --- a/apps/web/src/app/archive/_components/tournament-history-content/index.tsx +++ b/apps/web/src/app/archive/tournament/_components/TournamentHistoryContent.tsx @@ -1,21 +1,24 @@ import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { getTournamentList } from '@/apis/getTournamentList'; +import type { TournamentStatusT } from '@/types/tournament'; import { getQueryClient } from '@/utils/queryClient'; -import TournamentHistoryContentClient from './client'; +import ArchiveTournamentClient from './ArchiveTournamentClient'; + +const DEFAULT_STATUSES: TournamentStatusT[] = ['PENDING', 'IN_PROGRESS']; async function TournamentHistoryContent() { const queryClient = getQueryClient(); await queryClient.prefetchQuery({ - queryKey: ['tournamentList', []], - queryFn: () => getTournamentList(), + queryKey: ['tournamentList', DEFAULT_STATUSES], + queryFn: () => getTournamentList(DEFAULT_STATUSES), }); return ( - + ); } diff --git a/apps/web/src/app/archive/_components/TournamentHistoryList.tsx b/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx similarity index 83% rename from apps/web/src/app/archive/_components/TournamentHistoryList.tsx rename to apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx index 4d5dc206..d807557f 100644 --- a/apps/web/src/app/archive/_components/TournamentHistoryList.tsx +++ b/apps/web/src/app/archive/tournament/_components/TournamentHistoryList.tsx @@ -3,9 +3,14 @@ import { TrophyIconFill } from '@/assets/icons'; import TournamentCard from '@/components/tournament-card'; import { useGetTournamentList } from '@/hooks/useGetTournamentList'; +import type { TournamentStatusT } from '@/types/tournament'; -function TournamentHistoryList() { - const { tournamentListData } = useGetTournamentList(); +type Props = { + statuses: TournamentStatusT[]; +}; + +function TournamentHistoryList({ statuses }: Props) { + const { tournamentListData } = useGetTournamentList(statuses); if (tournamentListData.length === 0) return ( diff --git a/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx b/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx new file mode 100644 index 00000000..7577c49f --- /dev/null +++ b/apps/web/src/app/archive/tournament/_components/TournamentStatusTab.tsx @@ -0,0 +1,40 @@ +'use client'; + +import { cn } from '@/utils/cn'; + +export type TournamentStatusTabT = 'in-progress' | 'completed'; + +type Props = { + activeTab: TournamentStatusTabT; + onTabChange: (tab: TournamentStatusTabT) => void; +}; + +const TABS: { value: TournamentStatusTabT; label: string }[] = [ + { value: 'in-progress', label: '진행 중' }, + { value: 'completed', label: '완료' }, +]; + +function TournamentStatusTab({ activeTab, onTabChange }: Props) { + return ( +
+ {TABS.map(tab => ( + + ))} +
+ ); +} + +export default TournamentStatusTab; diff --git a/apps/web/src/app/archive/tournament/page.tsx b/apps/web/src/app/archive/tournament/page.tsx new file mode 100644 index 00000000..70d1383b --- /dev/null +++ b/apps/web/src/app/archive/tournament/page.tsx @@ -0,0 +1,7 @@ +import TournamentHistoryContent from './_components/TournamentHistoryContent'; + +async function ArchiveTournamentPage() { + return ; +} + +export default ArchiveTournamentPage; diff --git a/apps/web/src/app/archive/wish/[id]/_hooks/useDeleteWish.ts b/apps/web/src/app/archive/wish/[id]/_hooks/useDeleteWish.ts index 32467efa..5ffb1432 100644 --- a/apps/web/src/app/archive/wish/[id]/_hooks/useDeleteWish.ts +++ b/apps/web/src/app/archive/wish/[id]/_hooks/useDeleteWish.ts @@ -17,7 +17,7 @@ export const useDeleteWish = (wishId: number) => { onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['wishlists'] }); toast.success('위시 상품이 삭제되었습니다.'); - router.replace(ROUTES.ARCHIVE()); + router.replace(ROUTES.WISHLIST); }, onError: error => { if (!isAxiosError(error) || !error.response) return; diff --git a/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts b/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts index 20ee4d4f..9b869b7d 100644 --- a/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts +++ b/apps/web/src/app/archive/wish/[id]/_hooks/usePatchWish.ts @@ -41,7 +41,7 @@ export const usePatchWish = (wishId: number) => { toast.error(clientErrorMessage); } else if (status === 403 || status === 404 || status === 409) { toast.error(clientErrorMessage); - router.replace(ROUTES.ARCHIVE()); + router.replace(ROUTES.WISHLIST); } }, }); diff --git a/apps/web/src/app/archive/wish/[id]/layout.tsx b/apps/web/src/app/archive/wish/[id]/layout.tsx index 57363041..c59a703d 100644 --- a/apps/web/src/app/archive/wish/[id]/layout.tsx +++ b/apps/web/src/app/archive/wish/[id]/layout.tsx @@ -28,13 +28,12 @@ async function WishEditLayout({ children, params }: WishEditLayoutProps) { /** 아직 PROCESSING 상태인 경우에는 접근 불가 */ if (wishData.item.status === 'PROCESSING' || wishData.item.status === 'PENDING') - redirect(ROUTES.ARCHIVE()); + redirect(ROUTES.WISHLIST); } catch (error) { if (!isAxiosError(error)) throw error; /** 위시가 존재하지 않는 경우 */ - if (error.response?.status === 404) - redirect(ROUTES.ARCHIVE('wish')); + if (error.response?.status === 404) redirect(ROUTES.WISHLIST); throw error; } diff --git a/apps/web/src/app/archive/_apis/deleteWishes.ts b/apps/web/src/app/archive/wish/_apis/deleteWishes.ts similarity index 100% rename from apps/web/src/app/archive/_apis/deleteWishes.ts rename to apps/web/src/app/archive/wish/_apis/deleteWishes.ts diff --git a/apps/web/src/app/archive/_components/FabMenu.tsx b/apps/web/src/app/archive/wish/_components/FabMenu.tsx similarity index 100% rename from apps/web/src/app/archive/_components/FabMenu.tsx rename to apps/web/src/app/archive/wish/_components/FabMenu.tsx diff --git a/apps/web/src/app/archive/_components/WishAddDialog.tsx b/apps/web/src/app/archive/wish/_components/WishAddDialog.tsx similarity index 100% rename from apps/web/src/app/archive/_components/WishAddDialog.tsx rename to apps/web/src/app/archive/wish/_components/WishAddDialog.tsx diff --git a/apps/web/src/app/archive/_components/WishCardSkeleton.tsx b/apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx similarity index 100% rename from apps/web/src/app/archive/_components/WishCardSkeleton.tsx rename to apps/web/src/app/archive/wish/_components/WishCardSkeleton.tsx diff --git a/apps/web/src/app/archive/_components/wish-content/index.tsx b/apps/web/src/app/archive/wish/_components/WishContent.tsx similarity index 93% rename from apps/web/src/app/archive/_components/wish-content/index.tsx rename to apps/web/src/app/archive/wish/_components/WishContent.tsx index 983054e1..7f7e5980 100644 --- a/apps/web/src/app/archive/_components/wish-content/index.tsx +++ b/apps/web/src/app/archive/wish/_components/WishContent.tsx @@ -4,7 +4,7 @@ import type { WishlistPageT } from '@/apis/getWishlist'; import { getWishlist } from '@/apis/getWishlist'; import { getQueryClient } from '@/utils/queryClient'; -import WishContentClient from './client'; +import WishContentClient from './WishContentClient'; async function WishContent() { const queryClient = getQueryClient(); diff --git a/apps/web/src/app/archive/_components/wish-content/client.tsx b/apps/web/src/app/archive/wish/_components/WishContentClient.tsx similarity index 75% rename from apps/web/src/app/archive/_components/wish-content/client.tsx rename to apps/web/src/app/archive/wish/_components/WishContentClient.tsx index 522dc0d3..c48fc42c 100644 --- a/apps/web/src/app/archive/_components/wish-content/client.tsx +++ b/apps/web/src/app/archive/wish/_components/WishContentClient.tsx @@ -2,12 +2,12 @@ import { useState } from 'react'; -import { useWishlistDelete } from '../../_hooks/useDeleteWishes'; -import { useShareIntentWish } from '../../_hooks/useShareIntentWish'; -import WishAddDialog from '../WishAddDialog'; -import WishlistBottomBar from '../WishlistBottomBar'; -import WishlistFabArea from '../WishlistFabArea'; -import WishlistList from '../WishlistList'; +import { useDeleteWishes } from '../_hooks/useDeleteWishes'; +import { useShareIntentWish } from '../_hooks/useShareIntentWish'; +import WishAddDialog from './WishAddDialog'; +import WishlistBottomBar from './WishlistBottomBar'; +import WishlistFabArea from './WishlistFabArea'; +import WishlistList from './WishlistList'; function WishContentClient() { const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -20,7 +20,7 @@ function WishContentClient() { handleEnterDeleteMode, handleToggleSelect, handleConfirmDelete, - } = useWishlistDelete(); + } = useDeleteWishes(); return ( <> diff --git a/apps/web/src/app/archive/_components/WishFab.tsx b/apps/web/src/app/archive/wish/_components/WishFab.tsx similarity index 100% rename from apps/web/src/app/archive/_components/WishFab.tsx rename to apps/web/src/app/archive/wish/_components/WishFab.tsx diff --git a/apps/web/src/app/archive/_components/WishlistTabContent.tsx b/apps/web/src/app/archive/wish/_components/WishGridContent.tsx similarity index 75% rename from apps/web/src/app/archive/_components/WishlistTabContent.tsx rename to apps/web/src/app/archive/wish/_components/WishGridContent.tsx index 490ca5f2..1a3b22de 100644 --- a/apps/web/src/app/archive/_components/WishlistTabContent.tsx +++ b/apps/web/src/app/archive/wish/_components/WishGridContent.tsx @@ -1,21 +1,21 @@ -import WishGrid from '@/app/archive/_components/wish-grid'; import { HeartIconFill } from '@/assets/icons'; +import type { WishItemT } from '@/types/wish'; -import type { WishItemT } from '../_types/wish'; +import WishGrid from './wish-grid'; -type WishlistTabContentProps = { +type WishGridContentProps = { items: WishItemT[]; isDeleteMode?: boolean; selectedIds?: Set; onToggleSelect?: (id: number) => void; }; -function WishlistTabContent({ +function WishGridContent({ items, isDeleteMode, selectedIds, onToggleSelect, -}: WishlistTabContentProps) { +}: WishGridContentProps) { if (items.length === 0) { return (
@@ -35,4 +35,4 @@ function WishlistTabContent({ ); } -export default WishlistTabContent; +export default WishGridContent; diff --git a/apps/web/src/app/archive/_components/WishlistBottomBar.tsx b/apps/web/src/app/archive/wish/_components/WishlistBottomBar.tsx similarity index 100% rename from apps/web/src/app/archive/_components/WishlistBottomBar.tsx rename to apps/web/src/app/archive/wish/_components/WishlistBottomBar.tsx diff --git a/apps/web/src/app/archive/_components/WishlistFabArea.tsx b/apps/web/src/app/archive/wish/_components/WishlistFabArea.tsx similarity index 100% rename from apps/web/src/app/archive/_components/WishlistFabArea.tsx rename to apps/web/src/app/archive/wish/_components/WishlistFabArea.tsx diff --git a/apps/web/src/app/archive/_components/WishlistList.tsx b/apps/web/src/app/archive/wish/_components/WishlistList.tsx similarity index 95% rename from apps/web/src/app/archive/_components/WishlistList.tsx rename to apps/web/src/app/archive/wish/_components/WishlistList.tsx index 8b383658..e9e7e161 100644 --- a/apps/web/src/app/archive/_components/WishlistList.tsx +++ b/apps/web/src/app/archive/wish/_components/WishlistList.tsx @@ -7,7 +7,7 @@ import { useSSEFallback } from '@/hooks/useSSEFallback'; import { hasParsingItems } from '@/utils/item'; import WishCardSkeleton from './WishCardSkeleton'; -import WishlistTabContent from './WishlistTabContent'; +import WishGridContent from './WishGridContent'; type WishlistListProps = { isDeleteMode: boolean; @@ -37,7 +37,7 @@ function WishlistList({ isDeleteMode, selectedIds, onToggleSelect }: WishlistLis return ( <> - { +export const useDeleteWishes = () => { const [isDeleteMode, setIsDeleteMode] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); diff --git a/apps/web/src/app/archive/_hooks/useShareIntentWish.ts b/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts similarity index 81% rename from apps/web/src/app/archive/_hooks/useShareIntentWish.ts rename to apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts index 1ab59930..80368a03 100644 --- a/apps/web/src/app/archive/_hooks/useShareIntentWish.ts +++ b/apps/web/src/app/archive/wish/_hooks/useShareIntentWish.ts @@ -29,22 +29,26 @@ const getUrlFromShareIntent = (payload: ShareIntentPayloadT): string | null => { /** 앱 공유(ShareIntent)로 전달된 상품 URL을 위시리스트에 등록 */ export const useShareIntentWish = () => { - const hasProcessedRef = useRef(false); + /** 같은 URL의 중복 전달만 방지 — 후속 공유는 계속 처리돼야 한다 */ + const processedUrlsRef = useRef(new Set()); const { postWishLinkMutation } = usePostWishLink(); const handleShareIntent = useCallback( (payload: ShareIntentPayloadT) => { - if (hasProcessedRef.current) return; - const url = getUrlFromShareIntent(payload); if (!url) { toast.error('공유된 링크를 찾을 수 없어요'); return; } - hasProcessedRef.current = true; - postWishLinkMutation(url); + if (processedUrlsRef.current.has(url)) return; + + processedUrlsRef.current.add(url); + /** 실패한 URL은 잠금 해제해 재공유 시 다시 시도되도록 */ + postWishLinkMutation(url, { + onError: () => processedUrlsRef.current.delete(url), + }); }, [postWishLinkMutation] ); diff --git a/apps/web/src/app/archive/wish/page.tsx b/apps/web/src/app/archive/wish/page.tsx new file mode 100644 index 00000000..0c81bbb9 --- /dev/null +++ b/apps/web/src/app/archive/wish/page.tsx @@ -0,0 +1,12 @@ +import ArchivePageLayout from '../_common/_components/ArchivePageLayout'; +import WishContent from './_components/WishContent'; + +async function ArchiveWishPage() { + return ( + + + + ); +} + +export default ArchiveWishPage; diff --git a/apps/web/src/app/notification/_utils/getNotificationRoute.ts b/apps/web/src/app/notification/_utils/getNotificationRoute.ts index 41d5da4f..efe9b5b7 100644 --- a/apps/web/src/app/notification/_utils/getNotificationRoute.ts +++ b/apps/web/src/app/notification/_utils/getNotificationRoute.ts @@ -22,7 +22,7 @@ export const getNotificationRoute = ( if (extra?.kind === 'TOURNAMENT' && extra.tournamentId) { return ROUTES.TOURNAMENT_CREATE(extra.tournamentId); } - return ROUTES.ARCHIVE(); + return ROUTES.WISHLIST; case 'ANNOUNCEMENT': return null; } diff --git a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx index 681d1d52..b1ffcf5c 100644 --- a/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx +++ b/apps/web/src/app/tournament/[id]/create/by-wish/_components/WishSelectCard.tsx @@ -1,6 +1,6 @@ -import WishCard from '@/app/archive/_components/wish-grid/WishCard'; import CheckboxEmptyIconFill from '@/assets/icons/fill/checkbox-empty.svg'; import CheckboxSelectedIconFill from '@/assets/icons/fill/checkbox-selected.svg'; +import WishCard from '@/components/common/wish-card'; type WishSelectCardProps = { name: string; diff --git a/apps/web/src/components/bottom-tab-bar/index.tsx b/apps/web/src/components/bottom-tab-bar/index.tsx index ff0a2603..ada6ca03 100644 --- a/apps/web/src/components/bottom-tab-bar/index.tsx +++ b/apps/web/src/components/bottom-tab-bar/index.tsx @@ -10,9 +10,12 @@ import { ROUTES } from '@/consts/route'; const TABS = [ { label: '홈', icon: HomeIconFill, href: ROUTES.HOME }, - { label: '보관', icon: HeartIconFill, href: ROUTES.ARCHIVE() }, + { label: '보관', icon: HeartIconFill, href: ROUTES.WISHLIST }, ] as const; +const matchesTabPath = (pathname: string, basePath: string) => + pathname === basePath || pathname.startsWith(`${basePath}/`); + function BottomTabBar() { const router = useRouter(); const pathname = usePathname(); @@ -43,8 +46,9 @@ function BottomTabBar() { {TABS.map(({ label, icon: Icon, href }) => { const isActive = label === '보관' - ? pathname.startsWith(ROUTES.ARCHIVE_BASE) - : pathname === href || pathname.startsWith(`${href}/`); + ? matchesTabPath(pathname, ROUTES.WISHLIST) || + matchesTabPath(pathname, ROUTES.TOURNAMENT_HISTORY) + : matchesTabPath(pathname, href); return (