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
330 changes: 330 additions & 0 deletions frontend/src/assets/images/popup/app-download.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 6 additions & 0 deletions frontend/src/constants/eventName.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ export const USER_EVENT = {
CATEGORY_BUTTON_CLICKED: 'CategoryButton Clicked',
SEARCH_BOX_CLICKED: 'SearchBox Clicked',

// 메인 페이지 팝업
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',

// 배너 클릭
BANNER_CLICKED: 'Banner Clicked',
APP_DOWNLOAD_BANNER_CLICKED: 'App Download Banner Clicked',
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 @@ -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';
Expand All @@ -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,
Expand All @@ -53,6 +52,7 @@ const MainPage = () => {

return (
<>
<Popup />
<Header />
<Banner />
<Styled.PageContainer>
Expand Down
27 changes: 2 additions & 25 deletions frontend/src/pages/MainPage/components/Banner/Banner.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/pages/MainPage/components/Popup/Popup.styles.ts
Original file line number Diff line number Diff line change
@@ -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: 16px;
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;
`;
Comment on lines +34 to +35
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

ContainerButtonGroup의 border-radius 불일치를 확인해 주세요.

Containerborder-radius: 10px를 사용하고, ButtonGroup은 하단 모서리에 16px를 사용합니다. Containeroverflow: hidden이 적용되어 있어 ButtonGroup의 16px radius가 10px로 잘릴 수 있습니다.

🔎 border-radius 통일 제안
 export const Container = styled.div`
   display: flex;
   flex-direction: column;
   width: 100%;
   overflow: hidden;
   background-color: #ffffff;
-  border-radius: 10px;
+  border-radius: 16px;
 `;

Also applies to: 60-61

🤖 Prompt for AI Agents
In @frontend/src/pages/MainPage/components/Popup/Popup.styles.ts around lines 34
- 35, Container uses border-radius: 10px while ButtonGroup applies 16px on
bottom corners and Container has overflow: hidden, so the ButtonGroup's 16px
corners will be clipped; make the radii consistent by either changing
ButtonGroup's bottom corner radius to 10px to match Container (symbols:
Container and ButtonGroup) or removing/moving overflow: hidden to an inner
wrapper so ButtonGroup can keep 16px without being clipped; apply the same
change to the other occurrence noted (lines referencing the second
Container/ButtonGroup pair).


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]};
}
`;
156 changes: 156 additions & 0 deletions frontend/src/pages/MainPage/components/Popup/Popup.test.tsx
Original file line number Diff line number Diff line change
@@ -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);
});
});
});
Loading