Header.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. 'use client';
  2. import Styles from '../styles/layout.module.scss';
  3. import { useCallback, useEffect, useRef } 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 Profile from '@/app/component/Profile';
  12. import Logo from '@/app/component/Logo';
  13. import PointChargeIcon from '@/public/icons/layout/point.svg';
  14. import useCart from '@/hooks/useCart';
  15. import { FEATURE_CHANNEL } from '@/constants/features';
  16. // 채널/방송 메뉴(생방송·크리에이터)는 채널 기능 ON 일 때만 노출
  17. const channelNavItems = [
  18. { href: '/live', label: '생방송' },
  19. { href: '/creators', label: '크리에이터' }
  20. ];
  21. const baseNavItems = [
  22. { href: '/games', label: '게임' },
  23. { href: '/feed/all', label: '피드' },
  24. { href: '/store', label: '상점' },
  25. { href: '/attendance', label: '출석부' },
  26. { href: '/board/notice', label: '고객지원' }
  27. ];
  28. const navItems = FEATURE_CHANNEL ? [...channelNavItems, ...baseNavItems] : baseNavItems;
  29. const SCROLL_AMOUNT = 120;
  30. type Props = {
  31. sidebarOpen: boolean;
  32. onToggle: () => void;
  33. };
  34. export default function Header({ sidebarOpen, onToggle }: Props)
  35. {
  36. const { isAuthenticated } = useAuth();
  37. const { totalCount: cartCount } = useCart();
  38. const pathname = usePathname();
  39. const dragScroll = useDragScroll<HTMLDivElement>();
  40. const searchRef = useRef<HTMLInputElement>(null);
  41. // 통합 검색 포커스 단축키: Ctrl+K(⌘+K) / '/' (finviz 관례) — 검색 동작 자체는 후속
  42. useEffect(() => {
  43. const handler = (e: KeyboardEvent) => {
  44. if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'k') {
  45. e.preventDefault();
  46. searchRef.current?.focus();
  47. return;
  48. }
  49. if (e.key === '/' && !e.ctrlKey && !e.metaKey && !e.altKey) {
  50. const target = e.target as HTMLElement|null;
  51. const tag = target?.tagName;
  52. if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
  53. return;
  54. }
  55. e.preventDefault();
  56. searchRef.current?.focus();
  57. }
  58. };
  59. window.addEventListener('keydown', handler);
  60. return () => {
  61. window.removeEventListener('keydown', handler);
  62. };
  63. }, []);
  64. const scrollTabs = useCallback((dir: 'left' | 'right') => {
  65. const el = dragScroll.ref.current;
  66. if (!el) {
  67. return;
  68. }
  69. el.scrollBy({ left: dir === 'left' ? -SCROLL_AMOUNT : SCROLL_AMOUNT, behavior: 'smooth' });
  70. }, [dragScroll.ref]);
  71. const handlePopupCharge = useCallback(() => {
  72. const w = 450, h = 635;
  73. const left = window.screenX + (window.outerWidth - w) / 2;
  74. const top = window.screenY + (window.outerHeight - h) / 2;
  75. window.open('/charge', 'charge', `width=${w},height=${h},left=${left},top=${top},scrollbars=no,resizable=no`);
  76. }, []);
  77. return (
  78. <header id='header' className={Styles.header}>
  79. {/* 1줄: 로고 + 우측 아이콘 (PC에서는 전체 내비) */}
  80. <div className={Styles.headerRow1}>
  81. {/* 햄버거 — 좌측 사이드바(채널 or 관심종목 자리표시) 슬라이드 토글 (모바일 전용) */}
  82. <button type='button' className={Styles.hamburger} onClick={onToggle} aria-label='메뉴' aria-expanded={sidebarOpen}>
  83. <FontAwesomeIcon icon={sidebarOpen ? faXmark : faBars} />
  84. </button>
  85. <Link href='/' className={Styles.logo} aria-label="개미투자 홈">
  86. <Logo size="sm" />
  87. </Link>
  88. {/* PC 내비게이션 */}
  89. <nav className={Styles.pcNav} aria-label='주요 메뉴'>
  90. <ul className='flex gap-4'>
  91. {navItems.map(item => (
  92. <li key={item.label}>
  93. <Link href={item.href}>{item.label}</Link>
  94. </li>
  95. ))}
  96. </ul>
  97. </nav>
  98. {/* 중앙 통합 검색 — UI + Ctrl+K/'/' 포커스만 (자동완성·커맨드 팔레트는 후속 D6-4) */}
  99. <div className={Styles.searchBox} role='search'>
  100. <input
  101. ref={searchRef}
  102. type='search'
  103. className={Styles.searchInput}
  104. placeholder='종목·글·유저 검색'
  105. aria-label='통합 검색'
  106. autoComplete='off'
  107. />
  108. <kbd className={Styles.searchKbd} aria-hidden='true'>Ctrl K</kbd>
  109. </div>
  110. {/* 우측 아이콘 (항상 표시) */}
  111. <div className={Styles.headerActions}>
  112. <Link href="/cart" className={Styles.cartBtn} title="장바구니" aria-label="장바구니">
  113. <FontAwesomeIcon icon={faCartShopping} />
  114. {cartCount > 0 && (
  115. <span className={Styles.cartBadge}>{cartCount > 99 ? '99+' : cartCount}</span>
  116. )}
  117. </Link>
  118. {isAuthenticated && (
  119. <>
  120. <button type="button" className={Styles.chargeBtn} onClick={handlePopupCharge} title="포인트 충전">
  121. <PointChargeIcon width={24} height={24} />
  122. </button>
  123. <NotificationBell />
  124. </>
  125. )}
  126. <Profile />
  127. </div>
  128. </div>
  129. {/* 2줄: 모바일 전용 가로 스크롤 탭 */}
  130. <nav className={Styles.headerRow2} aria-label='주요 메뉴 (모바일)'>
  131. <button type='button' className={Styles.scrollArrow} onClick={() => scrollTabs('left')} aria-label='왼쪽 스크롤'>
  132. <FontAwesomeIcon icon={faChevronLeft} />
  133. </button>
  134. <div
  135. className={Styles.mobileTabScroll}
  136. ref={dragScroll.ref}
  137. onMouseDown={dragScroll.onMouseDown}
  138. onMouseMove={dragScroll.onMouseMove}
  139. onMouseUp={dragScroll.onMouseUp}
  140. onMouseLeave={dragScroll.onMouseLeave}
  141. >
  142. {navItems.map(item => (
  143. <Link
  144. key={item.label}
  145. href={item.href}
  146. className={`${Styles.mobileTab}${pathname === item.href || (item.href !== '/' && pathname.startsWith(item.href)) ? ` ${Styles.mobileTabActive}` : ''}`}
  147. >
  148. {item.label}
  149. </Link>
  150. ))}
  151. </div>
  152. <button type='button' className={Styles.scrollArrow} onClick={() => scrollTabs('right')} aria-label='오른쪽 스크롤'>
  153. <FontAwesomeIcon icon={faChevronRight} />
  154. </button>
  155. </nav>
  156. {/* 지수 티커 스트립 — TradingView 임베드 컨테이너 (규격만, 내용 주입 전까지 :empty 로 미표시) */}
  157. <div id='header-ticker' className={Styles.tickerStrip} aria-hidden='true' />
  158. </header>
  159. );
  160. }