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];