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)} >