Skip to content
Open
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
3 changes: 2 additions & 1 deletion frontend/.storybook/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
});
},
};
Expand Down
15 changes: 7 additions & 8 deletions frontend/docs/claude/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 태그 (소셜 미디어 공유 미리보기)

Expand Down
42 changes: 42 additions & 0 deletions frontend/docs/features/components/bottom-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 웹 바텀 네비게이션 (BottomNavigation)

앱 네이티브 바텀탭(React Navigation)을 웹으로 이식한 하단 고정 네비게이션. 웹/웹뷰 통합 마이그레이션의 일부로, RN `app/(tabs)/_layout.tsx` 스펙을 그대로 재현했다.

## 탭 구성

| 탭 | 경로 | 아이콘 |
| ---- | ---------------- | --------------------------------------------- |
| 홈 | `/` | home.svg (mask 틴팅) |
| 구독 | `/subscriptions` | subscribe_selected / _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의 배경 `<rect fill="white"/>`는 제거했다(아이콘 패스는 원본 유지). 구독 아이콘은 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 바에 콘텐츠가 가리지 않게 한다.
31 changes: 31 additions & 0 deletions frontend/docs/features/menu/menu-page.md
Original file line number Diff line number Diff line change
@@ -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` 라우트

## 참고

아직 공용 레이아웃(헤더/바텀네비)에 부착되지 않은 독립 페이지다. 부착은 레이아웃 셸(AppLayout) 단계에서 진행한다.
24 changes: 24 additions & 0 deletions frontend/docs/features/subscriptions/subscriptions-page.md
Original file line number Diff line number Diff line change
@@ -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/WebviewMainPage/components/SubscribeButton/SubscribeButton.tsx` — 카드/구독 버튼 (재사용)
- `src/utils/appStoreLink.ts` (`getAppStoreLink`) — 웹 CTA 링크
- `src/routes/AppRoutes.tsx` — `/subscriptions` 라우트

## 참고

아직 공용 레이아웃(헤더/바텀네비)에 부착되지 않은 독립 페이지다. 부착은 레이아웃 셸(AppLayout) 단계에서 진행한다.
3 changes: 3 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -25,6 +27,7 @@ const App = () => {
return (
<>
<GlobalStyles />
{isInAppWebView() && <WebviewGlobalStyles />}
<GlobalBoundary>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/assets/images/icons/bottomNav/home.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/assets/images/icons/bottomNav/menu.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/images/icons/menu/chevron.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/assets/images/icons/menu/document.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
5 changes: 5 additions & 0 deletions frontend/src/assets/images/icons/menu/info.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/assets/images/icons/menu/people.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -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<typeof BottomNavigation>;

export default meta;
type Story = StoryObj<typeof meta>;

export const Home: Story = {
decorators: [
(Story) => (
<MemoryRouter initialEntries={['/']}>
<Story />
</MemoryRouter>
),
],
};

export const Subscriptions: Story = {
decorators: [
(Story) => (
<MemoryRouter initialEntries={['/subscriptions']}>
<Story />
</MemoryRouter>
),
],
};

export const Menu: Story = {
decorators: [
(Story) => (
<MemoryRouter initialEntries={['/menu']}>
<Story />
</MemoryRouter>
),
],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import styled from 'styled-components';
import { theme } from '@/styles/theme';
import { Z_INDEX } from '@/styles/zIndex';

export const Nav = styled.nav`
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;
`;
111 changes: 111 additions & 0 deletions frontend/src/components/common/BottomNavigation/BottomNavigation.tsx
Original file line number Diff line number Diff line change
@@ -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<React.SVGProps<SVGSVGElement>>;

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: 'explore',
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 <Icon width={28} height={28} aria-hidden />;
}
return (
<Styled.ImageIcon
src={active ? icon.active : icon.inactive}
alt=''
aria-hidden
/>
);
};

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);
};

return (
<Styled.Nav aria-label='하단 네비게이션'>
<Styled.Inner>
{TABS.map((tab) => {
const active = isTabActive(pathname, tab.path);
return (
<Styled.Tab
key={tab.key}
type='button'
$active={active}
aria-current={active ? 'page' : undefined}
onClick={() => handleTabClick(tab)}
>
{renderIcon(tab.icon, active)}
<Styled.Label>{tab.label}</Styled.Label>
</Styled.Tab>
);
})}
</Styled.Inner>
</Styled.Nav>
);
};

export default BottomNavigation;
Loading
Loading