From bc6c68e8a711eca4a89fc7f02ba773e3a254514f Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 19:47:41 +0900 Subject: [PATCH 01/12] =?UTF-8?q?feat:=20=ED=94=8C=EB=9E=AB=ED=8F=BC?= =?UTF-8?q?=EB=B3=84=20=EC=95=B1=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EB=A7=81?= =?UTF-8?q?=ED=81=AC=20=EC=9C=A0=ED=8B=B8=20=ED=95=A8=EC=88=98=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Platform 타입 정의 ('iOS' | 'Android' | 'Other') - getAppStoreLink: 사용자 플랫폼 감지 후 적절한 앱 스토어 링크 반환 - detectPlatform: iOS/Android/기타 플랫폼 감지 - APP_STORE_LINKS: 플랫폼별 앱 스토어 링크 상수 - 테스트 코드 작성 (12개 테스트, 100% 커버리지) --- frontend/src/utils/appStoreLink.test.ts | 153 ++++++++++++++++++++++++ frontend/src/utils/appStoreLink.ts | 33 +++++ 2 files changed, 186 insertions(+) create mode 100644 frontend/src/utils/appStoreLink.test.ts create mode 100644 frontend/src/utils/appStoreLink.ts diff --git a/frontend/src/utils/appStoreLink.test.ts b/frontend/src/utils/appStoreLink.test.ts new file mode 100644 index 000000000..993e01271 --- /dev/null +++ b/frontend/src/utils/appStoreLink.test.ts @@ -0,0 +1,153 @@ +import { + APP_STORE_LINKS, + detectPlatform, + getAppStoreLink, +} from './appStoreLink'; + +describe('appStoreLink 유틸 함수 테스트', () => { + const originalNavigator = global.navigator; + + afterEach(() => { + Object.defineProperty(global, 'navigator', { + value: originalNavigator, + writable: true, + configurable: true, + }); + }); + + describe('detectPlatform', () => { + it('iOS 기기를 감지한다 (iPhone)', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('iOS'); + }); + + it('iOS 기기를 감지한다 (iPad)', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (iPad; CPU OS 14_0 like Mac OS X) AppleWebKit/605.1.15', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('iOS'); + }); + + it('iOS 기기를 감지한다 (Macintosh)', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('iOS'); + }); + + it('Android 기기를 감지한다', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('Android'); + }); + + it('기타 플랫폼을 감지한다 (Windows)', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('Other'); + }); + + it('기타 플랫폼을 감지한다 (Linux)', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(detectPlatform()).toBe('Other'); + }); + }); + + describe('getAppStoreLink', () => { + it('iOS 기기에서 App Store 링크를 반환한다', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/605.1.15', + }, + writable: true, + configurable: true, + }); + + expect(getAppStoreLink()).toBe(APP_STORE_LINKS.ios); + }); + + it('Android 기기에서 Play Store 링크를 반환한다', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(getAppStoreLink()).toBe(APP_STORE_LINKS.android); + }); + + it('기타 플랫폼에서 기본 링크를 반환한다', () => { + Object.defineProperty(global, 'navigator', { + value: { + userAgent: + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36', + }, + writable: true, + configurable: true, + }); + + expect(getAppStoreLink()).toBe(APP_STORE_LINKS.default); + }); + }); + + describe('APP_STORE_LINKS 상수', () => { + it('iOS 앱 스토어 링크가 올바른 형식이다', () => { + expect(APP_STORE_LINKS.ios).toContain('itms-apps://'); + expect(APP_STORE_LINKS.ios).toContain('6755062085'); + }); + + it('Android Play Store 링크가 올바른 형식이다', () => { + expect(APP_STORE_LINKS.android).toContain('play.google.com'); + expect(APP_STORE_LINKS.android).toContain('com.moadong.moadong'); + }); + + it('기본 링크가 Android 링크와 동일하다', () => { + expect(APP_STORE_LINKS.default).toBe(APP_STORE_LINKS.android); + }); + }); +}); diff --git a/frontend/src/utils/appStoreLink.ts b/frontend/src/utils/appStoreLink.ts new file mode 100644 index 000000000..74abf6f43 --- /dev/null +++ b/frontend/src/utils/appStoreLink.ts @@ -0,0 +1,33 @@ +export 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', +} as const; + +export type Platform = 'iOS' | 'Android' | 'Other'; + +export 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; +}; + +export const detectPlatform = (): Platform => { + const userAgent = navigator.userAgent.toLowerCase(); + + if (/iphone|ipad|ipod|macintosh/.test(userAgent)) { + return 'iOS'; + } + if (/android/.test(userAgent)) { + return 'Android'; + } + return 'Other'; +}; From 5e4704533bf47a827559bdfd2eea446fce3f0add Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:03:57 +0900 Subject: [PATCH 02/12] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EB=B0=B0?= =?UTF-8?q?=EB=84=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/assets/images/popup/app-download.svg | 330 ++++++++++++++++++ 1 file changed, 330 insertions(+) create mode 100644 frontend/src/assets/images/popup/app-download.svg diff --git a/frontend/src/assets/images/popup/app-download.svg b/frontend/src/assets/images/popup/app-download.svg new file mode 100644 index 000000000..d1ebef24f --- /dev/null +++ b/frontend/src/assets/images/popup/app-download.svg @@ -0,0 +1,330 @@ + + + + +
+
+ + + + + + + + + + + + + + +
+
+ + +
+
+ + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + + + + +
+ + + + + + + + + + + + +
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
From 4b57405077305fc785f597e85f6b92a0825b76f6 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:04:05 +0900 Subject: [PATCH 03/12] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=EB=AA=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/constants/eventName.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index f4de46e73..e5137ac53 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', + // 메인 페이지 팝업 + MAIN_POPUP_VIEWED: 'Main Popup Viewed', + MAIN_POPUP_CLOSED: 'Main Popup Closed', + APP_DOWNLOAD_POPUP_CLICKED: 'App Download Popup Clicked', + // 배너 클릭 BANNER_CLICKED: 'Banner Clicked', APP_DOWNLOAD_BANNER_CLICKED: 'App Download Banner Clicked', From 86565b01745a6338bf87447440e4108deb161fb7 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:05:26 +0900 Subject: [PATCH 04/12] =?UTF-8?q?refactor:=20Banner=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20=EC=95=B1=20=EC=8A=A4=ED=86=A0=EC=96=B4=20?= =?UTF-8?q?=EB=A7=81=ED=81=AC=20=EB=A1=9C=EC=A7=81=20=EC=9C=A0=ED=8B=B8?= =?UTF-8?q?=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - APP_STORE_LINKS 상수 및 getAppStoreLink 함수 제거 - appStoreLink 유틸의 getAppStoreLink, detectPlatform 사용 - 플랫폼 감지 로직 간소화 및 코드 재사용성 향상 --- .../MainPage/components/Banner/Banner.tsx | 27 ++----------------- 1 file changed, 2 insertions(+), 25 deletions(-) diff --git a/frontend/src/pages/MainPage/components/Banner/Banner.tsx b/frontend/src/pages/MainPage/components/Banner/Banner.tsx index 7b305b6e0..2d5bca9cd 100644 --- a/frontend/src/pages/MainPage/components/Banner/Banner.tsx +++ b/frontend/src/pages/MainPage/components/Banner/Banner.tsx @@ -8,29 +8,10 @@ import { USER_EVENT } from '@/constants/eventName'; import useDevice from '@/hooks/useDevice'; import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import useNavigator from '@/hooks/useNavigator'; +import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; import * as Styled from './Banner.styles'; import BANNERS from './bannerData'; -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(); @@ -58,11 +39,7 @@ const Banner = () => { trackEvent(USER_EVENT.APP_DOWNLOAD_BANNER_CLICKED, { bannerId, bannerName, - platform: /iphone|ipad|ipod|macintosh/.test( - navigator.userAgent.toLowerCase(), - ) - ? 'ios' - : 'android', + platform: detectPlatform(), }); handleLink(storeLink); return; From 1001e4c9b025d31e717e1da672aeefd017c4b6c2 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:36:41 +0900 Subject: [PATCH 05/12] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EC=95=B1=20=EB=8B=A4=EC=9A=B4=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=ED=8C=9D=EC=97=85=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모바일에서만 표시되는 앱 출시 안내 팝업 구현 - useDevice 훅을 활용한 반응형 모바일 감지 - localStorage 기반 '다시 보지 않기' 기능 - 플랫폼별 앱 스토어 자동 연결 (iOS/Android) - Mixpanel 이벤트 트래킹 (팝업 표시, 닫기, 다운로드 클릭) - 이미지 클릭 시 앱 다운로드 페이지 이동 - 하단 버튼 그룹: 다시 보지 않기 / 닫기 - 배경 클릭으로 팝업 닫기 지원 --- frontend/src/pages/MainPage/MainPage.tsx | 4 +- .../MainPage/components/Popup/Popup.styles.ts | 84 +++++++++++++++ .../pages/MainPage/components/Popup/Popup.tsx | 101 ++++++++++++++++++ 3 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 frontend/src/pages/MainPage/components/Popup/Popup.styles.ts create mode 100644 frontend/src/pages/MainPage/components/Popup/Popup.tsx diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx index a57197951..38f427a0b 100644 --- a/frontend/src/pages/MainPage/MainPage.tsx +++ b/frontend/src/pages/MainPage/MainPage.tsx @@ -8,6 +8,7 @@ import useTrackPageView from '@/hooks/useTrackPageView'; import Banner from '@/pages/MainPage/components/Banner/Banner'; import CategoryButtonList from '@/pages/MainPage/components/CategoryButtonList/CategoryButtonList'; import ClubCard from '@/pages/MainPage/components/ClubCard/ClubCard'; +import Popup from '@/pages/MainPage/components/Popup/Popup'; import { useSelectedCategory } from '@/store/useCategoryStore'; import { useSearchIsSearching, useSearchKeyword } from '@/store/useSearchStore'; import { Club } from '@/types/club'; @@ -25,8 +26,6 @@ const MainPage = () => { const tabs = ['부경대학교 중앙동아리'] as const; const [active, setActive] = useState<(typeof tabs)[number]>('부경대학교 중앙동아리'); - // TODO: 추후 확정되면 DivisionKey(중동/가동/과동) 같은 타입을 - // types/club.ts에 정의해서 tabs 관리하도록 리팩터링하기 const { data, error, isLoading } = useGetCardList({ keyword, @@ -53,6 +52,7 @@ const MainPage = () => { return ( <> +
diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts b/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts new file mode 100644 index 000000000..4f9f0b800 --- /dev/null +++ b/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts @@ -0,0 +1,84 @@ +import styled from 'styled-components'; +import { theme, Theme } from '@/styles/theme'; +import { Z_INDEX } from '@/styles/zIndex'; + +export const Overlay = styled.div<{ isOpen: boolean }>` + inset: 0; + position: fixed; + z-index: ${Z_INDEX.overlay}; + background: rgba(0, 0, 0, ${({ isOpen }) => (isOpen ? 0.45 : 0)}); + display: grid; + place-items: center; + padding: 24px; + transition: background-color 0.2s ease; +`; + +export const ModalContainer = styled.div<{ isOpen: boolean }>` + position: relative; + z-index: ${Z_INDEX.modal}; + max-width: 500px; + width: 100%; + max-height: 90vh; + background: transparent; + border-radius: 10px; + overflow: visible; + transition: transform 0.2s ease; +`; + +export const Container = styled.div` + display: flex; + flex-direction: column; + width: 100%; + overflow: hidden; + background-color: #ffffff; + border-radius: 10px; +`; + +export const ImageWrapper = styled.div` + width: 100%; + position: relative; + overflow: hidden; + cursor: pointer; + + &:active { + opacity: 0.95; + } +`; + +export const PopupImage = styled.img` + width: 100%; + height: auto; + display: block; + object-fit: cover; +`; + +export const ButtonGroup = styled.div` + display: flex; + align-items: center; + width: 100%; + background-color: ${theme.colors.gray[900]}; + border-radius: 0 0 16px 16px; +`; + +export const Button = styled.button` + flex: 1; + padding: 20px; + background-color: ${theme.colors.gray[900]}; + color: ${theme.colors.base.white}; + font-size: 15px; + font-weight: 500; + border: none; + cursor: pointer; + position: relative; + + &:first-child::after { + content: ''; + position: absolute; + right: 0; + top: 50%; + transform: translateY(-50%); + width: 1px; + height: 40%; + background-color: ${theme.colors.gray[600]}; + } +`; diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx new file mode 100644 index 000000000..fa39bb817 --- /dev/null +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -0,0 +1,101 @@ +import { MouseEvent, useEffect, useState } from 'react'; +import AppDownloadImage from '@/assets/images/popup/app-download.svg'; +import { USER_EVENT } from '@/constants/eventName'; +import useDevice from '@/hooks/useDevice'; +import useMixpanelTrack from '@/hooks/useMixpanelTrack'; +import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; +import * as Styled from './Popup.styles'; + +const POPUP_STORAGE_KEY = 'mainpage_popup_hidden'; + +const Popup = () => { + const [isOpen, setIsOpen] = useState(false); + const { isMobile } = useDevice(); + const trackEvent = useMixpanelTrack(); + + useEffect(() => { + const isHidden = localStorage.getItem(POPUP_STORAGE_KEY); + + if (isMobile && !isHidden) { + setIsOpen(true); + trackEvent(USER_EVENT.MAIN_POPUP_VIEWED, { + popupType: 'app_download', + }); + } + }, [isMobile, trackEvent]); + + useEffect(() => { + if (isOpen) { + document.body.style.overflow = 'hidden'; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isOpen]); + + const handleClose = () => { + trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { + popupType: 'app_download', + action: 'close_button', + }); + setIsOpen(false); + }; + + const handleDontShowAgain = () => { + trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { + popupType: 'app_download', + action: 'dont_show_again', + }); + localStorage.setItem(POPUP_STORAGE_KEY, 'true'); + setIsOpen(false); + }; + + const handleDownload = () => { + const storeLink = getAppStoreLink(); + trackEvent(USER_EVENT.APP_DOWNLOAD_POPUP_CLICKED, { + popupType: 'app_download', + platform: detectPlatform(), + }); + window.open(storeLink, '_blank'); + }; + + const handleBackdropClick = (e: MouseEvent) => { + if (e.target === e.currentTarget) { + trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { + popupType: 'app_download', + action: 'backdrop_click', + }); + handleClose(); + } + }; + + if (!isOpen) return null; + + return ( + + ) => e.stopPropagation()} + > + + + + + + + + 다시 보지 않기 + + 닫기 + + + + + ); +}; + +export default Popup; From e6946946fb47915e7ec08d6c9272bbc78548377f Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:51:01 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20=EB=A9=94=EC=9D=B8=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=8C=9D=EC=97=85=20A/B=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사용자를 50/50 비율로 show_popup/no_popup 그룹에 랜덤 할당 - localStorage로 그룹 정보 영구 저장 (동일 사용자는 항상 같은 그룹) - show_popup 그룹만 팝업 표시, no_popup 그룹은 컨트롤 그룹 - 모든 Mixpanel 이벤트에 abTestGroup 속성 추가 - MAIN_POPUP_NOT_SHOWN 이벤트 추가 (컨트롤 그룹 트래킹) - PopupABTestGroup 타입 정의로 다른 A/B 테스트와 구분 --- frontend/src/constants/eventName.ts | 1 + .../pages/MainPage/components/Popup/Popup.tsx | 42 +++++++++++++++++-- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts index e5137ac53..0fd757188 100644 --- a/frontend/src/constants/eventName.ts +++ b/frontend/src/constants/eventName.ts @@ -4,6 +4,7 @@ export const USER_EVENT = { // 메인 페이지 팝업 MAIN_POPUP_VIEWED: 'Main Popup Viewed', + MAIN_POPUP_NOT_SHOWN: 'Main Popup Not Shown', MAIN_POPUP_CLOSED: 'Main Popup Closed', APP_DOWNLOAD_POPUP_CLICKED: 'App Download Popup Clicked', diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index fa39bb817..81a78a450 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -7,6 +7,23 @@ import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; import * as Styled from './Popup.styles'; const POPUP_STORAGE_KEY = 'mainpage_popup_hidden'; +const AB_TEST_KEY = 'mainpage_popup_ab_group'; + +type PopupABTestGroup = 'show_popup' | 'no_popup'; + +const getABTestGroup = (): PopupABTestGroup => { + const savedGroup = localStorage.getItem(AB_TEST_KEY); + + if (savedGroup === 'show_popup' || savedGroup === 'no_popup') { + return savedGroup; + } + + const group: PopupABTestGroup = + Math.random() < 0.5 ? 'show_popup' : 'no_popup'; + localStorage.setItem(AB_TEST_KEY, group); + + return group; +}; const Popup = () => { const [isOpen, setIsOpen] = useState(false); @@ -15,12 +32,21 @@ const Popup = () => { useEffect(() => { const isHidden = localStorage.getItem(POPUP_STORAGE_KEY); + const abGroup = getABTestGroup(); if (isMobile && !isHidden) { - setIsOpen(true); - trackEvent(USER_EVENT.MAIN_POPUP_VIEWED, { - popupType: 'app_download', - }); + if (abGroup === 'show_popup') { + setIsOpen(true); + trackEvent(USER_EVENT.MAIN_POPUP_VIEWED, { + popupType: 'app_download', + abTestGroup: abGroup, + }); + } else { + trackEvent(USER_EVENT.MAIN_POPUP_NOT_SHOWN, { + popupType: 'app_download', + abTestGroup: abGroup, + }); + } } }, [isMobile, trackEvent]); @@ -34,17 +60,21 @@ const Popup = () => { }, [isOpen]); const handleClose = () => { + const abGroup = getABTestGroup(); trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { popupType: 'app_download', action: 'close_button', + abTestGroup: abGroup, }); setIsOpen(false); }; const handleDontShowAgain = () => { + const abGroup = getABTestGroup(); trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { popupType: 'app_download', action: 'dont_show_again', + abTestGroup: abGroup, }); localStorage.setItem(POPUP_STORAGE_KEY, 'true'); setIsOpen(false); @@ -52,18 +82,22 @@ const Popup = () => { const handleDownload = () => { const storeLink = getAppStoreLink(); + const abGroup = getABTestGroup(); trackEvent(USER_EVENT.APP_DOWNLOAD_POPUP_CLICKED, { popupType: 'app_download', platform: detectPlatform(), + abTestGroup: abGroup, }); window.open(storeLink, '_blank'); }; const handleBackdropClick = (e: MouseEvent) => { if (e.target === e.currentTarget) { + const abGroup = getABTestGroup(); trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { popupType: 'app_download', action: 'backdrop_click', + abTestGroup: abGroup, }); handleClose(); } From 0662b7bdc873c8f81ca77c4829027066e50eea84 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 20:59:12 +0900 Subject: [PATCH 07/12] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20'=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B3=B4=EC=A7=80=20=EC=95=8A=EA=B8=B0'=207?= =?UTF-8?q?=EC=9D=BC=20=EA=B8=B0=ED=95=9C=EC=A0=9C=20=EB=B0=8F=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - localStorage에 타임스탬프 저장하여 7일 후 자동 재표시 - isPopupHidden 함수로 날짜 기반 팝업 숨김 로직 구현 - POPUP_STORAGE_KEY를 'mainpage_popup_hidden_date'로 변경 - 테스트를 위해 상수 및 유틸 함수 export - 17개 테스트 케이스 작성 (모두 통과) - A/B 테스트 그룹 할당 테스트 - 7일 기반 팝업 숨김 로직 테스트 - 통합 시나리오 테스트 - 핵심 로직 90.9% 커버리지 달성 --- .../MainPage/components/Popup/Popup.test.tsx | 156 ++++++++++++++++++ .../pages/MainPage/components/Popup/Popup.tsx | 20 ++- 2 files changed, 171 insertions(+), 5 deletions(-) create mode 100644 frontend/src/pages/MainPage/components/Popup/Popup.test.tsx diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.test.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.test.tsx new file mode 100644 index 000000000..39ba87a97 --- /dev/null +++ b/frontend/src/pages/MainPage/components/Popup/Popup.test.tsx @@ -0,0 +1,156 @@ +import { + AB_TEST_KEY, + DAYS_TO_HIDE, + getABTestGroup, + isPopupHidden, + POPUP_STORAGE_KEY, +} from './Popup'; + +describe('Popup 유틸 함수 테스트', () => { + beforeEach(() => { + localStorage.clear(); + }); + + afterEach(() => { + localStorage.clear(); + }); + + describe('getABTestGroup', () => { + it('저장된 그룹이 없으면 새로운 그룹을 할당한다', () => { + const group = getABTestGroup(); + expect(['show_popup', 'no_popup']).toContain(group); + expect(localStorage.getItem(AB_TEST_KEY)).toBe(group); + }); + + it('저장된 그룹이 있으면 동일한 그룹을 반환한다', () => { + localStorage.setItem(AB_TEST_KEY, 'show_popup'); + const group = getABTestGroup(); + expect(group).toBe('show_popup'); + }); + + it('여러 번 호출해도 동일한 그룹을 유지한다', () => { + const firstCall = getABTestGroup(); + const secondCall = getABTestGroup(); + const thirdCall = getABTestGroup(); + + expect(firstCall).toBe(secondCall); + expect(secondCall).toBe(thirdCall); + }); + + it('약 50/50 비율로 그룹을 할당한다', () => { + const results = { show_popup: 0, no_popup: 0 }; + const iterations = 1000; + + for (let i = 0; i < iterations; i++) { + localStorage.clear(); + const group = getABTestGroup(); + results[group]++; + } + + // 40-60% 범위 내에 있으면 통과 + expect(results.show_popup).toBeGreaterThan(iterations * 0.4); + expect(results.show_popup).toBeLessThan(iterations * 0.6); + }); + }); + + describe('isPopupHidden', () => { + it('저장된 날짜가 없으면 false를 반환한다', () => { + expect(isPopupHidden()).toBe(false); + }); + + it('7일 미만이면 true를 반환한다 (1일 전)', () => { + const oneDayAgo = Date.now() - 1 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, oneDayAgo.toString()); + + expect(isPopupHidden()).toBe(true); + }); + + it('7일 미만이면 true를 반환한다 (6일 전)', () => { + const sixDaysAgo = Date.now() - 6 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, sixDaysAgo.toString()); + + expect(isPopupHidden()).toBe(true); + }); + + it('정확히 7일이면 false를 반환한다', () => { + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, sevenDaysAgo.toString()); + + expect(isPopupHidden()).toBe(false); + }); + + it('7일 이상이면 false를 반환한다 (8일 전)', () => { + const eightDaysAgo = Date.now() - 8 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, eightDaysAgo.toString()); + + expect(isPopupHidden()).toBe(false); + }); + + it('7일 이상이면 false를 반환한다 (30일 전)', () => { + const thirtyDaysAgo = Date.now() - 30 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, thirtyDaysAgo.toString()); + + expect(isPopupHidden()).toBe(false); + }); + + it('방금 저장한 경우 true를 반환한다', () => { + localStorage.setItem(POPUP_STORAGE_KEY, Date.now().toString()); + + expect(isPopupHidden()).toBe(true); + }); + + it('잘못된 형식의 날짜는 NaN으로 처리되어 false를 반환한다', () => { + localStorage.setItem(POPUP_STORAGE_KEY, 'invalid_date'); + + expect(isPopupHidden()).toBe(false); + }); + }); + + describe('DAYS_TO_HIDE 상수', () => { + it('7일로 설정되어 있다', () => { + expect(DAYS_TO_HIDE).toBe(7); + }); + }); + + describe('localStorage 키', () => { + it('POPUP_STORAGE_KEY가 올바르게 정의되어 있다', () => { + expect(POPUP_STORAGE_KEY).toBe('mainpage_popup_hidden_date'); + }); + + it('AB_TEST_KEY가 올바르게 정의되어 있다', () => { + expect(AB_TEST_KEY).toBe('mainpage_popup_ab_group'); + }); + }); + + describe('통합 시나리오', () => { + it('시나리오: 첫 방문 → 다시 보지 않기 → 6일 후 방문 → 7일 후 방문', () => { + // 1. 첫 방문 + expect(isPopupHidden()).toBe(false); + + // 2. "다시 보지 않기" 클릭 + localStorage.setItem(POPUP_STORAGE_KEY, Date.now().toString()); + expect(isPopupHidden()).toBe(true); + + // 3. 6일 후 방문 (아직 숨김) + const sixDaysAgo = Date.now() - 6 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, sixDaysAgo.toString()); + expect(isPopupHidden()).toBe(true); + + // 4. 7일 후 방문 (다시 표시) + const sevenDaysAgo = Date.now() - 7 * 24 * 60 * 60 * 1000; + localStorage.setItem(POPUP_STORAGE_KEY, sevenDaysAgo.toString()); + expect(isPopupHidden()).toBe(false); + }); + + it('시나리오: A/B 그룹 할당 후 팝업 숨김 상태 확인', () => { + const group = getABTestGroup(); + expect(['show_popup', 'no_popup']).toContain(group); + + expect(isPopupHidden()).toBe(false); + + localStorage.setItem(POPUP_STORAGE_KEY, Date.now().toString()); + expect(isPopupHidden()).toBe(true); + expect(getABTestGroup()).toBe(group); + }); + }); +}); diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index 81a78a450..b0b494d2f 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -6,12 +6,13 @@ import useMixpanelTrack from '@/hooks/useMixpanelTrack'; import { detectPlatform, getAppStoreLink } from '@/utils/appStoreLink'; import * as Styled from './Popup.styles'; -const POPUP_STORAGE_KEY = 'mainpage_popup_hidden'; -const AB_TEST_KEY = 'mainpage_popup_ab_group'; +export const POPUP_STORAGE_KEY = 'mainpage_popup_hidden_date'; +export const AB_TEST_KEY = 'mainpage_popup_ab_group'; +export const DAYS_TO_HIDE = 7; type PopupABTestGroup = 'show_popup' | 'no_popup'; -const getABTestGroup = (): PopupABTestGroup => { +export const getABTestGroup = (): PopupABTestGroup => { const savedGroup = localStorage.getItem(AB_TEST_KEY); if (savedGroup === 'show_popup' || savedGroup === 'no_popup') { @@ -25,13 +26,22 @@ const getABTestGroup = (): PopupABTestGroup => { return group; }; +export const isPopupHidden = (): boolean => { + const hiddenDate = localStorage.getItem(POPUP_STORAGE_KEY); + if (!hiddenDate) return false; + + const daysSinceHidden = + (Date.now() - parseInt(hiddenDate)) / (1000 * 60 * 60 * 24); + return daysSinceHidden < DAYS_TO_HIDE; +}; + const Popup = () => { const [isOpen, setIsOpen] = useState(false); const { isMobile } = useDevice(); const trackEvent = useMixpanelTrack(); useEffect(() => { - const isHidden = localStorage.getItem(POPUP_STORAGE_KEY); + const isHidden = isPopupHidden(); const abGroup = getABTestGroup(); if (isMobile && !isHidden) { @@ -76,7 +86,7 @@ const Popup = () => { action: 'dont_show_again', abTestGroup: abGroup, }); - localStorage.setItem(POPUP_STORAGE_KEY, 'true'); + localStorage.setItem(POPUP_STORAGE_KEY, Date.now().toString()); setIsOpen(false); }; From 655e5edce478155cad9d3ecb1e267cc062de5541 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 21:14:11 +0900 Subject: [PATCH 08/12] =?UTF-8?q?style:=20=ED=8C=9D=EC=97=85=EB=AA=A8?= =?UTF-8?q?=EB=8B=AC=20border=20radius=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/MainPage/components/Popup/Popup.styles.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts b/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts index 4f9f0b800..81229df75 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts +++ b/frontend/src/pages/MainPage/components/Popup/Popup.styles.ts @@ -20,7 +20,7 @@ export const ModalContainer = styled.div<{ isOpen: boolean }>` width: 100%; max-height: 90vh; background: transparent; - border-radius: 10px; + border-radius: 16px; overflow: visible; transition: transform 0.2s ease; `; From 20e203a69b2ef6c175e3c6c47c2a3873c9e62041 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 21:17:39 +0900 Subject: [PATCH 09/12] =?UTF-8?q?refactor:=20AB=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=B9=84=EC=9C=A8=20=EC=83=81=EC=88=98=EB=A1=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/MainPage/components/Popup/Popup.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index b0b494d2f..b579668eb 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -9,6 +9,7 @@ import * as Styled from './Popup.styles'; export const POPUP_STORAGE_KEY = 'mainpage_popup_hidden_date'; export const AB_TEST_KEY = 'mainpage_popup_ab_group'; export const DAYS_TO_HIDE = 7; +const AB_TEST_SPLIT_RATIO = 0.5; type PopupABTestGroup = 'show_popup' | 'no_popup'; @@ -20,7 +21,7 @@ export const getABTestGroup = (): PopupABTestGroup => { } const group: PopupABTestGroup = - Math.random() < 0.5 ? 'show_popup' : 'no_popup'; + Math.random() < AB_TEST_SPLIT_RATIO ? 'show_popup' : 'no_popup'; localStorage.setItem(AB_TEST_KEY, group); return group; From a3ac814588721ec225dfd4f4b59211e00b570785 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 21:24:17 +0900 Subject: [PATCH 10/12] =?UTF-8?q?refactor:=20=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=EB=8B=AB=EA=B8=B0=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=ED=8A=B8=EB=9E=98=ED=82=B9=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleClose에 action 파라미터 추가 - 닫기 방법(close_button, backdrop_click)을 파라미터로 전달 - handleBackdropClick에서 이벤트 중복 트래킹 제거 - 각 닫기 방법이 정확히 한 번씩만 트래킹되도록 개선 --- .../pages/MainPage/components/Popup/Popup.tsx | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index b579668eb..23ac09106 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -70,11 +70,13 @@ const Popup = () => { }; }, [isOpen]); - const handleClose = () => { + const handleClose = ( + action: 'close_button' | 'backdrop_click' = 'close_button', + ) => { const abGroup = getABTestGroup(); trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { popupType: 'app_download', - action: 'close_button', + action: action, abTestGroup: abGroup, }); setIsOpen(false); @@ -104,13 +106,7 @@ const Popup = () => { const handleBackdropClick = (e: MouseEvent) => { if (e.target === e.currentTarget) { - const abGroup = getABTestGroup(); - trackEvent(USER_EVENT.MAIN_POPUP_CLOSED, { - popupType: 'app_download', - action: 'backdrop_click', - abTestGroup: abGroup, - }); - handleClose(); + handleClose('backdrop_click'); } }; @@ -135,7 +131,7 @@ const Popup = () => { 다시 보지 않기 - 닫기 + handleClose()}>닫기 From c3fe6f1e6cd96c12cc686b381d460b27f8b33042 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 21:28:42 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20=ED=8C=9D=EC=97=85=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20preload=EB=A1=9C=20=EB=A0=8C=EB=8D=94?= =?UTF-8?q?=EB=A7=81=20=EA=B9=9C=EB=B9=A1=EC=9E=84=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 이미지 로딩 상태(imageLoaded) 추가 - Image 객체로 팝업 이미지 사전 로드 - 이미지 로드 완료 후에만 팝업 표시 - 버튼과 이미지가 동시에 렌더링되어 UX 개선 --- .../src/pages/MainPage/components/Popup/Popup.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index 23ac09106..ffa719795 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -38,14 +38,21 @@ export const isPopupHidden = (): boolean => { const Popup = () => { const [isOpen, setIsOpen] = useState(false); + const [imageLoaded, setImageLoaded] = useState(false); const { isMobile } = useDevice(); const trackEvent = useMixpanelTrack(); + useEffect(() => { + const img = new Image(); + img.src = AppDownloadImage; + img.onload = () => setImageLoaded(true); + }, []); + useEffect(() => { const isHidden = isPopupHidden(); const abGroup = getABTestGroup(); - if (isMobile && !isHidden) { + if (isMobile && !isHidden && imageLoaded) { if (abGroup === 'show_popup') { setIsOpen(true); trackEvent(USER_EVENT.MAIN_POPUP_VIEWED, { @@ -59,7 +66,7 @@ const Popup = () => { }); } } - }, [isMobile, trackEvent]); + }, [isMobile, trackEvent, imageLoaded]); useEffect(() => { if (isOpen) { From e2347dfb6060acb52eb9f296efc3cfba59dfb982 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Tue, 6 Jan 2026 21:33:33 +0900 Subject: [PATCH 12/12] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EC=8B=A4=ED=8C=A8=20=EC=8B=9C=20=ED=8C=9D?= =?UTF-8?q?=EC=97=85=20=ED=91=9C=EC=8B=9C=20=EC=95=88=20=EB=90=98=EB=8A=94?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/pages/MainPage/components/Popup/Popup.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/frontend/src/pages/MainPage/components/Popup/Popup.tsx b/frontend/src/pages/MainPage/components/Popup/Popup.tsx index ffa719795..7f92d785e 100644 --- a/frontend/src/pages/MainPage/components/Popup/Popup.tsx +++ b/frontend/src/pages/MainPage/components/Popup/Popup.tsx @@ -46,6 +46,7 @@ const Popup = () => { const img = new Image(); img.src = AppDownloadImage; img.onload = () => setImageLoaded(true); + img.onerror = () => setImageLoaded(true); }, []); useEffect(() => {