Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
49118d7
feat(components): 웹 바텀 네비게이션 컴포넌트 추가
seongwon030 Jun 5, 2026
3804dbf
fix(storybook): svg?react import 지원 추가
seongwon030 Jun 5, 2026
e5314c6
refactor(components): 바텀네비 아이콘 CSS mask → svg?react 전환
seongwon030 Jun 5, 2026
075f614
feat: 메뉴/구독 페이지 추가
seongwon030 Jun 5, 2026
26363a4
feat(layout): AppLayout 도입해 핵심 네비 페이지에 바텀네비 마운트
seongwon030 Jun 5, 2026
49f1c57
refactor(header): 헤더를 로고+검색만으로 간소화
seongwon030 Jun 5, 2026
114cfd1
refactor(webview): 웹뷰를 웹 라우트로 통합 (/webview 리다이렉트화)
seongwon030 Jun 5, 2026
f791af5
fix(header): 검색창 반응형 정렬 (데스크톱 우측, 태블릿 이하 꽉 채움)
seongwon030 Jun 5, 2026
309c444
refactor(header): 소개/연합회 페이지 웹뷰에서도 통일 헤더 노출
seongwon030 Jun 5, 2026
5fa1a7e
refactor(webview): 홍보목록 웹뷰 분기 제거 + Filter 헤더 여백 정렬
seongwon030 Jun 5, 2026
2379e16
docs(features): 웹/웹뷰 통합 라우팅으로 WebView 섹션 갱신
seongwon030 Jun 5, 2026
bab73de
fix: lint error
seongwon030 Jun 5, 2026
cbd63d9
fix(bottom-nav): 탭 활성 매핑 보정 + 데스크톱 폭 제한
seongwon030 Jun 5, 2026
ec6fd0e
fix(style): 스크롤바 거터 고정으로 바텀네비 레이아웃 시프트 제거
seongwon030 Jun 7, 2026
c1fdf73
feat(menu): 앱 버전 표시 추가
seongwon030 Jun 7, 2026
d489a6d
fix(menu): 앱 버전 UI를 메뉴 행 형태로 변경
seongwon030 Jun 7, 2026
8dff3d8
feat(layout): 바텀네비 모바일 전용으로 제한 + 데스크톱 헤더 nav 복원
seongwon030 Jun 7, 2026
9888dad
fix(review): PR #1643 리뷰 반영
seongwon030 Jun 8, 2026
c17a503
fix: lint error
seongwon030 Jun 8, 2026
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) 단계에서 진행한다.
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
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` — 카드/구독 버튼 (재사용)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
- `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,44 @@
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;
display: flex;
Comment thread
seongwon030 marked this conversation as resolved.
background-color: #ffffff;
border-top: 1px solid #f0f0f0;
padding-top: 6px;
padding-bottom: calc(6px + env(safe-area-inset-bottom));
z-index: ${Z_INDEX.bottomNav};
`;

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;
`;
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
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) =>
path === '/' ? pathname === '/' : pathname.startsWith(path);
Comment thread
seongwon030 marked this conversation as resolved.
Outdated

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='하단 네비게이션'>
{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.Nav>
);
};

export default BottomNavigation;
4 changes: 2 additions & 2 deletions frontend/src/components/common/Filter/Filter.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading
Loading