diff --git a/.gitignore b/.gitignore index 218c29a34..d849f5293 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dailyNote/ +/.omc \ No newline at end of file diff --git a/frontend/.gitignore b/frontend/.gitignore index f8d648482..0fb107ba4 100644 --- a/frontend/.gitignore +++ b/frontend/.gitignore @@ -23,3 +23,9 @@ public/sitemap.xml .env.sentry-build-plugin AGENTS.md + +# oh-my-claudecode +.omc/ + +# Claude Code (personal settings) +.claude/ diff --git a/frontend/docs/features/components/header.md b/frontend/docs/features/components/header.md new file mode 100644 index 000000000..b085a1a61 --- /dev/null +++ b/frontend/docs/features/components/header.md @@ -0,0 +1,36 @@ +# Header 컴포넌트 구조 + +`Header` 컴포넌트는 UI 렌더링만 담당하고, 비즈니스 로직은 전용 훅으로 분리되어 있다. + +## 역할 분리 + +| 관심사 | 담당 | +|--------|------| +| 네비게이션 + 트래킹 | `useHeaderNavigation` | +| 렌더 조건 판단 (showOn/hideOn) | `useHeaderVisibility` | +| 스크롤 감지 | `useScrollDetection` | +| UI 렌더링 | `Header.tsx` | + +## useHeaderVisibility + +`showOn`, `hideOn` props를 받아 현재 디바이스에서 Header를 렌더링할지 결정한다. + +```ts +const isVisible = useHeaderVisibility(showOn, hideOn); +if (!isVisible) return null; +``` + +- `hideOn`이 `showOn`보다 우선순위가 높다 +- 내부에서 `useDevice`, `isInAppWebView`를 호출해 현재 디바이스 타입을 판단 +- `DeviceType`은 `src/types/device.ts`에서 공통 관리 + +## useHeaderNavigation + +네비게이션 이동과 Mixpanel 트래킹을 함께 처리한다. `handleMenuClose`를 통해 모바일 메뉴 닫기 트래킹도 담당. + +## 관련 코드 + +- `src/components/common/Header/Header.tsx` — UI 렌더링 +- `src/hooks/Header/useHeaderVisibility.ts` — 렌더 조건 훅 +- `src/hooks/Header/useHeaderNavigation.ts` — 네비게이션 + 트래킹 훅 +- `src/types/device.ts` — DeviceType 공통 타입 diff --git a/frontend/src/components/common/Header/Header.tsx b/frontend/src/components/common/Header/Header.tsx index 3c0ad32ac..33a69a3bc 100644 --- a/frontend/src/components/common/Header/Header.tsx +++ b/frontend/src/components/common/Header/Header.tsx @@ -3,64 +3,35 @@ 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'; import AdminProfile from '@/components/common/Header/admin/AdminProfile'; -import { USER_EVENT } from '@/constants/eventName'; import useHeaderNavigation from '@/hooks/Header/useHeaderNavigation'; -import useMixpanelTrack from '@/hooks/Mixpanel/useMixpanelTrack'; +import useHeaderVisibility from '@/hooks/Header/useHeaderVisibility'; import { useScrollDetection } from '@/hooks/Scroll/useScrollDetection'; -import useDevice from '@/hooks/useDevice'; import SearchBox from '@/pages/MainPage/components/SearchBox/SearchBox'; -import isInAppWebView from '@/utils/isInAppWebView'; +import { DeviceType } from '@/types/device'; import * as Styled from './Header.styles'; -type DeviceType = 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'webview'; - interface HeaderProps { showOn?: DeviceType[]; hideOn?: DeviceType[]; } const Header = ({ showOn, hideOn }: HeaderProps) => { - const trackEvent = useMixpanelTrack(); const location = useLocation(); const [isMenuOpen, setIsMenuOpen] = useState(false); const isScrolled = useScrollDetection(); - const { isMobile, isTablet, isLaptop, isDesktop } = useDevice(); + const isVisible = useHeaderVisibility(showOn, hideOn); const { handleHomeClick, handleIntroduceClick, handleClubUnionClick, handlePromotionClick, + handleMenuClose, } = useHeaderNavigation(); const isAdminPage = location.pathname.startsWith('/admin'); const isAdminLoginPage = location.pathname.startsWith('/admin/login'); - const isWebView = isInAppWebView(); - - const getCurrentDeviceTypes = (): DeviceType[] => { - const types: DeviceType[] = []; - if (isMobile) types.push('mobile'); - if (isTablet) types.push('tablet'); - if (isLaptop) types.push('laptop'); - if (isDesktop) types.push('desktop'); - if (isWebView) types.push('webview'); - return types; - }; - - const shouldRender = (): boolean => { - const currentTypes = getCurrentDeviceTypes(); - - if (hideOn) { - return !hideOn.some((type) => currentTypes.includes(type)); - } - if (showOn) { - return showOn.some((type) => currentTypes.includes(type)); - } - - return true; - }; - - if (!shouldRender()) { + if (!isVisible) { return null; } @@ -80,9 +51,14 @@ const Header = ({ showOn, hideOn }: HeaderProps) => { const closeMenu = () => { setIsMenuOpen(false); - trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED); }; - const toggleMenu = () => setIsMenuOpen((prev) => !prev); + const toggleMenu = () => { + setIsMenuOpen((prev) => { + const next = !prev; + if (prev && !next) handleMenuClose(); + return next; + }); + }; return ( diff --git a/frontend/src/hooks/Header/useHeaderNavigation.ts b/frontend/src/hooks/Header/useHeaderNavigation.ts index 3097f4e3a..1b1722f84 100644 --- a/frontend/src/hooks/Header/useHeaderNavigation.ts +++ b/frontend/src/hooks/Header/useHeaderNavigation.ts @@ -37,12 +37,17 @@ const useHeaderNavigation = () => { trackEvent(USER_EVENT.ADMIN_BUTTON_CLICKED); }, [navigate, trackEvent]); + const handleMenuClose = useCallback(() => { + trackEvent(USER_EVENT.MOBILE_MENU_DELETE_BUTTON_CLICKED); + }, [trackEvent]); + return { handleHomeClick, handleIntroduceClick, handleClubUnionClick, handlePromotionClick, handleAdminClick, + handleMenuClose, }; }; diff --git a/frontend/src/hooks/Header/useHeaderVisibility.test.ts b/frontend/src/hooks/Header/useHeaderVisibility.test.ts new file mode 100644 index 000000000..f51bbd995 --- /dev/null +++ b/frontend/src/hooks/Header/useHeaderVisibility.test.ts @@ -0,0 +1,193 @@ +import { renderHook } from '@testing-library/react'; +import useDevice from '@/hooks/useDevice'; +import { DeviceType } from '@/types/device'; +import isInAppWebView from '@/utils/isInAppWebView'; +import useHeaderVisibility from './useHeaderVisibility'; + +jest.mock('@/hooks/useDevice'); +jest.mock('@/utils/isInAppWebView'); + +const mockUseDevice = useDevice as jest.Mock; +const mockIsInAppWebView = isInAppWebView as jest.Mock; + +const setupDevice = ( + overrides: Partial< + Record<'isMobile' | 'isTablet' | 'isLaptop' | 'isDesktop', boolean> + > = {}, +) => { + mockUseDevice.mockReturnValue({ + isMobile: false, + isTablet: false, + isLaptop: false, + isDesktop: true, + ...overrides, + }); +}; + +describe('useHeaderVisibility 테스트', () => { + beforeEach(() => { + jest.clearAllMocks(); + setupDevice(); + mockIsInAppWebView.mockReturnValue(false); + }); + + describe('props 없을 때 (기본값)', () => { + it('showOn, hideOn 모두 없으면 항상 true를 반환한다', () => { + // Given & When + const { result } = renderHook(() => useHeaderVisibility()); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('hideOn 테스트', () => { + it('현재 디바이스가 hideOn에 포함되면 false를 반환한다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['desktop']), + ); + + // Then + expect(result.current).toBe(false); + }); + + it('현재 디바이스가 hideOn에 포함되지 않으면 true를 반환한다', () => { + // Given + setupDevice({ isMobile: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['desktop']), + ); + + // Then + expect(result.current).toBe(true); + }); + + it('hideOn에 여러 디바이스가 있을 때 하나라도 일치하면 false를 반환한다', () => { + // Given + setupDevice({ isTablet: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['mobile', 'tablet'] as DeviceType[]), + ); + + // Then + expect(result.current).toBe(false); + }); + + it('webview 환경에서 hideOn에 webview가 포함되면 false를 반환한다', () => { + // Given + mockIsInAppWebView.mockReturnValue(true); + + // When + const { result } = renderHook(() => + useHeaderVisibility(undefined, ['webview']), + ); + + // Then + expect(result.current).toBe(false); + }); + }); + + describe('showOn 테스트', () => { + it('현재 디바이스가 showOn에 포함되면 true를 반환한다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'])); + + // Then + expect(result.current).toBe(true); + }); + + it('현재 디바이스가 showOn에 포함되지 않으면 false를 반환한다', () => { + // Given + setupDevice({ isMobile: true, isDesktop: false }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'])); + + // Then + expect(result.current).toBe(false); + }); + + it('showOn에 여러 디바이스가 있을 때 하나라도 일치하면 true를 반환한다', () => { + // Given + setupDevice({ isTablet: true, isDesktop: false }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(['mobile', 'tablet'] as DeviceType[]), + ); + + // Then + expect(result.current).toBe(true); + }); + + it('webview 환경에서 showOn에 webview가 포함되면 true를 반환한다', () => { + // Given + setupDevice({ isDesktop: false }); + mockIsInAppWebView.mockReturnValue(true); + + // When + const { result } = renderHook(() => useHeaderVisibility(['webview'])); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('빈 배열 경계 조건', () => { + it('hideOn=[]일 때 showOn이 무시되지 않고 평가된다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility(['desktop'], [])); + + // Then + expect(result.current).toBe(true); + }); + + it('showOn=[]일 때 true를 반환한다 (기본값 fallback)', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => useHeaderVisibility([])); + + // Then + expect(result.current).toBe(true); + }); + + it('hideOn=[], showOn=[]일 때 true를 반환한다', () => { + // Given & When + const { result } = renderHook(() => useHeaderVisibility([], [])); + + // Then + expect(result.current).toBe(true); + }); + }); + + describe('hideOn이 showOn보다 우선순위가 높다', () => { + it('hideOn과 showOn이 동시에 있을 때 hideOn이 우선 적용된다', () => { + // Given + setupDevice({ isDesktop: true }); + + // When + const { result } = renderHook(() => + useHeaderVisibility(['desktop'], ['desktop']), + ); + + // Then + expect(result.current).toBe(false); + }); + }); +}); diff --git a/frontend/src/hooks/Header/useHeaderVisibility.ts b/frontend/src/hooks/Header/useHeaderVisibility.ts new file mode 100644 index 000000000..2b0e7a14e --- /dev/null +++ b/frontend/src/hooks/Header/useHeaderVisibility.ts @@ -0,0 +1,22 @@ +import useDevice from '@/hooks/useDevice'; +import { DeviceType } from '@/types/device'; +import isInAppWebView from '@/utils/isInAppWebView'; + +const useHeaderVisibility = (showOn?: DeviceType[], hideOn?: DeviceType[]) => { + const { isMobile, isTablet, isLaptop, isDesktop } = useDevice(); + const isWebView = isInAppWebView(); + + const currentTypes: DeviceType[] = [ + isMobile && 'mobile', + isTablet && 'tablet', + isLaptop && 'laptop', + isDesktop && 'desktop', + isWebView && 'webview', + ].filter(Boolean) as DeviceType[]; + + if (hideOn?.length) return !hideOn.some((t) => currentTypes.includes(t)); + if (showOn?.length) return showOn.some((t) => currentTypes.includes(t)); + return true; +}; + +export default useHeaderVisibility; diff --git a/frontend/src/types/device.ts b/frontend/src/types/device.ts new file mode 100644 index 000000000..a5d24a8ef --- /dev/null +++ b/frontend/src/types/device.ts @@ -0,0 +1 @@ +export type DeviceType = 'mobile' | 'tablet' | 'laptop' | 'desktop' | 'webview';