diff --git a/frontend/src/hooks/useNavigator.test.ts b/frontend/src/hooks/useNavigator.test.ts index 0c3c18cd3..299a8b2cd 100644 --- a/frontend/src/hooks/useNavigator.test.ts +++ b/frontend/src/hooks/useNavigator.test.ts @@ -1,10 +1,24 @@ import { useNavigate } from 'react-router-dom'; import { renderHook, RenderHookResult } from '@testing-library/react'; import useNavigator from '@/hooks/useNavigator'; +import isInAppWebView from '@/utils/isInAppWebView'; +import { + requestNavigateWebview, + requestOpenExternalUrl, +} from '@/utils/webviewBridge'; jest.mock('react-router-dom', () => ({ useNavigate: jest.fn(), })); +jest.mock('@/utils/isInAppWebView'); +jest.mock('@/utils/webviewBridge', () => ({ + requestNavigateWebview: jest.fn(), + requestOpenExternalUrl: jest.fn(), +})); + +const mockIsInAppWebView = isInAppWebView as jest.Mock; +const mockRequestNavigateWebview = requestNavigateWebview as jest.Mock; +const mockRequestOpenExternalUrl = requestOpenExternalUrl as jest.Mock; describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { const mockNavigate = jest.fn(); @@ -14,13 +28,14 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { beforeEach(() => { jest.clearAllMocks(); (useNavigate as jest.Mock).mockReturnValue(mockNavigate); + mockIsInAppWebView.mockReturnValue(false); Object.defineProperty(window, 'location', { writable: true, value: { href: '' }, }); + window.open = jest.fn(); - // given handleLink = renderHook(() => useNavigator()); }); @@ -33,19 +48,15 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { describe('링크가 비어있으면', () => { it('아무 페이지로도 이동하지 않는다', () => { - // When handleLink.result.current(''); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); it('공백만 있는 링크도 이동하지 않는다', () => { - // When handleLink.result.current(' '); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); @@ -58,47 +69,91 @@ describe('useNavigator - 사용자가 링크를 클릭했을 때', () => { ['vbscript', 'vbscript:msgbox("XSS")'], ['대문자 javascript', 'JAVASCRIPT:alert("XSS")'], ])('%s 프로토콜 링크는 차단된다', (_, maliciousUrl) => { - // When handleLink.result.current(maliciousUrl); - // Then expect(mockNavigate).not.toHaveBeenCalled(); expect(window.location.href).toBe(''); }); }); - describe('외부 사이트 링크를 클릭하면', () => { - it.each([ - ['https', 'https://example.com'], - ['http', 'http://example.com'], - ['App Store (itms-apps)', 'itms-apps://itunes.apple.com/app/123456'], - ])('%s 링크는 해당 사이트로 이동한다', (_, externalUrl) => { - // When - handleLink.result.current(externalUrl); - - // Then - expect(window.location.href).toBe(externalUrl); - expect(mockNavigate).not.toHaveBeenCalled(); + describe('일반 웹에서', () => { + describe('외부 링크를 클릭하면', () => { + it.each([ + ['https', 'https://example.com'], + ['http', 'http://example.com'], + ['itms-apps', 'itms-apps://itunes.apple.com/app/123456'], + ])('%s 링크는 window.location.href로 이동한다', (_, externalUrl) => { + handleLink.result.current(externalUrl); + + expect(window.location.href).toBe(externalUrl); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + }); + + describe('내부 경로를 클릭하면', () => { + it('React Router로 이동한다', () => { + handleLink.result.current('/introduce'); + + expect(mockNavigate).toHaveBeenCalledWith('/introduce'); + expect(window.location.href).toBe(''); + }); + + it('상대 경로도 React Router로 이동한다', () => { + handleLink.result.current('about'); + + expect(mockNavigate).toHaveBeenCalledWith('about'); + expect(window.location.href).toBe(''); + }); }); }); - describe('내부 페이지 링크를 클릭하면', () => { - it('소개 페이지로 이동할 수 있다', () => { - // When - handleLink.result.current('/introduce'); + describe('웹뷰에서', () => { + beforeEach(() => { + mockIsInAppWebView.mockReturnValue(true); + handleLink = renderHook(() => useNavigator()); + }); - // Then - expect(mockNavigate).toHaveBeenCalledWith('/introduce'); - expect(window.location.href).toBe(''); + describe('외부 링크를 클릭하면', () => { + it('http/https 링크는 requestOpenExternalUrl로 앱에 위임한다', () => { + mockRequestOpenExternalUrl.mockReturnValue(true); + + handleLink.result.current('https://example.com'); + + expect(mockRequestOpenExternalUrl).toHaveBeenCalledWith( + 'https://example.com', + ); + expect(window.location.href).toBe(''); + }); + + it('itms-apps:// 링크는 requestOpenExternalUrl이 false를 반환하면 window.open으로 폴백한다', () => { + mockRequestOpenExternalUrl.mockReturnValue(false); + + handleLink.result.current('itms-apps://itunes.apple.com/app/123456'); + + expect(mockRequestOpenExternalUrl).toHaveBeenCalled(); + expect(window.open).toHaveBeenCalledWith( + 'itms-apps://itunes.apple.com/app/123456', + ); + }); }); - it('상대 경로로도 이동할 수 있다', () => { - // When - handleLink.result.current('about'); + describe('내부 경로를 클릭하면', () => { + it('requestNavigateWebview로 앱에 위임한다', () => { + handleLink.result.current('/promotions/123'); - // Then - expect(mockNavigate).toHaveBeenCalledWith('about'); - expect(window.location.href).toBe(''); + expect(mockRequestNavigateWebview).toHaveBeenCalledWith( + 'promotions/123', + ); + expect(mockNavigate).not.toHaveBeenCalled(); + }); + + it('leading slash를 제거한 slug로 전달한다', () => { + handleLink.result.current('/festival-introduction'); + + expect(mockRequestNavigateWebview).toHaveBeenCalledWith( + 'festival-introduction', + ); + }); }); }); }); diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts index 8bfdbd5cb..6488244e8 100644 --- a/frontend/src/hooks/useNavigator.ts +++ b/frontend/src/hooks/useNavigator.ts @@ -1,7 +1,12 @@ import { useCallback } from 'react'; import { useNavigate } from 'react-router-dom'; import isInAppWebView from '@/utils/isInAppWebView'; -import { requestOpenExternalUrl } from '@/utils/webviewBridge'; +import { + requestNavigateWebview, + requestOpenExternalUrl, +} from '@/utils/webviewBridge'; + +const toSlug = (path: string) => path.replace(/^\//, ''); const useNavigator = () => { const navigate = useNavigate(); @@ -10,28 +15,20 @@ const useNavigator = () => { (url: string) => { const trimmedUrl = url?.trim(); if (!trimmedUrl) return; + if (/^(javascript|data|vbscript):/i.test(trimmedUrl)) return; + + const inWebview = isInAppWebView(); + const isExternal = /^(https?|itms-apps):\/\//.test(trimmedUrl); - const isDangerousProtocol = /^(javascript|data|vbscript):/i.test( - trimmedUrl, - ); - if (isDangerousProtocol) return; - - const isExternalUrl = /^(https?|itms-apps):\/\//.test(trimmedUrl); - - if (isExternalUrl) { - // 웹뷰에서 window.location.href로 외부 URL을 열면 WebView 자체가 이동해버리므로 앱에 위임. - // requestOpenExternalUrl은 http/https만 허용하므로, itms-apps:// 등 비표준 스킴은 false를 - // 반환 → window.open으로 폴백해 OS가 처리하도록 위임 - if (isInAppWebView()) { - if (!requestOpenExternalUrl(trimmedUrl)) { - window.open(trimmedUrl); - } - } else { - window.location.href = trimmedUrl; - } - } else { - navigate(trimmedUrl); + if (isExternal) { + if (inWebview && !requestOpenExternalUrl(trimmedUrl)) + window.open(trimmedUrl); + else if (!inWebview) window.location.href = trimmedUrl; + return; } + + if (inWebview) requestNavigateWebview(toSlug(trimmedUrl)); + else navigate(trimmedUrl); }, [navigate], ); diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.tsx b/frontend/src/pages/MainPage/components/Banner/Banner.tsx index afd9c0891..5bba0262d 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.tsx +++ b/frontend/src/pages/MainPage/components/Banner/Banner.tsx @@ -10,11 +10,6 @@ import { useGetBanners } from '@/hooks/Queries/useBanner'; import useDevice from '@/hooks/useDevice'; import useNavigator from '@/hooks/useNavigator'; import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; -import isInAppWebView from '@/utils/isInAppWebView'; -import { - requestNavigateWebview, - requestOpenExternalUrl, -} from '@/utils/webviewBridge'; import * as Styled from './Banner.styles'; import BANNERS from './bannerData'; @@ -22,8 +17,6 @@ interface BannerProps { isWebview?: boolean; } -const inAppWebView = isInAppWebView(); - const Banner = ({ isWebview = false }: BannerProps) => { const { isMobile } = useDevice(); const handleLink = useNavigator(); @@ -69,12 +62,8 @@ const Banner = ({ isWebview = false }: BannerProps) => { linkTo: url, }); - if (inAppWebView) { - if (url === WEBVIEW_LINK_TARGET.CLUB_FESTIVAL) { - requestNavigateWebview('festival-introduction'); - return; - } - requestOpenExternalUrl(url); + if (url === WEBVIEW_LINK_TARGET.CLUB_FESTIVAL) { + handleLink('/festival-introduction'); return; } @@ -140,13 +129,13 @@ const Banner = ({ isWebview = false }: BannerProps) => { ))} - {(isMobile || inAppWebView) && ( + {(isMobile || isWebview) && ( {currentIndex + 1} / {displayBanners?.length ?? 0} )} - {!isMobile && !inAppWebView && ( + {!isMobile && !isWebview && ( {displayBanners?.map((_, index) => ( diff --git a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx index c14008cb8..3f808f3ba 100644 --- a/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx +++ b/frontend/src/pages/PromotionPage/components/detail/PromotionClubCTA/PromotionClubCTA.tsx @@ -1,6 +1,6 @@ -import { useNavigate } from 'react-router-dom'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useNavigator from '@/hooks/useNavigator'; import isInAppWebView from '@/utils/isInAppWebView'; import { requestNavigateWebview } from '@/utils/webviewBridge'; import ArrowButton from '../PromotionArrowButton/PromotionArrowButton'; @@ -12,7 +12,7 @@ interface Props { } const PromotionClubCTA = ({ clubId, clubName }: Props) => { - const navigate = useNavigate(); + const handleLink = useNavigator(); const trackEvent = useMixpanelTrack(); const handleNavigate = () => { @@ -21,10 +21,11 @@ const PromotionClubCTA = ({ clubId, clubName }: Props) => { club_name: clubName, }); + // 웹뷰는 club/id 기반 slug, 일반 웹은 clubName 기반 경로 사용 if (isInAppWebView()) { requestNavigateWebview(`club/${clubId}`); } else { - navigate(`/clubDetail/@${encodeURIComponent(clubName)}`); + handleLink(`/clubDetail/@${encodeURIComponent(clubName)}`); } }; diff --git a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx index f8f5070da..1f401b202 100644 --- a/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx +++ b/frontend/src/pages/PromotionPage/components/list/PromotionCard/PromotionCard.tsx @@ -1,6 +1,6 @@ -import { useNavigate } from 'react-router-dom'; import { USER_EVENT } from '@/constants/eventName'; import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useNavigator from '@/hooks/useNavigator'; import { getDDay } from '@/pages/PromotionPage/utils/getDday'; import { PromotionArticle } from '@/types/promotion'; import CardMeta from './CardMeta/CardMeta'; @@ -14,7 +14,7 @@ interface PromotionCardProps { const PromotionCard = ({ article }: PromotionCardProps) => { const trackEvent = useMixpanelTrack(); - const navigateToPromotionDetail = useNavigate(); + const handleLink = useNavigator(); const dday = getDDay(article.eventStartDate, article.eventEndDate); const handleCardClick = () => { @@ -24,7 +24,7 @@ const PromotionCard = ({ article }: PromotionCardProps) => { source: 'promotion-card', }); - navigateToPromotionDetail('/festival-introduction'); + handleLink('/festival-introduction'); return; } @@ -32,7 +32,7 @@ const PromotionCard = ({ article }: PromotionCardProps) => { clubId: article.clubId, }); - navigateToPromotionDetail(`/promotions/${article.id}`); + handleLink(`/promotions/${article.id}`); }; const imageUrl = article.images?.[0];