Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
222c373
refactor: useIsMobile 훅을 useDevice로 통합
seongwon030 Dec 8, 2025
2c0fe8e
feat: 관리자 사이드바에서 회원탈퇴 기능 제거
seongwon030 Dec 10, 2025
f5203b0
feat: 중앙동아리 문구에 학교명 추가
seongwon030 Dec 16, 2025
1f9562c
Merge pull request #921 from Moadong/refactor/#920-unify-device-detec…
seongwon030 Dec 17, 2025
42b3766
Merge pull request #924 from Moadong/feature/#923-remove-account-dele…
seongwon030 Dec 17, 2025
2978c99
Merge pull request #936 from Moadong/feature/add-school-name
seongwon030 Dec 17, 2025
8a0d26e
feat: 모아동 앱출시 배너 추가
seongwon030 Dec 17, 2025
38ec1f1
fix: useNavigator 외부 URL 판별 시 허용 프로토콜 화이트리스트 적용
seongwon030 Dec 17, 2025
a33dde5
feat: 앱 릴리즈 배너 데이터 추가
seongwon030 Dec 17, 2025
57db215
feat: user-agent 기반 OS별 스토어 링크 분기 처리
seongwon030 Dec 17, 2025
9e174c3
refactor: console 제거
seongwon030 Dec 17, 2025
8afe9cb
fix: 악성 프로토콜 차단 및 외부 URL 화이트리스트 적용
seongwon030 Dec 17, 2025
6203c5d
test: 링크 이동 기능 단위 테스트 추가
seongwon030 Dec 17, 2025
54ea58e
refactor: useNavigator 테스트 가독성 개선
seongwon030 Dec 17, 2025
5454edd
feat: 배너 순서 변경 (앱 릴리즈 1번으로)
seongwon030 Dec 17, 2025
eee9400
feat: 배너 클릭 이벤트 트래킹 추가
seongwon030 Dec 17, 2025
1c8d5c6
refactor: useNavigator 테스트에 it.each 적용으로 중복 제거
seongwon030 Dec 17, 2025
d14a323
Merge pull request #938 from Moadong/feature/#937-add-app-banner-MOA-427
seongwon030 Dec 17, 2025
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.
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import * as Styled from './QuestionTitle.styles';
import { APPLICATION_FORM } from '@/constants/APPLICATION_FORM';
import useIsMobile from '@/hooks/useIsMobile';
import { useEffect, useLayoutEffect, useRef } from 'react';
import useDevice from '@/hooks/useDevice';
import { useLayoutEffect, useRef } from 'react';

interface QuestionTitleProps {
id: number;
Expand All @@ -18,7 +18,7 @@ const QuestionTitle = ({
mode,
onTitleChange,
}: QuestionTitleProps) => {
const isMobile = useIsMobile();
const { isMobile } = useDevice();
const textAreaRef = useRef<HTMLTextAreaElement>(null);

useLayoutEffect(() => {
Expand Down
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('');
});
});
});

18 changes: 0 additions & 18 deletions frontend/src/hooks/useIsMobile.ts

This file was deleted.

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
9 changes: 0 additions & 9 deletions frontend/src/pages/AdminPage/components/SideBar/SideBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,6 @@ const tabs: TabCategory[] = [
category: '계정 관리',
items: [
{ label: '비밀번호 수정', path: '/admin/account-edit' },
{ label: '회원탈퇴', path: '/admin/user-delete' },
],
},
];
Expand All @@ -64,14 +63,6 @@ const SideBar = ({ clubLogo, clubName }: SideBarProps) => {
trackEvent(ADMIN_EVENT.TAB_CLICKED, {
tabName: item.label,
});
// if (item.label === '아이디/비밀번호 수정') {
// alert('아이디/비밀번호 수정 기능은 아직 준비 중이에요. ☺️');
// return;
// }
if (item.label === '회원탈퇴') {
alert('회원탈퇴 기능은 아직 준비 중이에요. ☺️');
return;
}
navigate(item.path);
};

Expand Down
4 changes: 2 additions & 2 deletions frontend/src/pages/MainPage/MainPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ const MainPage = () => {
const recruitmentStatus = 'all';
const division = 'all';
const searchCategory = isSearching ? 'all' : selectedCategory;
const tabs = ['중앙동아리'] as const;
const [active, setActive] = useState<(typeof tabs)[number]>('중앙동아리');
const tabs = ['부경대학교 중앙동아리'] as const;
const [active, setActive] = useState<(typeof tabs)[number]>('부경대학교 중앙동아리');
// TODO: 추후 확정되면 DivisionKey(중동/가동/과동) 같은 타입을
// types/club.ts에 정의해서 tabs 관리하도록 리팩터링하기

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;