diff --git a/frontend/src/assets/images/banners/banner_desktop4.png b/frontend/src/assets/images/banners/banner_desktop4.png
new file mode 100644
index 000000000..bb345416d
Binary files /dev/null and b/frontend/src/assets/images/banners/banner_desktop4.png differ
diff --git a/frontend/src/assets/images/banners/banner_mobile4.png b/frontend/src/assets/images/banners/banner_mobile4.png
new file mode 100644
index 000000000..5f43461a8
Binary files /dev/null and b/frontend/src/assets/images/banners/banner_mobile4.png differ
diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts
index 68b5e5a43..03ca03eee 100644
--- a/frontend/src/constants/eventName.ts
+++ b/frontend/src/constants/eventName.ts
@@ -2,6 +2,11 @@ export const USER_EVENT = {
CATEGORY_BUTTON_CLICKED: 'CategoryButton Clicked',
SEARCH_BOX_CLICKED: 'SearchBox Clicked',
+ // 배너 클릭
+ BANNER_CLICKED: 'Banner Clicked',
+ APP_DOWNLOAD_BANNER_CLICKED: 'App Download Banner Clicked',
+
+
// 네비게이션
BACK_BUTTON_CLICKED: 'Back Button Clicked',
HOME_BUTTON_CLICKED: 'Home Button Clicked',
diff --git a/frontend/src/hooks/__tests__/useNavigator.test.ts b/frontend/src/hooks/__tests__/useNavigator.test.ts
new file mode 100644
index 000000000..96b412d35
--- /dev/null
+++ b/frontend/src/hooks/__tests__/useNavigator.test.ts
@@ -0,0 +1,105 @@
+import { renderHook, RenderHookResult } from '@testing-library/react';
+import { useNavigate } from 'react-router-dom';
+import useNavigator from '../useNavigator';
+
+jest.mock('react-router-dom', () => ({
+ useNavigate: jest.fn(),
+}));
+
+describe('useNavigator - 사용자가 링크를 클릭했을 때', () => {
+ const mockNavigate = jest.fn();
+ const originalLocation = window.location;
+ let handleLink: RenderHookResult<(url: string) => void, unknown>;
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+ (useNavigate as jest.Mock).mockReturnValue(mockNavigate);
+
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: { href: '' },
+ });
+
+ // given
+ handleLink = renderHook(() => useNavigator());
+ });
+
+ afterEach(() => {
+ Object.defineProperty(window, 'location', {
+ writable: true,
+ value: originalLocation,
+ });
+ });
+
+ 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('');
+ });
+ });
+
+ describe('악성 링크를 클릭하면', () => {
+ it.each([
+ ['javascript', 'javascript:alert("XSS")'],
+ ['data', 'data:text/html,'],
+ ['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('내부 페이지 링크를 클릭하면', () => {
+ it('소개 페이지로 이동할 수 있다', () => {
+ // When
+ handleLink.result.current('/introduce');
+
+ // Then
+ expect(mockNavigate).toHaveBeenCalledWith('/introduce');
+ expect(window.location.href).toBe('');
+ });
+
+ it('상대 경로로도 이동할 수 있다', () => {
+ // When
+ handleLink.result.current('about');
+
+ // Then
+ expect(mockNavigate).toHaveBeenCalledWith('about');
+ expect(window.location.href).toBe('');
+ });
+ });
+});
+
diff --git a/frontend/src/hooks/useNavigator.ts b/frontend/src/hooks/useNavigator.ts
index b7ae67388..7201671f7 100644
--- a/frontend/src/hooks/useNavigator.ts
+++ b/frontend/src/hooks/useNavigator.ts
@@ -9,10 +9,13 @@ const useNavigator = () => {
const trimmedUrl = url?.trim();
if (!trimmedUrl) return;
- const isExternalUrl = trimmedUrl.startsWith('https://');
+ const isDangerousProtocol = /^(javascript|data|vbscript):/i.test(trimmedUrl);
+ if (isDangerousProtocol) return;
+
+ const isExternalUrl = /^(https?|itms-apps):\/\//.test(trimmedUrl);
if (isExternalUrl) {
- window.open(trimmedUrl, '_blank', 'noopener,noreferrer');
+ window.location.href = trimmedUrl;
} else {
navigate(trimmedUrl);
}
diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.tsx b/frontend/src/pages/MainPage/components/Banner/Banner.tsx
index 0cfe8984b..eaa7d2b3a 100644
--- a/frontend/src/pages/MainPage/components/Banner/Banner.tsx
+++ b/frontend/src/pages/MainPage/components/Banner/Banner.tsx
@@ -6,12 +6,34 @@ import * as Styled from './Banner.styles';
import BANNERS from './bannerData';
import useDevice from '@/hooks/useDevice';
import useNavigator from '@/hooks/useNavigator';
+import useMixpanelTrack from '@/hooks/useMixpanelTrack';
+import { USER_EVENT } from '@/constants/eventName';
import PrevButton from '@/assets/images/icons/prev_button_icon.svg';
import NextButton from '@/assets/images/icons/next_button_icon.svg';
+
+const APP_STORE_LINKS = {
+ ios: 'itms-apps://itunes.apple.com/app/6755062085',
+ android: 'https://play.google.com/store/apps/details?id=com.moadong.moadong&pcampaignid=web_share',
+ default: 'https://play.google.com/store/apps/details?id=com.moadong.moadong&pcampaignid=web_share',
+};
+
+const getAppStoreLink = (): string => {
+ const userAgent = navigator.userAgent.toLowerCase();
+
+ if (/iphone|ipad|ipod|macintosh/.test(userAgent)) {
+ return APP_STORE_LINKS.ios;
+ }
+ if (/android/.test(userAgent)) {
+ return APP_STORE_LINKS.android;
+ }
+ return APP_STORE_LINKS.default;
+};
+
const Banner = () => {
const { isMobile } = useDevice();
const handleLink = useNavigator();
+ const trackEvent = useMixpanelTrack();
const [swiperInstance, setSwiperInstance] = useState(null);
const [currentIndex, setCurrentIndex] = useState(0);
@@ -23,10 +45,22 @@ const Banner = () => {
swiperInstance?.slideNext();
};
- const handleBannerClick = (url?: string) => {
- if (url) {
- handleLink(url);
+ const handleBannerClick = (bannerId: string, bannerName: string, url?: string) => {
+ if (!url) return;
+
+ if (url === 'APP_STORE_LINK') {
+ const storeLink = getAppStoreLink();
+ trackEvent(USER_EVENT.APP_DOWNLOAD_BANNER_CLICKED, {
+ bannerId,
+ bannerName,
+ platform: /iphone|ipad|ipod|macintosh/.test(navigator.userAgent.toLowerCase()) ? 'ios' : 'android',
+ });
+ handleLink(storeLink);
+ return;
}
+
+ trackEvent(USER_EVENT.BANNER_CLICKED, { bannerId, bannerName, linkTo: url });
+ handleLink(url);
};
return (
@@ -56,7 +90,7 @@ const Banner = () => {
handleBannerClick(banner.linkTo)}
+ onClick={() => handleBannerClick(banner.id, banner.alt, banner.linkTo)}
>
