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 = () => { )} -