diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts
index 45d2c5267..3e9343431 100644
--- a/frontend/.storybook/main.ts
+++ b/frontend/.storybook/main.ts
@@ -10,9 +10,10 @@ const config: StorybookConfig = {
viteFinal: async (config) => {
const { mergeConfig } = await import('vite');
const { default: tsconfigPaths } = await import('vite-tsconfig-paths');
+ const { default: svgr } = await import('vite-plugin-svgr');
return mergeConfig(config, {
- plugins: [tsconfigPaths()],
+ plugins: [tsconfigPaths(), svgr()],
});
},
};
diff --git a/frontend/docs/claude/features.md b/frontend/docs/claude/features.md
index 0253d11a0..2ee3c77b0 100644
--- a/frontend/docs/claude/features.md
+++ b/frontend/docs/claude/features.md
@@ -14,16 +14,15 @@ const { variant } = useExperiment(mainBannerExperiment);
// variant는 'A' 또는 'B'
```
-## WebView 필터탭 라우팅
+## 웹/웹뷰 통합 라우팅
-`src/routes/webviewFilterConfig.ts`의 `WEBVIEW_FILTER_CONFIG`이 필터탭 UI와 라우트 등록의 단일 진실 공급원.
+웹과 인앱 웹뷰는 **동일한 웹 라우트**를 사용한다. 웹뷰 여부는 경로가 아니라 `isInAppWebView()`(UA의 `MoadongApp`)로 판단하며 헤더(로고+검색)·바텀네비·필터를 공유한다.
-**새 필터탭 추가 시 수정할 파일 2곳:**
-
-1. `src/routes/webviewFilterConfig.ts` — `{ label, path }` 항목 추가
-2. `src/routes/webviewRoutes.tsx` — `PAGE_MAP`에 해당 path의 컴포넌트 연결
-
-`WEBVIEW_FILTER_CONFIG`을 수정하면 `Filter.tsx`의 탭 UI와 `webviewRoutes.tsx`의 라우트가 자동으로 반영됨.
+- **레이아웃**: `src/layouts/AppLayout.tsx`(중첩 라우트 레이아웃)가 헤더+바텀네비를 묶어 핵심 네비 페이지(`/`, `/promotions`, `/subscriptions`, `/menu`, `/introduce`, `/club-union`)에 적용.
+- **필터탭(동아리/홍보)**: `src/components/common/Filter/Filter.tsx`의 `WEB_FILTER_OPTIONS`. 모바일·웹뷰에서 노출되며, fixed 헤더(56px)를 비우기 위해 `margin-top: 56px`.
+- **바텀네비**: `src/components/common/BottomNavigation/` (홈/구독/메뉴). 상세·폼·관리자 등 AppLayout 밖 페이지에는 미노출.
+- **웹뷰 전용 동작**: `isInAppWebView()`로 분기 (예: 메인 카드 구독 버튼, `WebviewGlobalStyles`). 상세/홍보상세는 자체 TopBar가 있어 `Header`를 `hideOn={['webview']}`로 숨긴다.
+- **구버전 앱 호환**: `src/routes/webviewRoutes.tsx`는 `/webview/* → 웹 경로` 리다이렉트만 담당(`/webview/main`→`/`, `/webview/club/:id`→`/clubDetail/:id` 등). 구버전 앱 진입 URL 보호용이라 제거 금지.
## OG 태그 (소셜 미디어 공유 미리보기)
diff --git a/frontend/docs/features/components/bottom-navigation.md b/frontend/docs/features/components/bottom-navigation.md
new file mode 100644
index 000000000..81f51f4b5
--- /dev/null
+++ b/frontend/docs/features/components/bottom-navigation.md
@@ -0,0 +1,42 @@
+# 웹 바텀 네비게이션 (BottomNavigation)
+
+앱 네이티브 바텀탭(React Navigation)을 웹으로 이식한 하단 고정 네비게이션. 웹/웹뷰 통합 마이그레이션의 일부로, RN `app/(tabs)/_layout.tsx` 스펙을 그대로 재현했다.
+
+## 탭 구성
+
+| 탭 | 경로 | 아이콘 |
+| ---- | ---------------- | --------------------------------------------- |
+| 홈 | `/` | home.svg (mask 틴팅) |
+| 구독 | `/subscriptions` | `subscribe_selected`/`subscribe_unselected.png` (2-state) |
+| 메뉴 | `/menu` | menu.svg (mask 틴팅) |
+
+active 판정: `/`는 정확히 일치, 나머지는 `startsWith`.
+
+## 스타일 (RN 스펙 1:1)
+
+- 컨테이너: 배경 `#FFFFFF`, 상단 border `1px #F0F0F0`, `position: fixed; bottom: 0`
+- 패딩: top `6px`, bottom `calc(6px + env(safe-area-inset-bottom))`
+- 아이콘 28×28, active `#FF5414`(primary[900]) / inactive `#C5C5C5`(gray[500])
+- 라벨 10px / weight 500 / 아이콘↔라벨 간격 4px
+- z-index: `Z_INDEX.bottomNav`
+
+## 아이콘 틴팅
+
+단색 아이콘(홈/메뉴)은 `svg?react`(vite-plugin-svgr)로 컴포넌트 import하고, RN 원본이 `currentColor`를 쓰므로 부모 `Tab`의 `color`(active `#FF5414` / inactive `#C5C5C5`)를 상속받아 틴팅된다. 라벨도 `color: inherit`로 같은 색을 따른다. RN에서 가져온 SVG의 배경 ``는 제거했다(아이콘 패스는 원본 유지). 구독 아이콘은 PNG 2-state라 선택/비선택 이미지를 교체한다.
+
+## 트래킹
+
+탭 클릭 시 `USER_EVENT.BOTTOM_TAB_CLICKED`(`'BottomTab Clicked'`, 앱과 동일 문자열)를 전송한다 — payload `{ tab, path }`.
+
+## 관련 코드
+
+- `src/components/common/BottomNavigation/BottomNavigation.tsx` — 컴포넌트
+- `src/components/common/BottomNavigation/BottomNavigation.styles.ts` — 스타일
+- `src/components/common/BottomNavigation/BottomNavigation.stories.tsx` — Storybook 미리보기
+- `src/assets/images/icons/bottomNav/` — 아이콘 에셋 (RN에서 이식)
+- `src/styles/zIndex.ts` — `bottomNav` z-index 토큰
+- `src/constants/eventName.ts` — `BOTTOM_TAB_CLICKED`
+
+## 마운트
+
+`src/layouts/AppLayout.tsx`(중첩 라우트 레이아웃)에서 렌더되며, `/`·`/introduce`·`/club-union`·`/promotions`·`/subscriptions`·`/menu` 6개 라우트에 노출된다. 상세·지원폼·관리자·게임·웹뷰 등은 그룹 밖이라 미노출. AppLayout이 `Outlet` 래퍼에 하단 여백(`calc(56px + env(safe-area-inset-bottom))`)을 주어 fixed 바에 콘텐츠가 가리지 않게 한다.
diff --git a/frontend/docs/features/menu/menu-page.md b/frontend/docs/features/menu/menu-page.md
new file mode 100644
index 000000000..c90a9ef7d
--- /dev/null
+++ b/frontend/docs/features/menu/menu-page.md
@@ -0,0 +1,31 @@
+# 메뉴(더보기) 페이지 (MenuPage)
+
+바텀 네비게이션 "메뉴" 탭의 페이지(`/menu`). 앱 네이티브 더보기 화면(RN `app/(tabs)/more.tsx`)을 웹으로 이식했다. 통합 후 헤더가 로고+검색만 남으므로, 기존 헤더 메뉴 성격의 항목을 이 페이지로 모은다.
+
+## 항목 (RN 더보기 그대로)
+
+| 항목 | 이동 |
+| --- | --- |
+| 서비스 소개 | `/introduce` |
+| 총 동아리 연합회 | `/club-union` |
+| 개인정보 처리방침 | 외부 노션 링크 (새 탭) |
+
+RN의 "앱 버전" 항목은 웹에 해당 없어 제외. introduce/club-union은 기존 `useHeaderNavigation` 핸들러를 재사용해 네비게이션·Mixpanel 트래킹을 유지한다. 개인정보 처리방침은 Footer와 동일한 외부 링크를 사용한다.
+
+## 스타일 (RN 레이아웃 재현)
+
+- "더보기" 타이틀 헤더 + 항목 리스트(행마다 border-bottom)
+- 각 행: 원형 아이콘 컨테이너(`#FFECE5` 배경 + `#FF5414` 아이콘) + 제목 + chevron(`#C5C5C5`)
+- 앞쪽 아이콘은 RN의 Ionicons가 웹에 없어 웹용 단순 SVG로 재현(`src/assets/images/icons/menu/`). 디자인 시안 확보 시 교체.
+
+## 관련 코드
+
+- `src/pages/MenuPage/MenuPage.tsx` — 페이지
+- `src/pages/MenuPage/MenuPage.styles.ts`
+- `src/assets/images/icons/menu/` — info/people/document/chevron SVG (`?react`, currentColor 틴팅)
+- `src/hooks/Header/useHeaderNavigation.ts` — 소개/연합회 네비 핸들러 (재사용)
+- `src/routes/AppRoutes.tsx` — `/menu` 라우트
+
+## 참고
+
+`/menu`는 `AppLayout`의 하위 라우트로 공용 헤더/바텀네비를 사용한다.
diff --git a/frontend/docs/features/subscriptions/subscriptions-page.md b/frontend/docs/features/subscriptions/subscriptions-page.md
new file mode 100644
index 000000000..60ffbcdde
--- /dev/null
+++ b/frontend/docs/features/subscriptions/subscriptions-page.md
@@ -0,0 +1,24 @@
+# 구독한 동아리 페이지 (SubscriptionsPage)
+
+바텀 네비게이션 "구독" 탭의 페이지(`/subscriptions`). 앱 네이티브 구독 화면(RN `ui/subscribe/`)을 웹으로 이식했다.
+
+## 동작
+
+- **웹뷰(`isInAppWebView()`)**: `useWebviewSubscribe`로 네이티브 브릿지에서 구독 동아리 ID(`subscribedClubIds`)를 받아오고, `useGetCardList`(메인과 동일 쿼리, 캐시 공유) 결과를 해당 ID로 필터링해 `ClubCard` + `SubscribeButton`(현장 구독 토글)로 렌더. 로딩/에러/빈 상태("홈으로 가기") 처리.
+- **순수 웹(앱 아님)**: 구독은 네이티브 브릿지 전용이라 웹엔 데이터가 없다. "구독 기능은 모아동 앱에서 이용 가능" 안내 + 앱 다운로드 CTA(`getAppStoreLink`)를 렌더.
+
+구독 토글·상태 동기화는 기존 브릿지(`SUBSCRIBE_TOGGLE` / `SUBSCRIBE_STATE`)를 그대로 사용한다.
+
+## 관련 코드
+
+- `src/pages/SubscriptionsPage/SubscriptionsPage.tsx` — 페이지 (웹뷰/웹 분기)
+- `src/pages/SubscriptionsPage/SubscriptionsPage.styles.ts`
+- `src/hooks/useWebviewSubscribe.ts` — 구독 상태 브릿지 훅 (재사용)
+- `src/hooks/Queries/useClub.ts` (`useGetCardList`) — 동아리 목록 (재사용)
+- `src/pages/MainPage/components/ClubCard/ClubCard.tsx`, `src/pages/MainPage/components/SubscribeButton/SubscribeButton.tsx` — 카드/구독 버튼 (재사용)
+- `src/utils/appStoreLink.ts` (`getAppStoreLink`) — 웹 CTA 링크
+- `src/routes/AppRoutes.tsx` — `/subscriptions` 라우트
+
+## 참고
+
+`/subscriptions`는 `AppLayout`의 하위 라우트로 공용 헤더/바텀네비를 사용한다.
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 99c9378fd..c26378596 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -6,6 +6,8 @@ import { ScrollToTop } from '@/hooks/Scroll/ScrollToTop';
import AppRoutes from '@/routes/AppRoutes';
import GlobalStyles from '@/styles/Global.styles';
import { theme } from '@/styles/theme';
+import WebviewGlobalStyles from '@/styles/WebviewGlobal.styles';
+import isInAppWebView from '@/utils/isInAppWebView';
import { GlobalBoundary } from './components/common/ErrorBoundary';
import 'swiper/css';
@@ -25,6 +27,7 @@ const App = () => {
return (
<>
+ {isInAppWebView() && }
diff --git a/frontend/src/assets/images/icons/bottomNav/home.svg b/frontend/src/assets/images/icons/bottomNav/home.svg
new file mode 100644
index 000000000..f6f3e5505
--- /dev/null
+++ b/frontend/src/assets/images/icons/bottomNav/home.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/images/icons/bottomNav/menu.svg b/frontend/src/assets/images/icons/bottomNav/menu.svg
new file mode 100644
index 000000000..d9a91a1f0
--- /dev/null
+++ b/frontend/src/assets/images/icons/bottomNav/menu.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/images/icons/bottomNav/subscribe_selected.png b/frontend/src/assets/images/icons/bottomNav/subscribe_selected.png
new file mode 100644
index 000000000..1d09835d4
Binary files /dev/null and b/frontend/src/assets/images/icons/bottomNav/subscribe_selected.png differ
diff --git a/frontend/src/assets/images/icons/bottomNav/subscribe_unselected.png b/frontend/src/assets/images/icons/bottomNav/subscribe_unselected.png
new file mode 100644
index 000000000..3b16fab83
Binary files /dev/null and b/frontend/src/assets/images/icons/bottomNav/subscribe_unselected.png differ
diff --git a/frontend/src/assets/images/icons/menu/chevron.svg b/frontend/src/assets/images/icons/menu/chevron.svg
new file mode 100644
index 000000000..7a4aa4ae6
--- /dev/null
+++ b/frontend/src/assets/images/icons/menu/chevron.svg
@@ -0,0 +1,3 @@
+
diff --git a/frontend/src/assets/images/icons/menu/document.svg b/frontend/src/assets/images/icons/menu/document.svg
new file mode 100644
index 000000000..23501621d
--- /dev/null
+++ b/frontend/src/assets/images/icons/menu/document.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/images/icons/menu/info.svg b/frontend/src/assets/images/icons/menu/info.svg
new file mode 100644
index 000000000..65a076a12
--- /dev/null
+++ b/frontend/src/assets/images/icons/menu/info.svg
@@ -0,0 +1,5 @@
+
diff --git a/frontend/src/assets/images/icons/menu/people.svg b/frontend/src/assets/images/icons/menu/people.svg
new file mode 100644
index 000000000..3a65b16c8
--- /dev/null
+++ b/frontend/src/assets/images/icons/menu/people.svg
@@ -0,0 +1,6 @@
+
diff --git a/frontend/src/components/common/BottomNavigation/BottomNavigation.stories.tsx b/frontend/src/components/common/BottomNavigation/BottomNavigation.stories.tsx
new file mode 100644
index 000000000..0f5706915
--- /dev/null
+++ b/frontend/src/components/common/BottomNavigation/BottomNavigation.stories.tsx
@@ -0,0 +1,51 @@
+import { MemoryRouter } from 'react-router-dom';
+import type { Meta, StoryObj } from '@storybook/react';
+import BottomNavigation from './BottomNavigation';
+
+const meta = {
+ title: 'Components/Common/BottomNavigation',
+ component: BottomNavigation,
+ parameters: {
+ layout: 'fullscreen',
+ docs: {
+ description: {
+ component:
+ '앱 네이티브 바텀탭을 웹으로 옮긴 하단 네비게이션입니다. (홈 / 구독 / 메뉴)',
+ },
+ },
+ },
+ tags: ['autodocs'],
+} satisfies Meta;
+
+export default meta;
+type Story = StoryObj;
+
+export const Home: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export const Subscriptions: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
+
+export const Menu: Story = {
+ decorators: [
+ (Story) => (
+
+
+
+ ),
+ ],
+};
diff --git a/frontend/src/components/common/BottomNavigation/BottomNavigation.styles.ts b/frontend/src/components/common/BottomNavigation/BottomNavigation.styles.ts
new file mode 100644
index 000000000..a9ac1a68e
--- /dev/null
+++ b/frontend/src/components/common/BottomNavigation/BottomNavigation.styles.ts
@@ -0,0 +1,55 @@
+import styled from 'styled-components';
+import { media } from '@/styles/mediaQuery';
+import { theme } from '@/styles/theme';
+import { Z_INDEX } from '@/styles/zIndex';
+
+export const Nav = styled.nav`
+ display: none;
+
+ ${media.tablet} {
+ display: block;
+ position: fixed;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: #ffffff;
+ border-top: 1px solid #f0f0f0;
+ padding-bottom: env(safe-area-inset-bottom);
+ z-index: ${Z_INDEX.bottomNav};
+ }
+`;
+
+export const Inner = styled.div`
+ display: flex;
+ max-width: 500px;
+ margin: 0 auto;
+ padding: 6px 0;
+`;
+
+export const Tab = styled.button<{ $active: boolean }>`
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 4px;
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ color: ${({ $active }) =>
+ $active ? theme.colors.primary[900] : theme.colors.gray[500]};
+`;
+
+export const ImageIcon = styled.img`
+ width: 28px;
+ height: 28px;
+ object-fit: contain;
+`;
+
+export const Label = styled.span`
+ font-size: 10px;
+ font-weight: 500;
+ line-height: 1;
+ color: inherit;
+`;
diff --git a/frontend/src/components/common/BottomNavigation/BottomNavigation.tsx b/frontend/src/components/common/BottomNavigation/BottomNavigation.tsx
new file mode 100644
index 000000000..8b24e6544
--- /dev/null
+++ b/frontend/src/components/common/BottomNavigation/BottomNavigation.tsx
@@ -0,0 +1,111 @@
+import { useLocation, useNavigate } from 'react-router-dom';
+import HomeIcon from '@/assets/images/icons/bottomNav/home.svg?react';
+import MenuIcon from '@/assets/images/icons/bottomNav/menu.svg?react';
+import subscribeSelected from '@/assets/images/icons/bottomNav/subscribe_selected.png';
+import subscribeUnselected from '@/assets/images/icons/bottomNav/subscribe_unselected.png';
+import { USER_EVENT } from '@/constants/eventName';
+import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
+import * as Styled from './BottomNavigation.styles';
+
+type SvgComponent = React.ComponentType>;
+
+type TabIcon =
+ | { type: 'vector'; Component: SvgComponent }
+ | { type: 'image'; active: string; inactive: string };
+
+interface BottomNavTab {
+ key: string;
+ label: string;
+ path: string;
+ icon: TabIcon;
+}
+
+const TABS: BottomNavTab[] = [
+ {
+ key: 'home',
+ label: '홈',
+ path: '/',
+ icon: { type: 'vector', Component: HomeIcon },
+ },
+ {
+ key: 'subscriptions',
+ label: '구독',
+ path: '/subscriptions',
+ icon: {
+ type: 'image',
+ active: subscribeSelected,
+ inactive: subscribeUnselected,
+ },
+ },
+ {
+ key: 'more',
+ label: '메뉴',
+ path: '/menu',
+ icon: { type: 'vector', Component: MenuIcon },
+ },
+];
+
+const isTabActive = (pathname: string, path: string) => {
+ // 홈 탭: 메인 + 상단 Filter로 묶이는 홍보까지 활성
+ if (path === '/') {
+ return pathname === '/' || pathname === '/promotions';
+ }
+ // 메뉴 탭: 메뉴 페이지에서 진입하는 소개/연합회 하위 페이지까지 활성
+ if (path === '/menu') {
+ return (
+ pathname.startsWith('/menu') ||
+ pathname === '/introduce' ||
+ pathname === '/club-union'
+ );
+ }
+ return pathname.startsWith(path);
+};
+
+const renderIcon = (icon: TabIcon, active: boolean) => {
+ if (icon.type === 'vector') {
+ const Icon = icon.Component;
+ return ;
+ }
+ return (
+
+ );
+};
+
+const BottomNavigation = () => {
+ const { pathname } = useLocation();
+ const navigate = useNavigate();
+ const trackEvent = useMixpanelTrack();
+
+ const handleTabClick = (tab: BottomNavTab) => {
+ trackEvent(USER_EVENT.BOTTOM_TAB_CLICKED, { tab: tab.key, path: tab.path });
+ navigate(tab.path, { replace: true });
+ };
+
+ return (
+
+
+ {TABS.map((tab) => {
+ const active = isTabActive(pathname, tab.path);
+ return (
+ handleTabClick(tab)}
+ >
+ {renderIcon(tab.icon, active)}
+ {tab.label}
+
+ );
+ })}
+
+
+ );
+};
+
+export default BottomNavigation;
diff --git a/frontend/src/components/common/Filter/Filter.styles.ts b/frontend/src/components/common/Filter/Filter.styles.ts
index 44dbf0fd3..cfe333320 100644
--- a/frontend/src/components/common/Filter/Filter.styles.ts
+++ b/frontend/src/components/common/Filter/Filter.styles.ts
@@ -2,8 +2,8 @@ import styled from 'styled-components';
import Button from '@/components/common/Button/Button';
import { theme } from '@/styles/theme';
-export const FilterListContainer = styled.div<{ $isWebview?: boolean }>`
- margin-top: ${({ $isWebview }) => ($isWebview ? '0' : '56px')};
+export const FilterListContainer = styled.div`
+ margin-top: 56px;
display: flex;
flex-direction: row;
padding: 10px 20px;
diff --git a/frontend/src/components/common/Filter/Filter.tsx b/frontend/src/components/common/Filter/Filter.tsx
index a77a67547..3908cb772 100644
--- a/frontend/src/components/common/Filter/Filter.tsx
+++ b/frontend/src/components/common/Filter/Filter.tsx
@@ -2,7 +2,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { USER_EVENT } from '@/constants/eventName';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import useDevice from '@/hooks/useDevice';
-import { WEBVIEW_FILTER_CONFIG } from '@/routes/webviewFilterConfig';
+import isInAppWebView from '@/utils/isInAppWebView';
import * as Styled from './Filter.styles';
const WEB_FILTER_OPTIONS = [
@@ -21,8 +21,7 @@ const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => {
const { pathname } = useLocation();
const trackEvent = useMixpanelTrack();
- const isWebview = pathname.startsWith('/webview');
- const filterOptions = isWebview ? WEBVIEW_FILTER_CONFIG : WEB_FILTER_OPTIONS;
+ const isWebview = isInAppWebView();
const shouldShow = alwaysVisible || isMobile || isWebview;
const handleFilterOptionClick = (path: string) => {
@@ -33,8 +32,8 @@ const Filter = ({ alwaysVisible = false, hasNotification }: FilterProps) => {
return (
<>
{shouldShow && (
-
- {filterOptions.map((filter) => (
+
+ {WEB_FILTER_OPTIONS.map((filter) => (
`
export const Container = styled.div`
display: flex;
align-items: center;
- justify-content: space-between;
width: 100%;
max-width: 1180px;
- gap: 50px;
-
- ${media.tablet} {
- gap: 35px;
- }
- ${media.mobile} {
- gap: 30px;
- }
- ${media.mini_mobile} {
- gap: 15px;
- }
+ gap: 16px;
`;
export const LeftSection = styled.div`
display: flex;
align-items: center;
+ flex-shrink: 0;
gap: 45px;
`;
-export const LogoButton = styled.button`
- background: none;
- border: none;
- cursor: pointer;
- padding: 0;
-
- .desktop-logo {
- display: block;
- width: 152px;
- height: auto;
- }
-
- .mobile-logo {
- display: none;
- }
-
- @media (max-width: 870px) {
- .desktop-logo {
- display: none;
- }
- .mobile-logo {
- display: block;
- width: 32px;
- height: auto;
- }
- }
-`;
-
-export const Nav = styled.nav<{ isOpen: boolean }>`
+export const Nav = styled.nav`
display: flex;
align-items: center;
gap: 45px;
${media.tablet} {
- position: fixed;
- top: 56px;
- left: 0;
- right: 0;
- flex-direction: column;
- align-items: stretch;
- gap: 0;
- background: #fff;
- margin-bottom: 16px;
- border-radius: 0 0 20px 20px;
- box-shadow: 0px 20px 30px rgba(0, 0, 0, 0.16);
- transform: translateY(${({ isOpen }) => (isOpen ? '0' : '-200%')});
- transition: opacity 0.3s ease-in-out;
- z-index: 1;
+ display: none;
}
`;
-export const NavLink = styled.button<{ isActive?: boolean }>`
+export const NavLink = styled.button<{ $isActive?: boolean }>`
border: none;
font-weight: 500;
font-size: 14px;
cursor: pointer;
white-space: nowrap;
- color: ${({ isActive }) => (isActive ? '#FF5414' : '#3A3A3A')};
+ color: ${({ $isActive }) => ($isActive ? '#FF5414' : '#3A3A3A')};
background: transparent;
- transition: all 0.2s ease-in-out;
+ transition: color 0.2s ease-in-out;
&:hover {
opacity: 0.7;
}
+`;
- ${media.tablet} {
- display: inline-flex;
- padding: 12px 24px;
- background: ${({ isActive }) =>
- isActive ? 'rgba(255, 84, 20, 0.08)' : 'none'};
+export const SearchArea = styled.div`
+ width: 345px;
+ max-width: 100%;
+ margin-left: auto;
- &:last-child {
- margin-bottom: 16px;
- }
+ & > div {
+ max-width: none;
}
-`;
-export const MenuBar = styled.span`
- display: block;
- width: 100%;
- height: 2px;
- background-color: #4b4b4b;
- border-radius: 2px;
- transition: all 0.3s ease-in-out;
- transform-origin: center;
+ ${media.tablet} {
+ flex: 1;
+ width: auto;
+ margin-left: 0;
+ }
`;
-export const MenuButton = styled.button<{ isOpen: boolean }>`
- display: none;
+export const LogoButton = styled.button`
background: none;
border: none;
cursor: pointer;
padding: 0;
- width: 24px;
- height: 18px;
- position: relative;
- z-index: 3;
-
- ${media.tablet} {
- display: flex;
- flex-direction: column;
- justify-content: space-between;
- order: 2;
- }
-
- ${media.mobile} {
- width: 20px;
- }
- ${MenuBar}:nth-child(1) {
- transform: ${({ isOpen }) =>
- isOpen ? 'translateY(8.25px) rotate(45deg)' : 'none'};
+ .desktop-logo {
+ display: block;
+ width: 152px;
+ height: auto;
}
- ${MenuBar}:nth-child(2) {
- opacity: ${({ isOpen }) => (isOpen ? 0 : 1)};
+ .mobile-logo {
+ display: none;
}
- ${MenuBar}:nth-child(3) {
- transform: ${({ isOpen }) =>
- isOpen ? 'translateY(-8.25px) rotate(-45deg)' : 'none'};
+ @media (max-width: 870px) {
+ .desktop-logo {
+ display: none;
+ }
+ .mobile-logo {
+ display: block;
+ width: 32px;
+ height: auto;
+ }
}
`;
diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx
index e42cbb152..3f6413fc9 100644
--- a/frontend/src/components/common/Header/Header.tsx
+++ b/frontend/src/components/common/Header/Header.tsx
@@ -1,4 +1,3 @@
-import { useState } from 'react';
import { useLocation } from 'react-router-dom';
import MobileMainIcon from '@/assets/images/logos/moadong_mobile_logo.svg';
import DesktopMainIcon from '@/assets/images/moadong_name_logo.svg';
@@ -17,7 +16,6 @@ interface HeaderProps {
const Header = ({ showOn, hideOn }: HeaderProps) => {
const location = useLocation();
- const [isMenuOpen, setIsMenuOpen] = useState(false);
const isScrolled = useScrollDetection();
const isVisible = useHeaderVisibility(showOn, hideOn);
const {
@@ -25,17 +23,11 @@ const Header = ({ showOn, hideOn }: HeaderProps) => {
handleIntroduceClick,
handleClubUnionClick,
handlePromotionClick,
- handleMenuOpen,
- handleMenuClose,
} = useHeaderNavigation();
const isAdminPage = location.pathname.startsWith('/admin');
const isAdminLoginPage = location.pathname.startsWith('/admin/login');
- if (!isVisible) {
- return null;
- }
-
const navLinks = [
{ label: '모아동 소개', handler: handleIntroduceClick, path: '/introduce' },
{
@@ -50,17 +42,9 @@ const Header = ({ showOn, hideOn }: HeaderProps) => {
},
];
- const closeMenu = () => {
- setIsMenuOpen(false);
- };
- const toggleMenu = () => {
- if (isMenuOpen) {
- handleMenuClose();
- } else {
- handleMenuOpen();
- }
- setIsMenuOpen((prev) => !prev);
- };
+ if (!isVisible) {
+ return null;
+ }
return (
@@ -78,36 +62,26 @@ const Header = ({ showOn, hideOn }: HeaderProps) => {
alt='모아동 로고'
/>
-
-
- {navLinks.map((link) => (
- {
- link.handler();
- closeMenu();
- }}
- >
- {link.label}
-
- ))}
-
+ {!isAdminPage && (
+
+ {navLinks.map((link) => (
+
+ {link.label}
+
+ ))}
+
+ )}
{!isAdminPage && (
-
-
-
-
-
+
+
+
)}
-
- {!isAdminPage && }
{isAdminPage && !isAdminLoginPage && }
diff --git a/frontend/src/constants/eventName.ts b/frontend/src/constants/eventName.ts
index c93e86326..1334902bf 100644
--- a/frontend/src/constants/eventName.ts
+++ b/frontend/src/constants/eventName.ts
@@ -61,6 +61,9 @@ export const USER_EVENT = {
// 필터칩
FILTER_OPTION_CLICKED: 'Filter Option Clicked',
+ // 하단 네비게이션
+ BOTTOM_TAB_CLICKED: 'BottomTab Clicked',
+
// 동소한 (동아리 소개 한마당)
FESTIVAL_TAB_CLICKED: 'Festival Tab Clicked',
FESTIVAL_BOOTH_MAP_SLIDE_CHANGED: 'Festival BoothMap Slide Changed',
@@ -132,6 +135,8 @@ export const PAGE_VIEW = {
APPLICATION_FORM_PAGE: 'ApplicationFormPage',
CLUB_DETAIL_PAGE: 'ClubDetailPage',
MAIN_PAGE: 'MainPage',
+ SUBSCRIPTIONS_PAGE: 'SubscriptionsPage',
+ MENU_PAGE: 'MenuPage',
INTRODUCE_PAGE: 'IntroducePage',
CLUB_UNION_PAGE: 'ClubUnionPage',
FESTIVAL_INTRODUCTION_PAGE: '동소한 페이지',
@@ -154,4 +159,5 @@ export const PAGE_NAME = {
MAIN: 'main',
WEBVIEW_MAIN: 'webview-main',
INTRODUCE: 'introduce',
+ SUBSCRIPTIONS: 'subscriptions',
} as const;
diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts
index 733546141..3097f4e3a 100644
--- a/frontend/src/hooks/Header/useHeaderNavigation.ts
+++ b/frontend/src/hooks/Header/useHeaderNavigation.ts
@@ -37,22 +37,12 @@ const useHeaderNavigation = () => {
trackEvent(USER_EVENT.ADMIN_BUTTON_CLICKED);
}, [navigate, trackEvent]);
- const handleMenuOpen = useCallback(() => {
- trackEvent(USER_EVENT.MOBILE_MENU_BUTTON_CLICKED);
- }, [trackEvent]);
-
- const handleMenuClose = useCallback(() => {
- trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED);
- }, [trackEvent]);
-
return {
handleHomeClick,
handleIntroduceClick,
handleClubUnionClick,
handlePromotionClick,
handleAdminClick,
- handleMenuOpen,
- handleMenuClose,
};
};
diff --git a/frontend/src/hooks/Queries/usePromotionNotification.ts b/frontend/src/hooks/Queries/usePromotionNotification.ts
index bcc01e82d..d672232fa 100644
--- a/frontend/src/hooks/Queries/usePromotionNotification.ts
+++ b/frontend/src/hooks/Queries/usePromotionNotification.ts
@@ -18,7 +18,7 @@ const usePromotionNotification = () => {
const latestTime = getLatestPromotionTime(data);
const lastChecked = getLastCheckedTime();
- if (pathname === '/promotions' || pathname === '/webview/promotions') {
+ if (pathname === '/promotions') {
setLastCheckedTime(latestTime);
setHasNotification(false);
return;
diff --git a/frontend/src/layouts/AppLayout.styles.ts b/frontend/src/layouts/AppLayout.styles.ts
new file mode 100644
index 000000000..e2e1d1483
--- /dev/null
+++ b/frontend/src/layouts/AppLayout.styles.ts
@@ -0,0 +1,8 @@
+import styled from 'styled-components';
+import { media } from '@/styles/mediaQuery';
+
+export const Content = styled.div`
+ ${media.tablet} {
+ padding-bottom: calc(56px + env(safe-area-inset-bottom));
+ }
+`;
diff --git a/frontend/src/layouts/AppLayout.tsx b/frontend/src/layouts/AppLayout.tsx
new file mode 100644
index 000000000..bbd09253c
--- /dev/null
+++ b/frontend/src/layouts/AppLayout.tsx
@@ -0,0 +1,14 @@
+import { Outlet } from 'react-router-dom';
+import BottomNavigation from '@/components/common/BottomNavigation/BottomNavigation';
+import * as Styled from './AppLayout.styles';
+
+const AppLayout = () => (
+ <>
+
+
+
+
+ >
+);
+
+export default AppLayout;
diff --git a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx
index 3384e5a2b..aa7f13e80 100644
--- a/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx
+++ b/frontend/src/pages/ClubUnionPage/ClubUnionPage.tsx
@@ -67,7 +67,7 @@ const ClubUnionPage = () => {
return (
<>
-
+
총동아리연합회 소개
diff --git a/frontend/src/pages/IntroducePage/IntroducePage.tsx b/frontend/src/pages/IntroducePage/IntroducePage.tsx
index 5b0234c56..4541af41a 100644
--- a/frontend/src/pages/IntroducePage/IntroducePage.tsx
+++ b/frontend/src/pages/IntroducePage/IntroducePage.tsx
@@ -16,7 +16,7 @@ const IntroducePage = () => {
return (
<>
-
+
diff --git a/frontend/src/pages/MainPage/MainPage.tsx b/frontend/src/pages/MainPage/MainPage.tsx
index 698ef4f05..d257be1f6 100644
--- a/frontend/src/pages/MainPage/MainPage.tsx
+++ b/frontend/src/pages/MainPage/MainPage.tsx
@@ -8,18 +8,24 @@ import useScrollTracking from '@/hooks/Mixpanel/useScrollTracking';
import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
import { useGetCardList } from '@/hooks/Queries/useClub';
import usePromotionNotification from '@/hooks/Queries/usePromotionNotification';
+import useWebviewSubscribe from '@/hooks/useWebviewSubscribe';
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 { APP_DOWNLOAD_POPUP } from '@/pages/MainPage/components/Popup/popupConfigs';
+import SubscribeButton from '@/pages/MainPage/components/SubscribeButton/SubscribeButton';
import { useSelectedCategory } from '@/store/useCategoryStore';
import { useSearchIsSearching, useSearchKeyword } from '@/store/useSearchStore';
import { Club } from '@/types/club';
+import isInAppWebView from '@/utils/isInAppWebView';
import * as Styled from './MainPage.styles';
const MainPage = () => {
- useTrackPageView(PAGE_VIEW.MAIN_PAGE);
+ const inWebview = isInAppWebView();
+ useTrackPageView(
+ inWebview ? PAGE_VIEW.WEBVIEW_MAIN_PAGE : PAGE_VIEW.MAIN_PAGE,
+ );
useScrollTracking(PAGE_NAME.MAIN);
const { selectedCategory } = useSelectedCategory();
@@ -39,6 +45,7 @@ const MainPage = () => {
division,
});
const hasNotification = usePromotionNotification();
+ const { subscribedClubIds, toggleSubscribe } = useWebviewSubscribe();
const clubs = data?.clubs || [];
const totalCount = data?.totalCount ?? clubs.length;
@@ -49,16 +56,30 @@ const MainPage = () => {
const clubList = useMemo(() => {
if (!hasData) return null;
return clubs.map((club: Club, i: number) => (
-
+
+ {inWebview && (
+
+ toggleSubscribe(club.id, subscribedClubIds.has(club.id))
+ }
+ />
+ )}
+
));
- }, [clubs, hasData]);
+ }, [clubs, hasData, inWebview, subscribedClubIds, toggleSubscribe]);
return (
<>
-
+ {!inWebview && }
-
+
@@ -100,7 +121,7 @@ const MainPage = () => {
)}
-
+ {!inWebview && }
>
);
};
diff --git a/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx b/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx
index 7e5a57e68..1f182c082 100644
--- a/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx
+++ b/frontend/src/pages/MainPage/components/SearchBox/SearchBox.tsx
@@ -4,10 +4,9 @@ import { USER_EVENT } from '@/constants/eventName';
import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack';
import { useSelectedCategory } from '@/store/useCategoryStore';
import { useSearchInput } from '@/store/useSearchStore';
-import isInAppWebView from '@/utils/isInAppWebView';
import * as Styled from './SearchBox.styles';
-const HOME_ROUTE = isInAppWebView() ? '/webview/main' : '/';
+const HOME_ROUTE = '/';
const SearchBox = () => {
const { setKeyword, inputValue, setInputValue, setIsSearching } =
diff --git a/frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.styles.ts b/frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.styles.ts
new file mode 100644
index 000000000..aae5915a1
--- /dev/null
+++ b/frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.styles.ts
@@ -0,0 +1,25 @@
+import styled from 'styled-components';
+import { theme } from '@/styles/theme';
+
+export const SubscribeButton = styled.button<{ $subscribed: boolean }>`
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 36px;
+ height: 36px;
+ border: none;
+ background: none;
+ cursor: pointer;
+ color: ${({ $subscribed }) =>
+ $subscribed ? theme.colors.primary[900] : theme.colors.gray[500]};
+ padding: 4px;
+ margin-left: 8px;
+ border-radius: 50%;
+ transition: color 0.2s ease;
+
+ &:active {
+ transform: scale(0.88);
+ transition: transform 0.1s ease;
+ }
+`;
diff --git a/frontend/src/pages/WebviewMainPage/components/SubscribeButton/SubscribeButton.tsx b/frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.tsx
similarity index 91%
rename from frontend/src/pages/WebviewMainPage/components/SubscribeButton/SubscribeButton.tsx
rename to frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.tsx
index c25f700b9..b0fb81cb9 100644
--- a/frontend/src/pages/WebviewMainPage/components/SubscribeButton/SubscribeButton.tsx
+++ b/frontend/src/pages/MainPage/components/SubscribeButton/SubscribeButton.tsx
@@ -1,5 +1,5 @@
import SubscribeIcon from '@/assets/images/icons/subscribe_button_icon.svg?react';
-import * as Styled from '../../WebviewMainPage.styles';
+import * as Styled from './SubscribeButton.styles';
interface SubscribeButtonProps {
subscribed: boolean;
diff --git a/frontend/src/pages/MenuPage/MenuPage.styles.ts b/frontend/src/pages/MenuPage/MenuPage.styles.ts
new file mode 100644
index 000000000..bbd6c37db
--- /dev/null
+++ b/frontend/src/pages/MenuPage/MenuPage.styles.ts
@@ -0,0 +1,80 @@
+import styled, { css } from 'styled-components';
+import { theme } from '@/styles/theme';
+
+export const Container = styled.div`
+ background-color: #ffffff;
+`;
+
+export const Title = styled.h1`
+ font-size: 20px;
+ font-weight: 700;
+ color: #111111;
+ padding: 16px;
+ border-bottom: 1px solid #f0f0f0;
+`;
+
+export const MenuList = styled.div`
+ padding-top: 8px;
+`;
+
+const itemStyles = css`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 16px;
+ border: none;
+ border-bottom: 1px solid ${theme.colors.gray[100]};
+ background: none;
+ cursor: pointer;
+ text-decoration: none;
+`;
+
+export const MenuItem = styled.button`
+ ${itemStyles}
+`;
+
+export const MenuLink = styled.a`
+ ${itemStyles}
+`;
+
+export const ItemLeft = styled.span`
+ display: flex;
+ align-items: center;
+ gap: 12px;
+`;
+
+export const IconCircle = styled.span`
+ width: 40px;
+ height: 40px;
+ border-radius: 50%;
+ background-color: ${theme.colors.primary[500]};
+ color: ${theme.colors.primary[900]};
+ display: flex;
+ align-items: center;
+ justify-content: center;
+`;
+
+export const ItemText = styled.span`
+ font-size: 16px;
+ color: #111111;
+`;
+
+export const Chevron = styled.span`
+ display: flex;
+ color: ${theme.colors.gray[500]};
+`;
+
+export const MenuInfoRow = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: 100%;
+ padding: 16px;
+ border-bottom: 1px solid ${theme.colors.gray[100]};
+`;
+
+export const VersionText = styled.span`
+ font-size: 14px;
+ color: ${theme.colors.gray[400]};
+`;
diff --git a/frontend/src/pages/MenuPage/MenuPage.tsx b/frontend/src/pages/MenuPage/MenuPage.tsx
new file mode 100644
index 000000000..eb039c67e
--- /dev/null
+++ b/frontend/src/pages/MenuPage/MenuPage.tsx
@@ -0,0 +1,80 @@
+import ChevronIcon from '@/assets/images/icons/menu/chevron.svg?react';
+import DocumentIcon from '@/assets/images/icons/menu/document.svg?react';
+import InfoIcon from '@/assets/images/icons/menu/info.svg?react';
+import PeopleIcon from '@/assets/images/icons/menu/people.svg?react';
+import MoadongIcon from '@/assets/images/logos/moadong_mobile_logo.svg?react';
+import { PAGE_VIEW } from '@/constants/eventName';
+import useHeaderNavigation from '@/hooks/Header/useHeaderNavigation';
+import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
+import getAppVersion from '@/utils/getAppVersion';
+import * as Styled from './MenuPage.styles';
+
+const PRIVACY_POLICY_URL =
+ 'https://honorable-cough-8f9.notion.site/232aad23209680f2a2cadb146eff81cd?pvs=74';
+
+const MenuPage = () => {
+ useTrackPageView(PAGE_VIEW.MENU_PAGE);
+ const { handleIntroduceClick, handleClubUnionClick } = useHeaderNavigation();
+ const appVersion = getAppVersion();
+
+ return (
+
+ 더보기
+
+
+
+
+
+
+ 서비스 소개
+
+
+
+
+
+
+
+
+
+
+
+ 총 동아리 연합회
+
+
+
+
+
+
+
+
+
+
+
+ 개인정보 처리방침
+
+
+
+
+
+
+ {appVersion && (
+
+
+
+
+
+ 앱 버전
+
+ {appVersion}
+
+ )}
+
+
+ );
+};
+
+export default MenuPage;
diff --git a/frontend/src/pages/PromotionPage/PromotionListPage.styles.ts b/frontend/src/pages/PromotionPage/PromotionListPage.styles.ts
index cf4ace14b..89b69b211 100644
--- a/frontend/src/pages/PromotionPage/PromotionListPage.styles.ts
+++ b/frontend/src/pages/PromotionPage/PromotionListPage.styles.ts
@@ -2,11 +2,11 @@ import styled from 'styled-components';
import { media } from '@/styles/mediaQuery';
import { colors } from '@/styles/theme/colors';
-export const Container = styled.div<{ $isWebview?: boolean }>`
+export const Container = styled.div`
width: 100%;
max-width: 1280px;
margin: 0 auto;
- padding-top: ${({ $isWebview }) => ($isWebview ? '0' : '92px')};
+ padding-top: 92px;
${media.mobile} {
padding-top: 0;
@@ -30,28 +30,6 @@ export const Wrapper = styled.div`
}
`;
-export const SearchBarArea = styled.div`
- position: sticky;
- top: 0;
- z-index: 10;
- background-color: #fff;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px 8px;
-
- & > div:last-child {
- flex: 1;
- max-width: none;
- }
-`;
-
-export const LogoImage = styled.img`
- flex-shrink: 0;
- height: 24px;
- width: auto;
-`;
-
export const EmptyText = styled.p`
text-align: center;
font-size: 16px;
diff --git a/frontend/src/pages/PromotionPage/PromotionListPage.tsx b/frontend/src/pages/PromotionPage/PromotionListPage.tsx
index f95c2a4ab..3e0db78c9 100644
--- a/frontend/src/pages/PromotionPage/PromotionListPage.tsx
+++ b/frontend/src/pages/PromotionPage/PromotionListPage.tsx
@@ -1,11 +1,9 @@
-import MobileMainIcon from '@/assets/images/logos/moadong_mobile_logo.svg';
import Footer from '@/components/common/Footer/Footer';
import Header from '@/components/common/Header/Header';
import { PAGE_VIEW } from '@/constants/eventName';
import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
import { useGetPromotionArticles } from '@/hooks/Queries/usePromotion';
import usePromotionNotification from '@/hooks/Queries/usePromotionNotification';
-import SearchBox from '@/pages/MainPage/components/SearchBox/SearchBox';
import isInAppWebView from '@/utils/isInAppWebView';
import Filter from '../../components/common/Filter/Filter';
import PromottionGrid from './components/list/PromotionGrid/PromotionGrid';
@@ -31,27 +29,14 @@ const PromotionListPage = () => {
);
- if (inAppWebView) {
- return (
-
-
-
-
-
-
- {content}
-
- );
- }
-
return (
<>
-
+
{content}
-
+ {!inAppWebView && }
>
);
};
diff --git a/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.styles.ts b/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.styles.ts
new file mode 100644
index 000000000..688a72f25
--- /dev/null
+++ b/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.styles.ts
@@ -0,0 +1,42 @@
+import styled from 'styled-components';
+import { theme } from '@/styles/theme';
+
+export const Container = styled.div`
+ padding: 16px;
+`;
+
+export const Title = styled.h1`
+ font-size: 20px;
+ font-weight: 700;
+ color: ${theme.colors.gray[900]};
+ padding: 8px 4px 16px;
+`;
+
+export const CardList = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+`;
+
+export const Empty = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ padding: 60px 20px;
+ text-align: center;
+ color: ${theme.colors.gray[600]};
+ font-size: 14px;
+ line-height: 1.5;
+`;
+
+export const CtaButton = styled.button`
+ margin-top: 4px;
+ padding: 10px 20px;
+ border: none;
+ border-radius: 100px;
+ background-color: ${theme.colors.primary[900]};
+ color: #ffffff;
+ font-weight: 600;
+ cursor: pointer;
+`;
diff --git a/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.tsx b/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.tsx
new file mode 100644
index 000000000..05e1e6d21
--- /dev/null
+++ b/frontend/src/pages/SubscriptionsPage/SubscriptionsPage.tsx
@@ -0,0 +1,98 @@
+import { useMemo } from 'react';
+import { useNavigate } from 'react-router-dom';
+import Spinner from '@/components/common/Spinner/Spinner';
+import { PAGE_NAME, PAGE_VIEW } from '@/constants/eventName';
+import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
+import { useGetCardList } from '@/hooks/Queries/useClub';
+import useWebviewSubscribe from '@/hooks/useWebviewSubscribe';
+import ClubCard from '@/pages/MainPage/components/ClubCard/ClubCard';
+import SubscribeButton from '@/pages/MainPage/components/SubscribeButton/SubscribeButton';
+import { Club } from '@/types/club';
+import { getAppStoreLink } from '@/utils/appStoreLink';
+import isInAppWebView from '@/utils/isInAppWebView';
+import * as Styled from './SubscriptionsPage.styles';
+
+const SubscribedClubs = () => {
+ const navigate = useNavigate();
+ const { subscribedClubIds, toggleSubscribe } = useWebviewSubscribe();
+ const { data, isLoading, error, refetch } = useGetCardList({
+ keyword: '',
+ recruitmentStatus: 'all',
+ category: 'all',
+ division: 'all',
+ });
+
+ const subscribedClubs = useMemo(
+ () => (data?.clubs ?? []).filter((club) => subscribedClubIds.has(club.id)),
+ [data, subscribedClubIds],
+ );
+
+ return (
+
+ 구독
+ {isLoading ? (
+
+ ) : error ? (
+
+ 구독한 동아리 목록을 불러오지 못했어요.
+ refetch()}>
+ 다시 시도
+
+
+ ) : subscribedClubs.length === 0 ? (
+
+ 아직 구독한 동아리가 없어요.
+
+ 관심있는 동아리를 구독하고 새 소식을 받아보세요.
+ navigate('/')}>
+ 홈으로 가기
+
+
+ ) : (
+
+ {subscribedClubs.map((club: Club, index: number) => (
+
+
+ toggleSubscribe(club.id, subscribedClubIds.has(club.id))
+ }
+ />
+
+ ))}
+
+ )}
+
+ );
+};
+
+const SubscriptionsPage = () => {
+ useTrackPageView(PAGE_VIEW.SUBSCRIPTIONS_PAGE);
+
+ if (!isInAppWebView()) {
+ return (
+
+ 구독
+
+ 구독 기능은 모아동 앱에서 이용할 수 있어요.
+
+ window.open(getAppStoreLink(), '_blank', 'noopener,noreferrer')
+ }
+ >
+ 앱 다운로드
+
+
+
+ );
+ }
+
+ return ;
+};
+
+export default SubscriptionsPage;
diff --git a/frontend/src/pages/WebviewLayout/WebviewLayout.tsx b/frontend/src/pages/WebviewLayout/WebviewLayout.tsx
deleted file mode 100644
index 39a985321..000000000
--- a/frontend/src/pages/WebviewLayout/WebviewLayout.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import { Outlet } from 'react-router-dom';
-import WebviewGlobalStyles from '@/styles/WebviewGlobal.styles';
-
-const WebviewLayout = () => {
- return (
- <>
-
-
- >
- );
-};
-
-export default WebviewLayout;
diff --git a/frontend/src/pages/WebviewMainPage/WebviewMainPage.styles.ts b/frontend/src/pages/WebviewMainPage/WebviewMainPage.styles.ts
deleted file mode 100644
index 4396d65ce..000000000
--- a/frontend/src/pages/WebviewMainPage/WebviewMainPage.styles.ts
+++ /dev/null
@@ -1,111 +0,0 @@
-import styled from 'styled-components';
-
-export const PageContainer = styled.div`
- width: 100%;
- min-height: 100vh;
-`;
-
-export const SearchBarArea = styled.div`
- position: sticky;
- top: 0;
- z-index: 10;
- background-color: #fff;
- display: flex;
- align-items: center;
- gap: 10px;
- padding: 12px 16px 8px;
-
- & > div:last-child {
- flex: 1;
- max-width: none;
- }
-`;
-
-export const LogoImage = styled.img`
- flex-shrink: 0;
- height: 24px;
- width: auto;
-`;
-
-export const ContentArea = styled.div`
- padding: 0 16px;
-`;
-
-export const SectionBar = styled.div`
- display: flex;
- align-items: flex-end;
- justify-content: space-between;
- margin: 12px 4px 12px;
-`;
-
-export const SectionTitle = styled.span`
- font-size: 14px;
- font-weight: 700;
- color: #787878;
-`;
-
-export const TotalCount = styled.span`
- font-size: 12px;
- font-weight: bold;
- color: #787878;
-`;
-
-export const CardListWrapper = styled.div`
- width: 100%;
- margin-bottom: 60px;
-`;
-
-export const CardList = styled.div`
- display: flex;
- flex-direction: column;
- gap: 6px;
- margin-top: 4px;
-`;
-
-export const EmptyResult = styled.div`
- padding: 80px 20px;
- text-align: center;
- color: #555;
- font-size: 0.95rem;
- line-height: 1.6;
- white-space: pre-line;
-`;
-
-export const SubscribeButton = styled.button<{ $subscribed: boolean }>`
- flex-shrink: 0;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 36px;
- height: 36px;
- border: none;
- background: none;
- cursor: pointer;
- color: ${({ $subscribed }) => ($subscribed ? '#FF5414' : '#C5C5C5')};
- padding: 4px;
- margin-left: 8px;
- border-radius: 50%;
- transition: color 0.2s ease;
-
- &:active {
- transform: scale(0.88);
- transition: transform 0.1s ease;
- }
-`;
-
-export const RetryButton = styled.button`
- margin-top: 24px;
- padding: 10px 24px;
- font-size: 14px;
- font-weight: 600;
- color: white;
- background: ${({ theme }) => theme.colors.primary[900]};
- border: none;
- border-radius: 8px;
- cursor: pointer;
- transition: all 0.2s ease;
-
- &:active {
- transform: scale(0.98);
- }
-`;
diff --git a/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx b/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx
deleted file mode 100644
index b136597e4..000000000
--- a/frontend/src/pages/WebviewMainPage/WebviewMainPage.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { memo, useCallback, useMemo } from 'react';
-import MobileMainIcon from '@/assets/images/logos/moadong_mobile_logo.svg';
-import Filter from '@/components/common/Filter/Filter';
-import Spinner from '@/components/common/Spinner/Spinner';
-import { PAGE_NAME, PAGE_VIEW } from '@/constants/eventName';
-import useTrackPageView from '@/hooks/Mixpanel/useTrackPageView';
-import { useGetCardList } from '@/hooks/Queries/useClub';
-import usePromotionNotification from '@/hooks/Queries/usePromotionNotification';
-import useWebviewSubscribe from '@/hooks/useWebviewSubscribe';
-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 SearchBox from '@/pages/MainPage/components/SearchBox/SearchBox';
-import { useSelectedCategory } from '@/store/useCategoryStore';
-import { useSearchIsSearching, useSearchKeyword } from '@/store/useSearchStore';
-import { Club } from '@/types/club';
-import { requestNavigateWebview } from '@/utils/webviewBridge';
-import SubscribeButton from './components/SubscribeButton/SubscribeButton';
-import * as Styled from './WebviewMainPage.styles';
-
-const MemoClubCard = memo(ClubCard);
-
-const WebviewMainPage = () => {
- useTrackPageView(PAGE_VIEW.WEBVIEW_MAIN_PAGE);
-
- const { selectedCategory } = useSelectedCategory();
- const { keyword } = useSearchKeyword();
- const { isSearching } = useSearchIsSearching();
- const searchCategory = isSearching ? 'all' : selectedCategory;
-
- const { data, error, isLoading, refetch } = useGetCardList({
- keyword,
- recruitmentStatus: 'all',
- category: searchCategory,
- division: 'all',
- });
-
- const { toggleSubscribe, subscribedClubIds } = useWebviewSubscribe();
- const hasNotification = usePromotionNotification();
-
- const handleCardClick = useCallback((club: Club) => {
- requestNavigateWebview(`club/${club.id}`);
- }, []);
-
- const clubs = data?.clubs || [];
- const totalCount = data?.totalCount ?? clubs.length;
- const isEmpty = !isLoading && clubs.length === 0;
-
- const clubList = useMemo(() => {
- if (!clubs.length) return null;
- return clubs.map((club: Club, i: number) => (
-
-
- toggleSubscribe(club.id, subscribedClubIds.has(club.id))
- }
- />
-
- ));
- }, [clubs, subscribedClubIds, toggleSubscribe]);
-
- return (
-
-
-
-
-
-
-
-
-
-
-
- 부경대학교 중앙동아리
-
- {`전체 ${isLoading ? 0 : totalCount}개의 동아리`}
-
-
-
-
- {isLoading ? (
-
- ) : error ? (
-
- 동아리 목록을 불러오는 중 문제가 발생했습니다.
-
- refetch()}>
- 다시 시도
-
-
- ) : isEmpty ? (
-
- 앗, 조건에 맞는 동아리가 없어요.
-
- 다른 키워드나 조건으로 다시 시도해보세요!
-
- ) : (
- {clubList}
- )}
-
-
-
- );
-};
-
-export default WebviewMainPage;
diff --git a/frontend/src/routes/AppRoutes.tsx b/frontend/src/routes/AppRoutes.tsx
index 6e1b3cd45..4834e547e 100644
--- a/frontend/src/routes/AppRoutes.tsx
+++ b/frontend/src/routes/AppRoutes.tsx
@@ -2,6 +2,7 @@ import { lazy } from 'react';
import { Navigate, useRoutes } from 'react-router-dom';
import { ContentErrorBoundary } from '@/components/common/ErrorBoundary';
import { AdminClubProvider } from '@/context/AdminClubContext';
+import AppLayout from '@/layouts/AppLayout';
import LoginTab from '@/pages/AdminPage/auth/LoginTab/LoginTab';
import PrivateRoute from '@/pages/AdminPage/auth/PrivateRoute/PrivateRoute';
import ApplicationFormPage from '@/pages/ApplicationFormPage/ApplicationFormPage';
@@ -14,23 +15,71 @@ import ErrorTestPage from '@/pages/ErrorTestPage/ErrorTestPage';
import GamePage from '@/pages/GamePage/GamePage';
import IntroducePage from '@/pages/IntroducePage/IntroducePage';
import MainPage from '@/pages/MainPage/MainPage';
+import MenuPage from '@/pages/MenuPage/MenuPage';
import PromotionDetailPage from '@/pages/PromotionPage/PromotionDetailPage';
import PromotionListPage from '@/pages/PromotionPage/PromotionListPage';
+import SubscriptionsPage from '@/pages/SubscriptionsPage/SubscriptionsPage';
import webviewRoutes from './webviewRoutes';
const AdminRoutes = lazy(() => import('@/pages/AdminPage/AdminRoutes'));
const AppRoutes = () =>
useRoutes([
- /* 일반 웹 */
+ /* 바텀 네비게이션이 있는 일반 웹 페이지 */
{
- path: '/',
- element: (
-
-
-
- ),
+ element: ,
+ children: [
+ {
+ path: '/',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/introduce',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/club-union',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/promotions',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/subscriptions',
+ element: (
+
+
+
+ ),
+ },
+ {
+ path: '/menu',
+ element: (
+
+
+
+ ),
+ },
+ ],
},
+
/* 기존 웹 & 안드로이드 url (android: v1.1.0) */
{
path: '/club/:clubId',
@@ -73,30 +122,6 @@ const AppRoutes = () =>
),
},
- {
- path: '/introduce',
- element: (
-
-
-
- ),
- },
- {
- path: '/club-union',
- element: (
-
-
-
- ),
- },
- {
- path: '/promotions',
- element: (
-
-
-
- ),
- },
{
path: '/promotions/:promotionId',
element: (
diff --git a/frontend/src/routes/webviewFilterConfig.ts b/frontend/src/routes/webviewFilterConfig.ts
deleted file mode 100644
index 021b1385e..000000000
--- a/frontend/src/routes/webviewFilterConfig.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-export const WEBVIEW_FILTER_CONFIG = [
- { label: '동아리', path: '/webview/main' },
- { label: '홍보', path: '/webview/promotions' },
-] as const;
-
-export type WebviewFilterPath = (typeof WEBVIEW_FILTER_CONFIG)[number]['path'];
diff --git a/frontend/src/routes/webviewRoutes.tsx b/frontend/src/routes/webviewRoutes.tsx
index e5e670b06..66e383016 100644
--- a/frontend/src/routes/webviewRoutes.tsx
+++ b/frontend/src/routes/webviewRoutes.tsx
@@ -1,71 +1,31 @@
-import { ComponentType } from 'react';
-import { RouteObject } from 'react-router-dom';
-import { ContentErrorBoundary } from '@/components/common/ErrorBoundary';
-import ClubDetailPage from '@/pages/ClubDetailPage/ClubDetailPage';
-import ClubMapPage from '@/pages/ClubMapPage/ClubMapPage';
-import PromotionListPage from '@/pages/PromotionPage/PromotionListPage';
-import WebviewLayout from '@/pages/WebviewLayout/WebviewLayout';
-import WebviewMainPage from '@/pages/WebviewMainPage/WebviewMainPage';
-import {
- WEBVIEW_FILTER_CONFIG,
- WebviewFilterPath,
-} from './webviewFilterConfig';
+import { Navigate, RouteObject, useParams } from 'react-router-dom';
-const PAGE_MAP: Record = {
- '/webview/main': WebviewMainPage,
- '/webview/promotions': PromotionListPage,
+/**
+ * 구버전 네이티브 앱 호환용 리다이렉트.
+ *
+ * 앱은 `/webview/*` 를 진입 URL로 로드하고 `requestNavigateWebview('club/:id')`로
+ * 이동하므로, 통합된 웹 경로로 영구 리다이렉트한다. 사용자 폰의 구버전 바이너리가
+ * 깨지지 않도록 유지해야 한다(제거 금지).
+ */
+const ClubDetailRedirect = ({ map = false }: { map?: boolean }) => {
+ const { clubId, clubName } = useParams<{
+ clubId?: string;
+ clubName?: string;
+ }>();
+ const slug = clubName ? `@${clubName}` : clubId;
+ return ;
};
const webviewRoutes: RouteObject[] = [
+ { path: '/webview/main', element: },
{
- path: '/webview',
- element: ,
- children: [
- ...WEBVIEW_FILTER_CONFIG.map(({ path }) => {
- const Page = PAGE_MAP[path];
- return {
- path: path.replace('/webview/', ''),
- element: (
-
-
-
- ),
- };
- }),
- {
- path: 'club/:clubId',
- element: (
-
-
-
- ),
- },
- {
- path: 'club/:clubId/map',
- element: (
-
-
-
- ),
- },
- {
- path: 'club/@:clubName',
- element: (
-
-
-
- ),
- },
- {
- path: 'club/@:clubName/map',
- element: (
-
-
-
- ),
- },
- ],
+ path: '/webview/promotions',
+ element: ,
},
+ { path: '/webview/club/:clubId', element: },
+ { path: '/webview/club/:clubId/map', element: },
+ { path: '/webview/club/@:clubName', element: },
+ { path: '/webview/club/@:clubName/map', element: },
];
export default webviewRoutes;
diff --git a/frontend/src/styles/Global.styles.ts b/frontend/src/styles/Global.styles.ts
index d449cece6..00148e4c1 100644
--- a/frontend/src/styles/Global.styles.ts
+++ b/frontend/src/styles/Global.styles.ts
@@ -9,6 +9,7 @@ const GlobalStyles = createGlobalStyle`
}
html {
overscroll-behavior-y: none;
+ scrollbar-gutter: stable;
}
textarea, button, input, select {
font-family: 'Pretendard', sans-serif;
diff --git a/frontend/src/styles/zIndex.ts b/frontend/src/styles/zIndex.ts
index 463e74c7b..ccb163729 100644
--- a/frontend/src/styles/zIndex.ts
+++ b/frontend/src/styles/zIndex.ts
@@ -1,5 +1,6 @@
export const Z_INDEX = {
header: 1000, // 전역 헤더
+ bottomNav: 1000, // 하단 네비게이션
floatingButton: 1050, // 플로팅 버튼
overlay: 1100, // 모달 오버레이
modal: 1200, // 모달 컨테이너
diff --git a/frontend/src/utils/getAppVersion.ts b/frontend/src/utils/getAppVersion.ts
new file mode 100644
index 000000000..f7913a84c
--- /dev/null
+++ b/frontend/src/utils/getAppVersion.ts
@@ -0,0 +1,6 @@
+const getAppVersion = (): string | null => {
+ const match = navigator.userAgent.match(/MoadongApp\/(\d+\.\d+\.\d+)/);
+ return match ? match[1] : null;
+};
+
+export default getAppVersion;