Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/constants/eventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/hooks/__tests__/useNavigator.test.ts
Original file line number Diff line number Diff line change
@@ -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,<script>alert("XSS")</script>'],
['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('');
});
});
});

7 changes: 5 additions & 2 deletions frontend/src/hooks/useNavigator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
42 changes: 38 additions & 4 deletions frontend/src/pages/MainPage/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SwiperType | null>(null);
const [currentIndex, setCurrentIndex] = useState(0);

Expand All @@ -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 (
Expand Down Expand Up @@ -56,7 +90,7 @@ const Banner = () => {
<SwiperSlide key={banner.id}>
<Styled.BannerItem
isClickable={!!banner.linkTo}
onClick={() => handleBannerClick(banner.linkTo)}
onClick={() => handleBannerClick(banner.id, banner.alt, banner.linkTo)}
>
<img
src={isMobile ? banner.mobileImage : banner.desktopImage}
Expand Down
19 changes: 9 additions & 10 deletions frontend/src/pages/MainPage/components/Banner/bannerData.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import AllClubsDesktopImage from '@/assets/images/banners/banner_desktop1.png';
import StartNowDesktopImage from '@/assets/images/banners/banner_desktop2.png';
import PatchNoteDesktopImage from '@/assets/images/banners/banner_desktop3.png';
import AllClubsMobileImage from '@/assets/images/banners/banner_mobile1.png';
import StartNowMobileImage from '@/assets/images/banners/banner_mobile2.png';
import PatchNoteMobileImage from '@/assets/images/banners/banner_mobile3.png';
import AppReleaseDesktopImage from '@/assets/images/banners/banner_desktop4.png';
import AppReleaseMobileImage from '@/assets/images/banners/banner_mobile4.png';

interface BannerItem {
id: string;
Expand All @@ -14,6 +14,13 @@ interface BannerItem {
}

const BANNERS: BannerItem[] = [
{
id: 'app-release-december-2025',
desktopImage: AppReleaseDesktopImage,
mobileImage: AppReleaseMobileImage,
linkTo: 'APP_STORE_LINK',
alt: '앱 다운로드 배너',
},
{
id: 'all-clubs-in-one-place',
desktopImage: AllClubsDesktopImage,
Expand All @@ -28,14 +35,6 @@ const BANNERS: BannerItem[] = [
linkTo: '/introduce',
alt: '지금 바로 모아동에서 시작하세요',
},
{
id: 'patch-note-november-2025',
desktopImage: PatchNoteDesktopImage,
mobileImage: PatchNoteMobileImage,
linkTo:
'https://honorable-cough-8f9.notion.site/1e8aad232096804f9ea9ee4f5cf0cd10',
alt: '모아동 11월 패치노트 안내 - 지원서 관리 및 메인페이지 개편',
},
];

export default BANNERS;