Header.tsx 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  1. 'use client';
  2. import Styles from '../styles/layout.module.scss';
  3. import { useCallback } from 'react';
  4. import Link from 'next/link';
  5. import { usePathname } from 'next/navigation';
  6. import useAuth from '@/hooks/useAuth';
  7. import useDragScroll from '@/hooks/useDragScroll';
  8. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  9. import { faBars, faXmark, faCartShopping, faChevronLeft, faChevronRight } from '@fortawesome/free-solid-svg-icons';
  10. import NotificationBell from '@/app/component/NotificationBell';
  11. import HeaderSearch from '@/app/component/HeaderSearch';
  12. import SearchCommand from '@/app/component/SearchCommand';
  13. import Profile from '@/app/component/Profile';
  14. import Logo from '@/app/component/Logo';
  15. import PointChargeIcon from '@/public/icons/layout/point.svg';
  16. import useCart from '@/hooks/useCart';
  17. import { FEATURE_CHANNEL } from '@/constants/features';
  18. import { useConfigContext } from '@/contexts/configProvider';
  19. // 채널/방송 메뉴(생방송·크리에이터)는 채널 기능 ON 일 때만 노출
  20. const channelNavItems = [
  21. { href: '/live', label: '생방송' },
  22. { href: '/creators', label: '크리에이터' }
  23. ];
  24. // 관리자 관리형 메뉴(config.navMenu)가 비어 있을 때의 폴백 — 기존 하드코딩 목록
  25. const baseNavItems = [
  26. { href: '/market', label: '시장' },
  27. { href: '/stock', label: '종목' },
  28. { href: '/paper', label: '모의투자' },
  29. { href: '/games', label: '게임' },
  30. { href: '/board/briefing', label: '장전 시황' },
  31. { href: '/board/proof', label: '수익인증' },
  32. { href: '/feed/all', label: '피드' },
  33. { href: '/store', label: '상점' },
  34. { href: '/attendance', label: '출석부' },
  35. { href: '/board/notice', label: '고객지원' }
  36. ];
  37. const SCROLL_AMOUNT = 120;
  38. type Props = {
  39. sidebarOpen: boolean;
  40. onToggle: () => void;
  41. // 좌측 사이드바 노출 여부 — false 면 모바일 햄버거(사이드바 토글)도 숨김
  42. showAside?: boolean;
  43. };
  44. export default function Header({ sidebarOpen, onToggle, showAside = true }: Props)
  45. {
  46. const { isAuthenticated } = useAuth();
  47. const { totalCount: cartCount } = useCart();
  48. const pathname = usePathname();
  49. const dragScroll = useDragScroll<HTMLDivElement>();
  50. const config = useConfigContext();
  51. // 상단 메뉴: 관리자 관리형(config.navMenu)이 있으면 사용, 없으면 하드코딩 폴백
  52. const baseItems = config?.navMenu && config.navMenu.length > 0 ? config.navMenu : baseNavItems;
  53. const navItems = FEATURE_CHANNEL ? [...channelNavItems, ...baseItems] : baseItems;
  54. const scrollTabs = useCallback((dir: 'left' | 'right') => {
  55. const el = dragScroll.ref.current;
  56. if (!el) {
  57. return;
  58. }
  59. el.scrollBy({ left: dir === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT, behavior: 'smooth' });
  60. }, [dragScroll.ref]);
  61. const handlePopupCharge = useCallback(() => {
  62. const w = 450, h = 635;
  63. const left = window.screenX + (window.outerWidth - w) / 2;
  64. const top = window.screenY + (window.outerHeight - h) / 2;
  65. window.open('/charge', 'charge', `width=${w},height=${h},left=${left},top=${top},scrollbars=no,resizable=no`);
  66. }, []);
  67. return (
  68. <header id='header' className={Styles.header}>
  69. {/* 1줄: 로고 + 우측 아이콘 (PC에서는 전체 내비) */}
  70. <div className={Styles.headerRow1}>
  71. {/* 햄버거 — 좌측 사이드바(채널 or 관심종목) 슬라이드 토글 (모바일 전용). 사이드바 없는 페이지에선 미표시 */}
  72. {showAside && (
  73. <button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴' aria-expanded={sidebarOpen}>
  74. <FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
  75. </button>
  76. )}
  77. <Link href='/' className={Styles.logo} aria-label="개미투자 홈">
  78. <Logo size="sm" />
  79. </Link>
  80. {/* PC 내비게이션 */}
  81. <nav className={Styles.pcNav} aria-label='주요 메뉴'>
  82. <ul className='flex gap-4'>
  83. {navItems.map(item => (
  84. <li key={item.label}>
  85. <Link href={item.href}>{item.label}</Link>
  86. </li>
  87. ))}
  88. </ul>
  89. </nav>
  90. {/* 우측 아이콘 (항상 표시) — 통합 검색을 장바구니 바로 좌측에 배치 */}
  91. <div className={Styles.headerActions}>
  92. {/* 통합 검색 — 디바운스 suggest + combobox (Ctrl+K/'/' 포커스 포함) */}
  93. <HeaderSearch />
  94. <Link href="/cart" className={Styles.cartBtn} title="장바구니" aria-label="장바구니">
  95. <FontAwesomeIcon icon={faCartShopping} />
  96. {cartCount > 0 && (
  97. <span className={Styles.cartBadge}>{cartCount > 99 ? '99+' : cartCount}</span>
  98. )}
  99. </Link>
  100. {isAuthenticated && (
  101. <>
  102. <button type="button" className={Styles.chargeBtn} onClick={handlePopupCharge} title="캐시 충전">
  103. <PointChargeIcon width={24} height={24} />
  104. </button>
  105. <NotificationBell />
  106. </>
  107. )}
  108. <Profile />
  109. </div>
  110. </div>
  111. {/* 2줄: 모바일 전용 가로 스크롤 탭 */}
  112. <nav className={Styles.headerRow2} aria-label='주요 메뉴 (모바일)'>
  113. <button type='button' className={Styles.scrollArrow} onClick={() => scrollTabs('left')} aria-label='왼쪽 스크롤'>
  114. <FontAwesomeIcon icon={faChevronLeft} />
  115. </button>
  116. <div
  117. className={Styles.mobileTabScroll}
  118. ref={dragScroll.ref}
  119. onMouseDown={dragScroll.onMouseDown}
  120. onMouseMove={dragScroll.onMouseMove}
  121. onMouseUp={dragScroll.onMouseUp}
  122. onMouseLeave={dragScroll.onMouseLeave}
  123. >
  124. {navItems.map(item => (
  125. <Link
  126. key={item.label}
  127. href={item.href}
  128. className={`${Styles.mobileTab}${pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href)) ? ` ${Styles.mobileTabActive}` : ''}`}
  129. >
  130. {item.label}
  131. </Link>
  132. ))}
  133. </div>
  134. <button type='button' className={Styles.scrollArrow} onClick={() => scrollTabs('right')} aria-label='오른쪽 스크롤'>
  135. <FontAwesomeIcon icon={faChevronRight} />
  136. </button>
  137. </nav>
  138. {/* 지수 티커 스트립 — TradingView 임베드 컨테이너 (규격만, 내용 주입 전까지 :empty 로 미표시) */}
  139. <div id='header-ticker' className={Styles.tickerStrip} aria-hidden='true' />
  140. {/* Ctrl+K / ⌘K 커맨드 팔레트 — 전역 (렌더 없음, 열릴 때만 표시) */}
  141. <SearchCommand />
  142. </header>
  143. );
  144. }